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:
parent
54831878e0
commit
04955a4123
14 changed files with 123 additions and 66 deletions
|
@ -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",
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue