diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 33de108ac..5894cf681 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -101,6 +101,7 @@ "common_change_password": "Change Password", "common_create_new_album": "Create new album", "common_shared": "Shared", + "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", "control_bottom_app_bar_add_to_album": "Add to album", "control_bottom_app_bar_album_info": "{} items", "control_bottom_app_bar_album_info_shared": "{} items ยท Shared", diff --git a/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart b/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart index a07791e5f..f53ae043d 100644 --- a/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart +++ b/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart @@ -52,6 +52,8 @@ class AlbumThumbnailListTile extends StatelessWidget { ), httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, cacheKey: getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.JPEG), + errorWidget: (context, url, error) => + const Icon(Icons.image_not_supported_outlined), ); } diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index d5076ca7b..ae1e502ef 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -18,6 +18,7 @@ import 'package:immich_mobile/shared/services/asset.service.dart'; import 'package:immich_mobile/modules/home/ui/delete_dialog.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; +import 'package:immich_mobile/shared/ui/immich_image.dart'; import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart'; import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart'; import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart'; @@ -38,8 +39,7 @@ class GalleryViewerPage extends HookConsumerWidget { super.key, required this.assetList, required this.asset, - }) : controller = - PageController(initialPage: assetList.indexOf(asset)); + }) : controller = PageController(initialPage: assetList.indexOf(asset)); Asset? assetDetail; @@ -139,12 +139,16 @@ class GalleryViewerPage extends HookConsumerWidget { } void precacheNextImage(int index) { - if (index < assetList.length && index > 0) { + if (index < assetList.length && index >= 0) { final asset = assetList[index]; + if (asset.isLocal) { // Preload the local asset precacheImage(localImageProvider(asset), context); } else { + onError(Object exception, StackTrace? stackTrace) { + // swallow error silently + } // Probably load WEBP either way precacheImage( remoteThumbnailImageProvider( @@ -152,6 +156,7 @@ class GalleryViewerPage extends HookConsumerWidget { api.ThumbnailFormat.WEBP, ), context, + onError: onError, ); if (isLoadPreview.value) { // Precache the JPEG thumbnail @@ -161,6 +166,7 @@ class GalleryViewerPage extends HookConsumerWidget { api.ThumbnailFormat.JPEG, ), context, + onError: onError, ); } if (isLoadOriginal.value) { @@ -168,6 +174,7 @@ class GalleryViewerPage extends HookConsumerWidget { precacheImage( originalImageProvider(asset), context, + onError: onError, ); } } @@ -350,27 +357,37 @@ class GalleryViewerPage extends HookConsumerWidget { type: api.ThumbnailFormat.WEBP, ), httpHeaders: {'Authorization': authToken}, - progressIndicatorBuilder: (_, __, ___) => const Center( + progressIndicatorBuilder: (_, __, ___) => + const Center( child: ImmichLoadingIndicator(), ), fadeInDuration: const Duration(milliseconds: 0), fit: BoxFit.contain, + errorWidget: (context, url, error) => + const Icon(Icons.image_not_supported_outlined), ); - return CachedNetworkImage( - imageUrl: getThumbnailUrl( - asset, - type: api.ThumbnailFormat.JPEG, - ), - cacheKey: getThumbnailCacheKey( - asset, - type: api.ThumbnailFormat.JPEG, - ), - httpHeaders: {'Authorization': authToken}, - fit: BoxFit.contain, - fadeInDuration: const Duration(milliseconds: 0), - placeholder: (_, __) => webPThumbnail, - ); + if (isLoadOriginal.value) { + // loading the preview in the loadingBuilder only + // makes sense if the original is loaded in the builder + return CachedNetworkImage( + imageUrl: getThumbnailUrl( + asset, + type: api.ThumbnailFormat.JPEG, + ), + cacheKey: getThumbnailCacheKey( + asset, + type: api.ThumbnailFormat.JPEG, + ), + httpHeaders: {'Authorization': authToken}, + fit: BoxFit.contain, + fadeInDuration: const Duration(milliseconds: 0), + placeholder: (_, __) => webPThumbnail, + errorWidget: (_, __, ___) => webPThumbnail, + ); + } else { + return webPThumbnail; + } } else { return Image( image: localThumbnailImageProvider(asset), @@ -389,17 +406,23 @@ class GalleryViewerPage extends HookConsumerWidget { } else { if (isLoadOriginal.value) { provider = originalImageProvider(assetList[index]); - } else { + } else if (isLoadPreview.value) { provider = remoteThumbnailImageProvider( assetList[index], api.ThumbnailFormat.JPEG, ); + } else { + provider = remoteThumbnailImageProvider( + assetList[index], + api.ThumbnailFormat.WEBP, + ); } } return PhotoViewGalleryPageOptions( onDragStart: (_, details, __) => localPosition = details.localPosition, - onDragUpdate: (_, details, __) => handleSwipeUpDown(details), + onDragUpdate: (_, details, __) => + handleSwipeUpDown(details), onTapDown: (_, __, ___) => showAppBar.value = !showAppBar.value, imageProvider: provider, @@ -409,12 +432,17 @@ class GalleryViewerPage extends HookConsumerWidget { filterQuality: FilterQuality.high, tightMode: true, minScale: PhotoViewComputedScale.contained, + errorBuilder: (context, error, stackTrace) => ImmichImage( + assetList[indexOfAsset.value], + fit: BoxFit.contain, + ), ); } else { return PhotoViewGalleryPageOptions.customChild( onDragStart: (_, details, __) => localPosition = details.localPosition, - onDragUpdate: (_, details, __) => handleSwipeUpDown(details), + onDragUpdate: (_, details, __) => + handleSwipeUpDown(details), heroAttributes: PhotoViewHeroAttributes( tag: assetList[index].id, ), diff --git a/mobile/lib/modules/home/ui/home_page_app_bar.dart b/mobile/lib/modules/home/ui/home_page_app_bar.dart index e52ed47be..4d37ff326 100644 --- a/mobile/lib/modules/home/ui/home_page_app_bar.dart +++ b/mobile/lib/modules/home/ui/home_page_app_bar.dart @@ -66,6 +66,8 @@ class HomePageAppBar extends ConsumerWidget with PreferredSizeWidget { image: '$endpoint/user/profile-image/${authState.userId}?d=${dummy++}', fadeInDuration: const Duration(milliseconds: 200), + imageErrorBuilder: (context, error, stackTrace) => + Image.memory(kTransparentImage), ), ), ), diff --git a/mobile/lib/modules/home/ui/profile_drawer/profile_drawer_header.dart b/mobile/lib/modules/home/ui/profile_drawer/profile_drawer_header.dart index fad1055eb..ad05bc782 100644 --- a/mobile/lib/modules/home/ui/profile_drawer/profile_drawer_header.dart +++ b/mobile/lib/modules/home/ui/profile_drawer/profile_drawer_header.dart @@ -40,6 +40,8 @@ class ProfileDrawerHeader extends HookConsumerWidget { image: '$endpoint/user/profile-image/${authState.userId}?d=${dummy++}', fadeInDuration: const Duration(milliseconds: 200), + imageErrorBuilder: (context, error, stackTrace) => + Image.memory(kTransparentImage), ), ), ); diff --git a/mobile/lib/modules/home/ui/profile_drawer/server_info_box.dart b/mobile/lib/modules/home/ui/profile_drawer/server_info_box.dart index 2fb959ee6..a9ad51471 100644 --- a/mobile/lib/modules/home/ui/profile_drawer/server_info_box.dart +++ b/mobile/lib/modules/home/ui/profile_drawer/server_info_box.dart @@ -106,7 +106,9 @@ class ServerInfoBox extends HookConsumerWidget { ), ), Text( - "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch_}", + serverInfoState.serverVersion.major > 0 + ? "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch_}" + : "?", style: TextStyle( fontSize: 11, color: Colors.grey[500], diff --git a/mobile/lib/modules/login/providers/authentication.provider.dart b/mobile/lib/modules/login/providers/authentication.provider.dart index 7237d23cc..78c3f9357 100644 --- a/mobile/lib/modules/login/providers/authentication.provider.dart +++ b/mobile/lib/modules/login/providers/authentication.provider.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hive/hive.dart'; @@ -145,7 +147,14 @@ class AuthenticationNotifier extends StateNotifier { required String serverUrl, }) async { _apiService.setAccessToken(accessToken); - var userResponseDto = await _apiService.userApi.getMyUserInfo(); + UserResponseDto? userResponseDto; + try { + userResponseDto = await _apiService.userApi.getMyUserInfo(); + } on ApiException catch (e) { + if (e.innerException is SocketException) { + state = state.copyWith(isAuthenticated: true); + } + } if (userResponseDto != null) { var userInfoHiveBox = await Hive.openBox(userInfoBox); @@ -200,7 +209,7 @@ class AuthenticationNotifier extends StateNotifier { state = state.copyWith(deviceInfo: deviceInfo); } catch (e) { debugPrint("ERROR Register Device Info: $e"); - return false; + return e is ApiException && e.innerException is SocketException; } return true; diff --git a/mobile/lib/modules/search/ui/thumbnail_with_info.dart b/mobile/lib/modules/search/ui/thumbnail_with_info.dart index 932d7f8ec..de88716d1 100644 --- a/mobile/lib/modules/search/ui/thumbnail_with_info.dart +++ b/mobile/lib/modules/search/ui/thumbnail_with_info.dart @@ -53,6 +53,8 @@ class ThumbnailWithInfo extends StatelessWidget { httpHeaders: { "Authorization": "Bearer ${box.get(accessTokenKey)}" }, + errorWidget: (context, url, error) => + const Icon(Icons.image_not_supported_outlined), ), ) : Center( diff --git a/mobile/lib/modules/search/views/search_result_page.dart b/mobile/lib/modules/search/views/search_result_page.dart index 83fd5107d..40f330587 100644 --- a/mobile/lib/modules/search/views/search_result_page.dart +++ b/mobile/lib/modules/search/views/search_result_page.dart @@ -119,7 +119,10 @@ class SearchResultPage extends HookConsumerWidget { settings.getSetting(AppSettingsEnum.storageIndicator); if (searchResultPageState.isError) { - return const Text("Error"); + return Padding( + padding: const EdgeInsets.all(12), + child: const Text("common_server_error").tr(), + ); } if (searchResultPageState.isLoading) { diff --git a/mobile/lib/routing/auth_guard.dart b/mobile/lib/routing/auth_guard.dart index 4e907a768..0003c5336 100644 --- a/mobile/lib/routing/auth_guard.dart +++ b/mobile/lib/routing/auth_guard.dart @@ -1,9 +1,10 @@ +import 'dart:io'; + import 'package:auto_route/auto_route.dart'; import 'package:flutter/foundation.dart'; -import 'package:hive/hive.dart'; -import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/services/api.service.dart'; +import 'package:openapi/api.dart'; class AuthGuard extends AutoRouteGuard { final ApiService _apiService; @@ -11,11 +12,6 @@ class AuthGuard extends AutoRouteGuard { @override void onNavigation(NavigationResolver resolver, StackRouter router) async { try { - // temporary fix for race condition that the _apiService - // get called before accessToken is set - var userInfoHiveBox = await Hive.openBox(userInfoBox); - var accessToken = userInfoHiveBox.get(accessTokenKey); - _apiService.setAccessToken(accessToken); var res = await _apiService.authenticationApi.validateAccessToken(); if (res != null && res.authStatus) { @@ -23,9 +19,15 @@ class AuthGuard extends AutoRouteGuard { } else { router.replaceAll([const LoginRoute()]); } - } catch (e) { - debugPrint("Error [onNavigation] ${e.toString()}"); - router.replaceAll([const LoginRoute()]); + } on ApiException catch (e) { + if (e.code == HttpStatus.badRequest && + e.innerException is SocketException) { + // offline? + resolver.next(true); + } else { + debugPrint("Error [onNavigation] ${e.toString()}"); + router.replaceAll([const LoginRoute()]); + } return; } } diff --git a/mobile/lib/shared/providers/asset.provider.dart b/mobile/lib/shared/providers/asset.provider.dart index e8a8ee802..11635b45f 100644 --- a/mobile/lib/shared/providers/asset.provider.dart +++ b/mobile/lib/shared/providers/asset.provider.dart @@ -104,7 +104,10 @@ class AssetNotifier extends StateNotifier { final bool newLocal = await _albumService.refreshDeviceAlbums(); log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms"); stopwatch.reset(); - if (!newRemote && !newLocal) { + if (!newRemote && + !newLocal && + state.allAssets.length == + await _db.assets.filter().ownerIdEqualTo(me.isarId).count()) { log.info("state is already up-to-date"); return; } diff --git a/mobile/lib/shared/providers/server_info.provider.dart b/mobile/lib/shared/providers/server_info.provider.dart index f7e68fa9c..ae2779adb 100644 --- a/mobile/lib/shared/providers/server_info.provider.dart +++ b/mobile/lib/shared/providers/server_info.provider.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/shared/models/server_info_state.model.dart'; @@ -28,8 +29,7 @@ class ServerInfoNotifier extends StateNotifier { if (serverVersion == null) { state = state.copyWith( isVersionMismatch: true, - versionMismatchErrorMessage: - "Server is out of date. Some functionalities might not working correctly. Download and rebuild server", + versionMismatchErrorMessage: "common_server_error".tr(), ); return; } diff --git a/mobile/lib/shared/ui/immich_image.dart b/mobile/lib/shared/ui/immich_image.dart index c04654f72..cb64b7a45 100644 --- a/mobile/lib/shared/ui/immich_image.dart +++ b/mobile/lib/shared/ui/immich_image.dart @@ -11,15 +11,17 @@ import 'package:photo_manager/photo_manager.dart'; class ImmichImage extends StatelessWidget { const ImmichImage( this.asset, { - required this.width, - required this.height, + this.width, + this.height, + this.fit = BoxFit.cover, this.useGrayBoxPlaceholder = false, super.key, }); final Asset? asset; final bool useGrayBoxPlaceholder; - final double width; - final double height; + final double? width; + final double? height; + final BoxFit fit; @override Widget build(BuildContext context) { @@ -47,7 +49,7 @@ class ImmichImage extends StatelessWidget { ), width: width, height: height, - fit: BoxFit.cover, + fit: fit, frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { if (wasSynchronouslyLoaded || frame != null) { return child; @@ -93,7 +95,7 @@ class ImmichImage extends StatelessWidget { // keeping memCacheWidth, memCacheHeight, maxWidthDiskCache and // maxHeightDiskCache = null allows to simply store the webp thumbnail // from the server and use it for all rendered thumbnail sizes - fit: BoxFit.cover, + fit: fit, fadeInDuration: const Duration(milliseconds: 250), progressIndicatorBuilder: (context, url, downloadProgress) { if (useGrayBoxPlaceholder) { diff --git a/mobile/lib/shared/views/splash_screen.dart b/mobile/lib/shared/views/splash_screen.dart index a26e9d0a2..2e116b8f6 100644 --- a/mobile/lib/shared/views/splash_screen.dart +++ b/mobile/lib/shared/views/splash_screen.dart @@ -21,31 +21,30 @@ class SplashScreenPage extends HookConsumerWidget { Hive.box(hiveLoginInfoBox).get(savedLoginInfoKey); void performLoggingIn() async { - try { - if (loginInfo != null) { + bool isSuccess = false; + if (loginInfo != null) { + try { // Resolve API server endpoint from user provided serverUrl await apiService.resolveAndSetEndpoint(loginInfo.serverUrl); - - var isSuccess = await ref - .read(authenticationProvider.notifier) - .setSuccessLoginInfo( - accessToken: loginInfo.accessToken, - serverUrl: loginInfo.serverUrl, - ); - if (isSuccess) { - final hasPermission = await ref - .read(galleryPermissionNotifier.notifier) - .hasPermission; - if (hasPermission) { - // Resume backup (if enable) then navigate - ref.watch(backupProvider.notifier).resumeBackup(); - } - AutoRouter.of(context).replace(const TabControllerRoute()); - } else { - AutoRouter.of(context).replace(const LoginRoute()); - } + } catch (e) { + // okay, try to continue anyway if offline } - } catch (_) { + + isSuccess = + await ref.read(authenticationProvider.notifier).setSuccessLoginInfo( + accessToken: loginInfo.accessToken, + serverUrl: loginInfo.serverUrl, + ); + } + if (isSuccess) { + final hasPermission = + await ref.read(galleryPermissionNotifier.notifier).hasPermission; + if (hasPermission) { + // Resume backup (if enable) then navigate + ref.watch(backupProvider.notifier).resumeBackup(); + } + AutoRouter.of(context).replace(const TabControllerRoute()); + } else { AutoRouter.of(context).replace(const LoginRoute()); } }