feat: locked view mobile (#18316)

* feat: locked/private view

* feat: locked/private view

* feat: mobile lock/private view

* feat: mobile lock/private view

* merge main

* pr feedback

* pr feedback

* bottom sheet sizing

* always lock when navigating away
This commit is contained in:
Alex
2025-05-20 08:35:22 -05:00
committed by GitHub
parent 397808dd1a
commit fe71894308
57 changed files with 1893 additions and 289 deletions

View File

@@ -0,0 +1,52 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class AppNavigationObserver extends AutoRouterObserver {
/// Riverpod Instance
final WidgetRef ref;
AppNavigationObserver({
required this.ref,
});
@override
Future<void> didChangeTabRoute(
TabPageRoute route,
TabPageRoute previousRoute,
) async {
Future(
() => ref.read(inLockedViewProvider.notifier).state = false,
);
}
@override
void didPush(Route route, Route? previousRoute) {
_handleLockedViewState(route, previousRoute);
}
_handleLockedViewState(Route route, Route? previousRoute) {
final isInLockedView = ref.read(inLockedViewProvider);
final isFromLockedViewToDetailView =
route.settings.name == GalleryViewerRoute.name &&
previousRoute?.settings.name == LockedRoute.name;
final isFromDetailViewToInfoPanelView = route.settings.name == null &&
previousRoute?.settings.name == GalleryViewerRoute.name &&
isInLockedView;
if (route.settings.name == LockedRoute.name ||
isFromLockedViewToDetailView ||
isFromDetailViewToInfoPanelView) {
Future(
() => ref.read(inLockedViewProvider.notifier).state = true,
);
} else {
Future(
() => ref.read(inLockedViewProvider.notifier).state = false,
);
}
}
}

View File

@@ -0,0 +1,89 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/services.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/local_auth.service.dart';
import 'package:immich_mobile/services/secure_storage.service.dart';
import 'package:local_auth/error_codes.dart' as auth_error;
import 'package:logging/logging.dart';
// ignore: import_rule_openapi
import 'package:openapi/api.dart';
class LockedGuard extends AutoRouteGuard {
final ApiService _apiService;
final SecureStorageService _secureStorageService;
final LocalAuthService _localAuth;
final _log = Logger("AuthGuard");
LockedGuard(
this._apiService,
this._secureStorageService,
this._localAuth,
);
@override
void onNavigation(NavigationResolver resolver, StackRouter router) async {
final authStatus = await _apiService.authenticationApi.getAuthStatus();
if (authStatus == null) {
resolver.next(false);
return;
}
/// Check if a pincode has been created but this user. Show the form to create if not exist
if (!authStatus.pinCode) {
router.push(PinAuthRoute(createPinCode: true));
}
if (authStatus.isElevated) {
resolver.next(true);
return;
}
/// Check if the user has the pincode saved in secure storage, meaning
/// the user has enabled the biometric authentication
final securePinCode = await _secureStorageService.read(kSecuredPinCode);
if (securePinCode == null) {
router.push(PinAuthRoute());
return;
}
try {
final bool isAuth = await _localAuth.authenticate();
if (!isAuth) {
resolver.next(false);
return;
}
await _apiService.authenticationApi.unlockAuthSession(
SessionUnlockDto(pinCode: securePinCode),
);
resolver.next(true);
} on PlatformException catch (error) {
switch (error.code) {
case auth_error.notAvailable:
_log.severe("notAvailable: $error");
break;
case auth_error.notEnrolled:
_log.severe("not enrolled");
break;
default:
_log.severe("error");
break;
}
resolver.next(false);
} on ApiException {
// PIN code has changed, need to re-enter to access
await _secureStorageService.delete(kSecuredPinCode);
router.push(PinAuthRoute());
} catch (error) {
_log.severe("Failed to access locked page", error);
resolver.next(false);
}
}
}

View File

@@ -39,6 +39,8 @@ import 'package:immich_mobile/pages/library/favorite.page.dart';
import 'package:immich_mobile/pages/library/folder/folder.page.dart';
import 'package:immich_mobile/pages/library/library.page.dart';
import 'package:immich_mobile/pages/library/local_albums.page.dart';
import 'package:immich_mobile/pages/library/locked/locked.page.dart';
import 'package:immich_mobile/pages/library/locked/pin_auth.page.dart';
import 'package:immich_mobile/pages/library/partner/partner.page.dart';
import 'package:immich_mobile/pages/library/partner/partner_detail.page.dart';
import 'package:immich_mobile/pages/library/people/people_collection.page.dart';
@@ -67,24 +69,41 @@ import 'package:immich_mobile/routing/auth_guard.dart';
import 'package:immich_mobile/routing/backup_permission_guard.dart';
import 'package:immich_mobile/routing/custom_transition_builders.dart';
import 'package:immich_mobile/routing/duplicate_guard.dart';
import 'package:immich_mobile/routing/locked_guard.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/local_auth.service.dart';
import 'package:immich_mobile/services/secure_storage.service.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
part 'router.gr.dart';
final appRouterProvider = Provider(
(ref) => AppRouter(
ref.watch(apiServiceProvider),
ref.watch(galleryPermissionNotifier.notifier),
ref.watch(secureStorageServiceProvider),
ref.watch(localAuthServiceProvider),
),
);
@AutoRouterConfig(replaceInRouteName: 'Page,Route')
class AppRouter extends RootStackRouter {
late final AuthGuard _authGuard;
late final DuplicateGuard _duplicateGuard;
late final BackupPermissionGuard _backupPermissionGuard;
late final LockedGuard _lockedGuard;
AppRouter(
ApiService apiService,
GalleryPermissionNotifier galleryPermissionNotifier,
SecureStorageService secureStorageService,
LocalAuthService localAuthService,
) {
_authGuard = AuthGuard(apiService);
_duplicateGuard = DuplicateGuard();
_lockedGuard =
LockedGuard(apiService, secureStorageService, localAuthService);
_backupPermissionGuard = BackupPermissionGuard(galleryPermissionNotifier);
}
@@ -289,12 +308,13 @@ class AppRouter extends RootStackRouter {
page: ShareIntentRoute.page,
guards: [_authGuard, _duplicateGuard],
),
AutoRoute(
page: LockedRoute.page,
guards: [_authGuard, _lockedGuard, _duplicateGuard],
),
AutoRoute(
page: PinAuthRoute.page,
guards: [_authGuard, _duplicateGuard],
),
];
}
final appRouterProvider = Provider(
(ref) => AppRouter(
ref.watch(apiServiceProvider),
ref.watch(galleryPermissionNotifier.notifier),
),
);

