chore: finish drift locked page (#20013)

* feat: overlay mechanism

* handle merged asset local id extraction

* locked view asset viewer actions

* pr feedback
This commit is contained in:
Alex 2025-07-18 13:16:22 -05:00 committed by GitHub
parent dcfe8d5ade
commit 5d244c6fec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 143 additions and 26 deletions

View File

@ -1,13 +1,14 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.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:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/local_auth.provider.dart'; import 'package:immich_mobile/providers/local_auth.provider.dart';
import 'package:immich_mobile/routing/router.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_registration_form.dart';
import 'package:immich_mobile/widgets/forms/pin_verification_form.dart'; import 'package:immich_mobile/widgets/forms/pin_verification_form.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@RoutePage() @RoutePage()
class PinAuthPage extends HookConsumerWidget { class PinAuthPage extends HookConsumerWidget {
@ -19,6 +20,7 @@ class PinAuthPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final localAuthState = ref.watch(localAuthProvider); final localAuthState = ref.watch(localAuthProvider);
final showPinRegistrationForm = useState(createPinCode); final showPinRegistrationForm = useState(createPinCode);
final isBetaTimeline = Store.isBetaTimelineEnabled;
Future<void> registerBiometric(String pinCode) async { Future<void> registerBiometric(String pinCode) async {
final isRegistered = final isRegistered =
@ -39,9 +41,13 @@ class PinAuthPage extends HookConsumerWidget {
), ),
); );
if (isBetaTimeline) {
context.replaceRoute(const DriftLockedFolderRoute());
} else {
context.replaceRoute(const LockedRoute()); context.replaceRoute(const LockedRoute());
} }
} }
}
enableBiometricAuth() { enableBiometricAuth() {
showDialog( showDialog(
@ -93,8 +99,14 @@ class PinAuthPage extends HookConsumerWidget {
Center( Center(
child: PinVerificationForm( child: PinVerificationForm(
autoFocus: true, autoFocus: true,
onSuccess: (_) => onSuccess: (_) {
context.replaceRoute(const LockedRoute()), if (isBetaTimeline) {
context
.replaceRoute(const DriftLockedFolderRoute());
} else {
context.replaceRoute(const LockedRoute());
}
},
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),

View File

@ -95,9 +95,13 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
return RefreshIndicator( return RefreshIndicator(
onRefresh: onRefresh, onRefresh: onRefresh,
edgeOffset: 100,
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
ImmichSliverAppBar( ImmichSliverAppBar(
snap: false,
floating: false,
pinned: true,
actions: [ actions: [
IconButton( IconButton(
icon: const Icon( icon: const Icon(

View File

@ -4,14 +4,45 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.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/bottom_sheet/locked_folder_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.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/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
@RoutePage() @RoutePage()
class DriftLockedFolderPage extends StatelessWidget { class DriftLockedFolderPage extends ConsumerStatefulWidget {
const DriftLockedFolderPage({super.key}); const DriftLockedFolderPage({super.key});
@override
ConsumerState<DriftLockedFolderPage> createState() =>
_DriftLockedFolderPageState();
}
class _DriftLockedFolderPageState extends ConsumerState<DriftLockedFolderPage>
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ProviderScope( return ProviderScope(
@ -30,12 +61,18 @@ class DriftLockedFolderPage extends StatelessWidget {
}, },
), ),
], ],
child: _showOverlay
? const SizedBox()
: PopScope(
onPopInvokedWithResult: (didPop, _) =>
didPop ? ref.read(authProvider.notifier).lockPinCode() : null,
child: Timeline( child: Timeline(
appBar: MesmerizingSliverAppBar( appBar: MesmerizingSliverAppBar(
title: 'locked_folder'.t(context: context), title: 'locked_folder'.t(context: context),
), ),
bottomSheet: const LockedFolderBottomSheet(), bottomSheet: const LockedFolderBottomSheet(),
), ),
),
); );
} }
} }

View File

@ -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/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.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/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.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart';
import 'package:platform/platform.dart'; import 'package:platform/platform.dart';
@ -638,6 +639,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}); });
}); });
final isInLockedView = ref.watch(inLockedViewProvider);
// Currently it is not possible to scroll the asset when the bottom sheet is open all the way. // 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 // Issue: https://github.com/flutter/flutter/issues/109037
// TODO: Add a custom scrum builder once the fix lands on stable // TODO: Add a custom scrum builder once the fix lands on stable
@ -666,13 +669,13 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
), ),
bottomNavigationBar: showingBottomSheet bottomNavigationBar: showingBottomSheet
? const SizedBox.shrink() ? const SizedBox.shrink()
: const Column( : Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
AssetStackRow(), const AssetStackRow(),
ViewerBottomBar(), if (!isInLockedView) const ViewerBottomBar(),
], ],
), ),
), ),

View File

