From 5d244c6feca7010a64ac78a1e3dc1364ef989955 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 18 Jul 2025 13:16:22 -0500 Subject: [PATCH] chore: finish drift locked page (#20013) * feat: overlay mechanism * handle merged asset local id extraction * locked view asset viewer actions * pr feedback --- .../pages/library/locked/pin_auth.page.dart | 20 ++++++-- .../presentation/pages/drift_album.page.dart | 4 ++ .../pages/drift_locked_folder.page.dart | 51 ++++++++++++++++--- .../asset_viewer/asset_viewer.page.dart | 9 ++-- .../asset_viewer/bottom_sheet.widget.dart | 7 ++- .../asset_viewer/top_app_bar.widget.dart | 16 +++++- .../infrastructure/action.provider.dart | 20 +++++--- .../lib/routing/app_navigation_observer.dart | 25 ++++++++- mobile/lib/routing/router.dart | 2 +- mobile/lib/services/action.service.dart | 14 ++++- .../common/mesmerizing_sliver_app_bar.dart | 1 - 11 files changed, 143 insertions(+), 26 deletions(-) diff --git a/mobile/lib/pages/library/locked/pin_auth.page.dart b/mobile/lib/pages/library/locked/pin_auth.page.dart index cca0e3b7ac..9bfd96ed74 100644 --- a/mobile/lib/pages/library/locked/pin_auth.page.dart +++ b/mobile/lib/pages/library/locked/pin_auth.page.dart @@ -1,13 +1,14 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_hooks/flutter_hooks.dart' show useState; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/local_auth.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/forms/pin_registration_form.dart'; import 'package:immich_mobile/widgets/forms/pin_verification_form.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; @RoutePage() class PinAuthPage extends HookConsumerWidget { @@ -19,6 +20,7 @@ class PinAuthPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final localAuthState = ref.watch(localAuthProvider); final showPinRegistrationForm = useState(createPinCode); + final isBetaTimeline = Store.isBetaTimelineEnabled; Future registerBiometric(String pinCode) async { final isRegistered = @@ -39,7 +41,11 @@ class PinAuthPage extends HookConsumerWidget { ), ); - context.replaceRoute(const LockedRoute()); + if (isBetaTimeline) { + context.replaceRoute(const DriftLockedFolderRoute()); + } else { + context.replaceRoute(const LockedRoute()); + } } } @@ -93,8 +99,14 @@ class PinAuthPage extends HookConsumerWidget { Center( child: PinVerificationForm( autoFocus: true, - onSuccess: (_) => - context.replaceRoute(const LockedRoute()), + onSuccess: (_) { + if (isBetaTimeline) { + context + .replaceRoute(const DriftLockedFolderRoute()); + } else { + context.replaceRoute(const LockedRoute()); + } + }, ), ), const SizedBox(height: 24), diff --git a/mobile/lib/presentation/pages/drift_album.page.dart b/mobile/lib/presentation/pages/drift_album.page.dart index fbdf1ef116..c7dffbeaef 100644 --- a/mobile/lib/presentation/pages/drift_album.page.dart +++ b/mobile/lib/presentation/pages/drift_album.page.dart @@ -95,9 +95,13 @@ class _DriftAlbumsPageState extends ConsumerState { return RefreshIndicator( onRefresh: onRefresh, + edgeOffset: 100, child: CustomScrollView( slivers: [ ImmichSliverAppBar( + snap: false, + floating: false, + pinned: true, actions: [ IconButton( icon: const Icon( diff --git a/mobile/lib/presentation/pages/drift_locked_folder.page.dart b/mobile/lib/presentation/pages/drift_locked_folder.page.dart index 9b42cdb103..e134b418e9 100644 --- a/mobile/lib/presentation/pages/drift_locked_folder.page.dart +++ b/mobile/lib/presentation/pages/drift_locked_folder.page.dart @@ -4,14 +4,45 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; @RoutePage() -class DriftLockedFolderPage extends StatelessWidget { +class DriftLockedFolderPage extends ConsumerStatefulWidget { const DriftLockedFolderPage({super.key}); + @override + ConsumerState createState() => + _DriftLockedFolderPageState(); +} + +class _DriftLockedFolderPageState extends ConsumerState + with WidgetsBindingObserver { + bool _showOverlay = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (mounted) { + setState(() { + _showOverlay = state != AppLifecycleState.resumed; + }); + } + } + @override Widget build(BuildContext context) { return ProviderScope( @@ -30,12 +61,18 @@ class DriftLockedFolderPage extends StatelessWidget { }, ), ], - child: Timeline( - appBar: MesmerizingSliverAppBar( - title: 'locked_folder'.t(context: context), - ), - bottomSheet: const LockedFolderBottomSheet(), - ), + child: _showOverlay + ? const SizedBox() + : PopScope( + onPopInvokedWithResult: (didPop, _) => + didPop ? ref.read(authProvider.notifier).lockPinCode() : null, + child: Timeline( + appBar: MesmerizingSliverAppBar( + title: 'locked_folder'.t(context: context), + ), + bottomSheet: const LockedFolderBottomSheet(), + ), + ), ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 50f4a09197..9356c2f43e 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -25,6 +25,7 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart'; import 'package:platform/platform.dart'; @@ -638,6 +639,8 @@ class _AssetViewerState extends ConsumerState { }); }); + final isInLockedView = ref.watch(inLockedViewProvider); + // Currently it is not possible to scroll the asset when the bottom sheet is open all the way. // Issue: https://github.com/flutter/flutter/issues/109037 // TODO: Add a custom scrum builder once the fix lands on stable @@ -666,13 +669,13 @@ class _AssetViewerState extends ConsumerState { ), bottomNavigationBar: showingBottomSheet ? const SizedBox.shrink() - : const Column( + : Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - AssetStackRow(), - ViewerBottomBar(), + const AssetStackRow(), + if (!isInLockedView) const ViewerBottomBar(), ], ), ), diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index ea6dece942..a8b7c79588 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -18,6 +18,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_ import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; @@ -44,6 +45,8 @@ class AssetDetailBottomSheet extends ConsumerWidget { serverInfoProvider.select((state) => state.serverFeatures.trash), ); + final isInLockedView = ref.watch(inLockedViewProvider); + final actions = [ const ShareActionButton(source: ActionSource.viewer), if (asset.hasRemote) ...[ @@ -63,8 +66,10 @@ class AssetDetailBottomSheet extends ConsumerWidget { ], ]; + final lockedViewActions = []; + return BaseBottomSheet( - actions: actions, + actions: isInLockedView ? lockedViewActions : actions, slivers: const [_AssetDetailBottomSheet()], controller: controller, initialChildSize: initialChildSize, diff --git a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart index c85c4390ae..4cdf9f2287 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_act import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; @@ -27,6 +28,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { final user = ref.watch(currentUserProvider); final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; + final isInLockedView = ref.watch(inLockedViewProvider); final isShowingSheet = ref .watch(assetViewerProvider.select((state) => state.showingBottomSheet)); @@ -62,6 +64,14 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { const _KebabMenu(), ]; + final lockedViewActions = [ + if (isCasting || (asset.hasRemote && websocketConnected)) + const CastActionButton( + menuItem: true, + ), + const _KebabMenu(), + ]; + return IgnorePointer( ignoring: opacity < 255, child: AnimatedOpacity( @@ -74,7 +84,11 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { iconTheme: const IconThemeData(size: 22, color: Colors.white), actionsIconTheme: const IconThemeData(size: 22, color: Colors.white), shape: const Border(), - actions: isShowingSheet ? null : actions, + actions: isShowingSheet + ? null + : isInLockedView + ? lockedViewActions + : actions, ), ), ); diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 456b072a39..a0ebf448fc 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -47,7 +47,18 @@ class ActionNotifier extends Notifier { } List _getLocalIdsForSource(ActionSource source) { - return _getIdsForSource(source).toIds().toList(growable: false); + final Set assets = _getAssets(source); + final List localIds = []; + + for (final asset in assets) { + if (asset is LocalAsset) { + localIds.add(asset.id); + } else if (asset is RemoteAsset && asset.localId != null) { + localIds.add(asset.localId!); + } + } + + return localIds; } List _getOwnedRemoteIdsForSource(ActionSource source) { @@ -162,8 +173,9 @@ class ActionNotifier extends Notifier { Future moveToLockFolder(ActionSource source) async { final ids = _getOwnedRemoteIdsForSource(source); + final localIds = _getLocalIdsForSource(source); try { - await _service.moveToLockFolder(ids); + await _service.moveToLockFolder(ids, localIds); return ActionResult(count: ids.length, success: true); } catch (error, stack) { _logger.severe('Failed to move assets to lock folder', error, stack); @@ -329,7 +341,3 @@ extension on Iterable { return whereType().where((a) => a.ownerId == ownerId); } } - -extension on Iterable { - Iterable toIds() => map((e) => e.id); -} diff --git a/mobile/lib/routing/app_navigation_observer.dart b/mobile/lib/routing/app_navigation_observer.dart index 047e897c8e..98560018ee 100644 --- a/mobile/lib/routing/app_navigation_observer.dart +++ b/mobile/lib/routing/app_navigation_observer.dart @@ -25,7 +25,7 @@ class AppNavigationObserver extends AutoRouterObserver { @override void didPush(Route route, Route? previousRoute) { _handleLockedViewState(route, previousRoute); - + _handleDriftLockedFolderState(route, previousRoute); Future( () => ref.read(currentRouteNameProvider.notifier).state = route.settings.name, @@ -54,4 +54,27 @@ class AppNavigationObserver extends AutoRouterObserver { ); } } + + _handleDriftLockedFolderState(Route route, Route? previousRoute) { + final isInLockedView = ref.read(inLockedViewProvider); + final isFromLockedViewToDetailView = + route.settings.name == AssetViewerRoute.name && + previousRoute?.settings.name == DriftLockedFolderRoute.name; + + final isFromDetailViewToInfoPanelView = route.settings.name == null && + previousRoute?.settings.name == AssetViewerRoute.name && + isInLockedView; + + if (route.settings.name == DriftLockedFolderRoute.name || + isFromLockedViewToDetailView || + isFromDetailViewToInfoPanelView) { + Future( + () => ref.read(inLockedViewProvider.notifier).state = true, + ); + } else { + Future( + () => ref.read(inLockedViewProvider.notifier).state = false, + ); + } + } } diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 94e3437d66..3a694df816 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -427,7 +427,7 @@ class AppRouter extends RootStackRouter { ), AutoRoute( page: DriftLockedFolderRoute.page, - guards: [_authGuard, _duplicateGuard], + guards: [_authGuard, _lockedGuard, _duplicateGuard], ), AutoRoute( page: DriftVideoRoute.page, diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index adefd5da16..b59df5b3dc 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -83,7 +83,10 @@ class ActionService { ); } - Future moveToLockFolder(List remoteIds) async { + Future moveToLockFolder( + List remoteIds, + List localIds, + ) async { await _assetApiRepository.updateVisibility( remoteIds, AssetVisibilityEnum.locked, @@ -92,6 +95,15 @@ class ActionService { remoteIds, AssetVisibility.locked, ); + + // Ask user if they want to delete local copies + if (localIds.isNotEmpty) { + final deletedIds = await _assetMediaRepository.deleteAll(localIds); + + if (deletedIds.isNotEmpty) { + await _localAssetRepository.delete(deletedIds); + } + } } Future removeFromLockFolder(List remoteIds) async { diff --git a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart index eace57fe5c..eecc099a9e 100644 --- a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart @@ -22,7 +22,6 @@ class MesmerizingSliverAppBar extends ConsumerStatefulWidget { final String title; final IconData icon; - @override ConsumerState createState() => _MesmerizingSliverAppBarState();