View File

@@ -956,6 +956,25 @@ class LocalAlbumsRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [LockedPage]
class LockedRoute extends PageRouteInfo<void> {
const LockedRoute({List<PageRouteInfo>? children})
: super(
LockedRoute.name,
initialChildren: children,
);
static const String name = 'LockedRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const LockedPage();
},
);
}
/// generated route for
/// [LoginPage]
class LoginRoute extends PageRouteInfo<void> {
@@ -1359,6 +1378,53 @@ class PhotosRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [PinAuthPage]
class PinAuthRoute extends PageRouteInfo<PinAuthRouteArgs> {
PinAuthRoute({
Key? key,
bool createPinCode = false,
List<PageRouteInfo>? children,
}) : super(
PinAuthRoute.name,
args: PinAuthRouteArgs(
key: key,
createPinCode: createPinCode,
),
initialChildren: children,
);
static const String name = 'PinAuthRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args =
data.argsAs<PinAuthRouteArgs>(orElse: () => const PinAuthRouteArgs());
return PinAuthPage(
key: args.key,
createPinCode: args.createPinCode,
);
},
);
}
class PinAuthRouteArgs {
const PinAuthRouteArgs({
this.key,
this.createPinCode = false,
});
final Key? key;
final bool createPinCode;
@override
String toString() {
return 'PinAuthRouteArgs{key: $key, createPinCode: $createPinCode}';
}
}
/// generated route for
/// [PlacesCollectionPage]
class PlacesCollectionRoute extends PageRouteInfo<PlacesCollectionRouteArgs> {

View File

@@ -1,35 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/memory.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
class TabNavigationObserver extends AutoRouterObserver {
/// Riverpod Instance
final WidgetRef ref;
TabNavigationObserver({
required this.ref,
});
@override
Future<void> didChangeTabRoute(
TabPageRoute route,
TabPageRoute previousRoute,
) async {
if (route.name == 'HomeRoute') {
ref.invalidate(memoryFutureProvider);
Future(() => ref.read(assetProvider.notifier).getAllAsset());
// Update user info
try {
ref.read(userServiceProvider).refreshMyUser();
ref.read(serverInfoProvider.notifier).getServerVersion();
} catch (e) {
debugPrint("Error refreshing user info $e");
}
}
}
}