feature(mobile): allow app to be used offline (#1932)

* feature(mobile): allow app to be used offline

* translatable server/network error message

* adjust profile drawer error message

* call getAllAsset after cold app starts

* fix analyzer error

* update asset state if length differs

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Fynn Petersen-Frey 2023-03-15 22:29:07 +01:00 committed by GitHub
parent 54831878e0
commit 04955a4123
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 123 additions and 66 deletions

View file

@ -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",

View file

@ -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),
);
}

View file

@ -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,
),

View file

@ -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),
),
),
),

View file

@ -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),
),
),
);

View file

@ -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],

View file

@ -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<AuthenticationState> {
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<AuthenticationState> {
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;

View file

@ -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(

View file

@ -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) {

View file

@ -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;
}
}

View file

@ -104,7 +104,10 @@ class AssetNotifier extends StateNotifier<AssetsState> {
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;
}

View file

@ -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<ServerInfoState> {
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;
}

View file

@ -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) {

View file

@ -21,31 +21,30 @@ class SplashScreenPage extends HookConsumerWidget {
Hive.box<HiveSavedLoginInfo>(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());
}
}