@ -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/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/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/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/providers/server_info.provider.dart';
import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/utils/bytes_units.dart';
@ -44,6 +45,8 @@ class AssetDetailBottomSheet extends ConsumerWidget {
serverInfoProvider.select((state) => state.serverFeatures.trash), serverInfoProvider.select((state) => state.serverFeatures.trash),
); );
final isInLockedView = ref.watch(inLockedViewProvider);
final actions = <Widget>[ final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer), const ShareActionButton(source: ActionSource.viewer),
if (asset.hasRemote) ...[ if (asset.hasRemote) ...[
@ -63,8 +66,10 @@ class AssetDetailBottomSheet extends ConsumerWidget {
], ],
]; ];
final lockedViewActions = <Widget>[];
return BaseBottomSheet( return BaseBottomSheet(
actions: actions, actions: isInLockedView ? lockedViewActions : actions,
slivers: const [_AssetDetailBottomSheet()], slivers: const [_AssetDetailBottomSheet()],
controller: controller, controller: controller,
initialChildSize: initialChildSize, initialChildSize: initialChildSize,

View File

@ -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/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/cast.provider.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/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/user.provider.dart';
import 'package:immich_mobile/providers/websocket.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 user = ref.watch(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isInLockedView = ref.watch(inLockedViewProvider);
final isShowingSheet = ref final isShowingSheet = ref
.watch(assetViewerProvider.select((state) => state.showingBottomSheet)); .watch(assetViewerProvider.select((state) => state.showingBottomSheet));
@ -62,6 +64,14 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
const _KebabMenu(), const _KebabMenu(),
]; ];
final lockedViewActions = <Widget>[
if (isCasting || (asset.hasRemote && websocketConnected))
const CastActionButton(
menuItem: true,
),
const _KebabMenu(),
];
return IgnorePointer( return IgnorePointer(
ignoring: opacity < 255, ignoring: opacity < 255,
child: AnimatedOpacity( child: AnimatedOpacity(
@ -74,7 +84,11 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
iconTheme: const IconThemeData(size: 22, color: Colors.white), iconTheme: const IconThemeData(size: 22, color: Colors.white),
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white), actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
shape: const Border(), shape: const Border(),
actions: isShowingSheet ? null : actions, actions: isShowingSheet
? null
: isInLockedView
? lockedViewActions
: actions,
), ),
), ),
); );

View File

@ -47,7 +47,18 @@ class ActionNotifier extends Notifier<void> {
} }
List<String> _getLocalIdsForSource(ActionSource source) { List<String> _getLocalIdsForSource(ActionSource source) {
return _getIdsForSource<LocalAsset>(source).toIds().toList(growable: false); final Set<BaseAsset> assets = _getAssets(source);
final List<String> 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<String> _getOwnedRemoteIdsForSource(ActionSource source) { List<String> _getOwnedRemoteIdsForSource(ActionSource source) {
@ -162,8 +173,9 @@ class ActionNotifier extends Notifier<void> {
Future<ActionResult> moveToLockFolder(ActionSource source) async { Future<ActionResult> moveToLockFolder(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source); final ids = _getOwnedRemoteIdsForSource(source);
final localIds = _getLocalIdsForSource(source);
try { try {
await _service.moveToLockFolder(ids); await _service.moveToLockFolder(ids, localIds);
return ActionResult(count: ids.length, success: true); return ActionResult(count: ids.length, success: true);
} catch (error, stack) { } catch (error, stack) {
_logger.severe('Failed to move assets to lock folder', error, stack); _logger.severe('Failed to move assets to lock folder', error, stack);
@ -329,7 +341,3 @@ extension on Iterable<RemoteAsset> {
return whereType<RemoteAsset>().where((a) => a.ownerId == ownerId); return whereType<RemoteAsset>().where((a) => a.ownerId == ownerId);
} }
} }
extension on Iterable<LocalAsset> {
Iterable<String> toIds() => map((e) => e.id);
}

View File

@ -25,7 +25,7 @@ class AppNavigationObserver extends AutoRouterObserver {
@override @override
void didPush(Route route, Route? previousRoute) { void didPush(Route route, Route? previousRoute) {
_handleLockedViewState(route, previousRoute); _handleLockedViewState(route, previousRoute);
_handleDriftLockedFolderState(route, previousRoute);
Future( Future(
() => ref.read(currentRouteNameProvider.notifier).state = () => ref.read(currentRouteNameProvider.notifier).state =
route.settings.name, 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,
);
}
}
} }

View File

@ -427,7 +427,7 @@ class AppRouter extends RootStackRouter {
), ),
AutoRoute( AutoRoute(
page: DriftLockedFolderRoute.page, page: DriftLockedFolderRoute.page,
guards: [_authGuard, _duplicateGuard], guards: [_authGuard, _lockedGuard, _duplicateGuard],
), ),
AutoRoute( AutoRoute(
page: DriftVideoRoute.page, page: DriftVideoRoute.page,

View File

@ -83,7 +83,10 @@ class ActionService {
); );
} }
Future<void> moveToLockFolder(List<String> remoteIds) async { Future<void> moveToLockFolder(
List<String> remoteIds,
List<String> localIds,
) async {
await _assetApiRepository.updateVisibility( await _assetApiRepository.updateVisibility(
remoteIds, remoteIds,
AssetVisibilityEnum.locked, AssetVisibilityEnum.locked,
@ -92,6 +95,15 @@ class ActionService {
remoteIds, remoteIds,
AssetVisibility.locked, 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<void> removeFromLockFolder(List<String> remoteIds) async { Future<void> removeFromLockFolder(List<String> remoteIds) async {

View File

@ -22,7 +22,6 @@ class MesmerizingSliverAppBar extends ConsumerStatefulWidget {
final String title; final String title;
final IconData icon; final IconData icon;
@override @override
ConsumerState<MesmerizingSliverAppBar> createState() => ConsumerState<MesmerizingSliverAppBar> createState() =>
_MesmerizingSliverAppBarState(); _MesmerizingSliverAppBarState();