diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index d2fd945cbf..a280eb1c63 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -272,7 +272,11 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve darkTheme: getThemeData(colorScheme: immichTheme.dark, locale: context.locale), theme: getThemeData(colorScheme: immichTheme.light, locale: context.locale), builder: (context, child) => ImmichTranslationProvider( - translations: ImmichTranslations(submit: context.t.submit, password: context.t.password), + translations: ImmichTranslations( + submit: context.t.submit, + password: context.t.password, + undo: context.t.undo, + ), child: ImmichThemeProvider(colorScheme: context.colorScheme, child: child!), ), routerConfig: router.config( diff --git a/mobile/lib/presentation/actions/archive.action.dart b/mobile/lib/presentation/actions/archive.action.dart index f8e4a1e038..1e41411a3c 100644 --- a/mobile/lib/presentation/actions/archive.action.dart +++ b/mobile/lib/presentation/actions/archive.action.dart @@ -57,7 +57,8 @@ class ArchiveAction extends AssetActionBuilder { try { await assetService.update(assetIds, visibility: .some(shouldArchive ? .archive : .timeline)); - toastService.success(message); + Future undo() => assetService.update(assetIds, visibility: .some(shouldArchive ? .timeline : .archive)); + toastService.success(message, toast: .new(onUndo: undo)); clearSelection(); } catch (error, stack) { handleError(error, stack: stack, description: "Failed to update the archive status for assets"); diff --git a/mobile/lib/presentation/actions/delete.action.dart b/mobile/lib/presentation/actions/delete.action.dart index 23a2ef2a2f..7edac9aa42 100644 --- a/mobile/lib/presentation/actions/delete.action.dart +++ b/mobile/lib/presentation/actions/delete.action.dart @@ -11,6 +11,7 @@ import 'package:immich_mobile/providers/infrastructure/toast.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/services/cleanup.service.dart'; +import 'package:immich_mobile/services/toast.service.dart'; import 'package:immich_mobile/utils/error_handler.dart'; import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; @@ -67,15 +68,19 @@ class DeleteAction extends AssetActionBuilder { } final (:localIds, :remoteIds, :trash) = state; + final assetService = ref.read(assetServiceProvider); final toastService = ref.read(toastServiceProvider); final clearSelection = ref.read(clearSelectionProvider(source)); try { final String? message; + // Only trashing is reversible; a permanent delete and a device cleanup are not. + ToastOption? undo; if (remoteIds.isEmpty) { message = await _removeLocalAssets(context, ref, localIds); } else if (trash) { message = await _moveToTrash(context, ref, remoteIds, localIds); + undo = .new(onUndo: () => assetService.restoreTrash(remoteIds)); } else { message = await _deletePermanently(context, ref, remoteIds, localIds); } @@ -84,7 +89,7 @@ class DeleteAction extends AssetActionBuilder { return; } - toastService.success(message); + toastService.success(message, toast: undo); clearSelection(); } catch (error, stack) { handleError(error, stack: stack, description: "Failed to delete assets"); diff --git a/mobile/lib/presentation/actions/lock.action.dart b/mobile/lib/presentation/actions/lock.action.dart index 3d090b712d..dad0d020a9 100644 --- a/mobile/lib/presentation/actions/lock.action.dart +++ b/mobile/lib/presentation/actions/lock.action.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/toast.provider.dart'; +import 'package:immich_mobile/services/toast.service.dart'; import 'package:immich_mobile/utils/error_handler.dart'; typedef _State = ({bool shouldLock, List assetIds, List localIds}); @@ -62,7 +63,11 @@ class LockAction extends AssetActionBuilder { // A locked asset still sits in the device gallery, so offer to remove the local copy. await assetService.deleteLocal(localIds); } - toastService.success(message); + // Unlocking is a sensitive action and requires an elevated session + final toast = shouldLock + ? null + : ToastOption(onUndo: () => assetService.update(assetIds, visibility: const .some(.locked))); + toastService.success(message, toast: toast); clearSelection(); } catch (error, stack) { handleError(error, stack: stack, description: "Failed to update the locked folder for assets"); diff --git a/mobile/lib/presentation/actions/restore.action.dart b/mobile/lib/presentation/actions/restore.action.dart index 0a1b707b39..1ce444e906 100644 --- a/mobile/lib/presentation/actions/restore.action.dart +++ b/mobile/lib/presentation/actions/restore.action.dart @@ -38,7 +38,7 @@ class RestoreAction extends AssetActionBuilder { try { await assetService.restoreTrash(assetIds); - toastService.success(message); + toastService.success(message, toast: .new(onUndo: () => assetService.trash(assetIds))); clearSelection(); } catch (error, stack) { handleError(error, stack: stack, description: "Failed to restore assets"); diff --git a/mobile/lib/services/toast.service.dart b/mobile/lib/services/toast.service.dart index 2b61a945ff..1cbb526351 100644 --- a/mobile/lib/services/toast.service.dart +++ b/mobile/lib/services/toast.service.dart @@ -13,14 +13,25 @@ class ToastService { const ToastService(); FutureOr success(String message, {ToastOption? toast}) { - snackbar.success(message, duration: toast?.timeout); + snackbar.success(message, duration: toast?.timeout, action: toast?.action); } FutureOr info(String message, {ToastOption? toast}) { - snackbar.info(message, duration: toast?.timeout); + snackbar.info(message, duration: toast?.timeout, action: toast?.action); } FutureOr error(String message, {ToastOption? toast}) { - snackbar.error(message, duration: toast?.timeout); + snackbar.error(message, duration: toast?.timeout, action: toast?.action); + } +} + +extension on ToastOption { + SnackbarAction? get action { + final onUndo = this.onUndo; + if (onUndo == null) { + return null; + } + + return SnackbarAction(onPressed: onUndo); } } diff --git a/mobile/packages/ui/lib/src/snackbar.dart b/mobile/packages/ui/lib/src/snackbar.dart index 1ede1124a8..a248b81872 100644 --- a/mobile/packages/ui/lib/src/snackbar.dart +++ b/mobile/packages/ui/lib/src/snackbar.dart @@ -1,8 +1,18 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:immich_ui/immich_ui.dart'; +import 'package:immich_ui/src/internal.dart'; final scaffoldMessengerKey = GlobalKey(); +class SnackbarAction { + final String? label; + final FutureOr Function() onPressed; + + const SnackbarAction({this.label, required this.onPressed}); +} + class SnackbarManager { const SnackbarManager(); @@ -10,6 +20,7 @@ class SnackbarManager { String message, SnackbarType type, { Duration? duration, + SnackbarAction? action, }) { final messenger = scaffoldMessengerKey.currentState; final context = scaffoldMessengerKey.currentContext; @@ -19,10 +30,10 @@ class SnackbarManager { duration ??= const .new(seconds: 4); messenger.hideCurrentSnackBar(); - return messenger.showSnackBar(_build(context, message, type, duration)); + return messenger.showSnackBar(_build(context, message, type, duration, action)); } - SnackBar _build(BuildContext context, String message, SnackbarType type, Duration duration) { + SnackBar _build(BuildContext context, String message, SnackbarType type, Duration duration, SnackbarAction? action) { final theme = Theme.of(context); final colors = theme.extension() ?? ImmichColors.harmonized(theme.colorScheme); final (IconData icon, Color background, Color foreground) = switch (type) { @@ -31,11 +42,22 @@ class SnackbarManager { .error => (Icons.warning_rounded, colors.error, colors.onError), }; + SnackBarAction? snackAction; + if (action != null) { + snackAction = .new( + label: action.label ?? context.translations.undo, + onPressed: action.onPressed, + textColor: foreground, + ); + } + return SnackBar( behavior: .floating, backgroundColor: background, duration: duration, shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.sm))), + // A snackbar carrying an action stays up until it is tapped by default; our actions are optional + persist: false, content: Row( children: [ Icon(icon, color: foreground, size: ImmichIconSize.sm), @@ -50,17 +72,27 @@ class SnackbarManager { ), ], ), + action: snackAction, ); } - ScaffoldFeatureController? info(String message, {Duration? duration}) => - show(message, .info, duration: duration); + ScaffoldFeatureController? info( + String message, { + Duration? duration, + SnackbarAction? action, + }) => show(message, .info, duration: duration, action: action); - ScaffoldFeatureController? success(String message, {Duration? duration}) => - show(message, .success, duration: duration); + ScaffoldFeatureController? success( + String message, { + Duration? duration, + SnackbarAction? action, + }) => show(message, .success, duration: duration, action: action); - ScaffoldFeatureController? error(String message, {Duration? duration}) => - show(message, .error, duration: duration); + ScaffoldFeatureController? error( + String message, { + Duration? duration, + SnackbarAction? action, + }) => show(message, .error, duration: duration, action: action); } const snackbar = SnackbarManager(); diff --git a/mobile/packages/ui/lib/src/translation.dart b/mobile/packages/ui/lib/src/translation.dart index cd51f74422..1bca33f4f1 100644 --- a/mobile/packages/ui/lib/src/translation.dart +++ b/mobile/packages/ui/lib/src/translation.dart @@ -1,27 +1,24 @@ import 'package:flutter/material.dart'; class ImmichTranslations { - late String submit; - late String password; + final String submit; + final String password; + final String undo; - ImmichTranslations({String? submit, String? password}) { - this.submit = submit ?? 'Submit'; - this.password = password ?? 'Password'; - } + const ImmichTranslations({String? submit, String? password, String? undo}) + : submit = submit ?? 'Submit', + password = password ?? 'Password', + undo = undo ?? 'Undo'; } class ImmichTranslationProvider extends InheritedWidget { final ImmichTranslations? translations; - const ImmichTranslationProvider({ - super.key, - this.translations, - required super.child, - }); + const ImmichTranslationProvider({super.key, this.translations, required super.child}); static ImmichTranslations of(BuildContext context) { final provider = context.dependOnInheritedWidgetOfExactType(); - return provider?.translations ?? ImmichTranslations(); + return provider?.translations ?? const .new(); } @override diff --git a/mobile/test/unit/presentation/actions/archive_action_test.dart b/mobile/test/unit/presentation/actions/archive_action_test.dart index 555d6af2ff..80f1556e39 100644 --- a/mobile/test/unit/presentation/actions/archive_action_test.dart +++ b/mobile/test/unit/presentation/actions/archive_action_test.dart @@ -92,6 +92,28 @@ void main() { expect(find.byType(ImmichIconButton), findsNothing); }); + testWidgets('offers an undo that puts the archived assets back on the timeline', (tester) async { + final asset = owned(); + + await pumpArchive(tester, {asset}); + await tester.pumpAndSettle(); + await tester.tap(find.text('Undo')); + await tester.pump(); + + verify(() => assetService.update([asset.id], visibility: const .some(.timeline))).called(1); + }); + + testWidgets('offers an undo that re-archives the unarchived assets', (tester) async { + final asset = owned(visibility: .archive); + + await pumpArchive(tester, {asset}); + await tester.pumpAndSettle(); + await tester.tap(find.text('Undo')); + await tester.pump(); + + verify(() => assetService.update([asset.id], visibility: const .some(.archive))).called(1); + }); + testWidgets('is hidden inside the locked folder view', (tester) async { await tester.pumpTestWidget( context, diff --git a/mobile/test/unit/presentation/actions/delete_action_test.dart b/mobile/test/unit/presentation/actions/delete_action_test.dart index 4c1bf9306c..b68e2096da 100644 --- a/mobile/test/unit/presentation/actions/delete_action_test.dart +++ b/mobile/test/unit/presentation/actions/delete_action_test.dart @@ -105,6 +105,17 @@ void main() { verify(() => cleanupService.deleteLocalAssets(['local'])).called(1); verify(() => assetService.trash([asset.id])).called(1); }); + + testWidgets('offers an undo that restores the trashed assets', (tester) async { + final asset = owned(); + + await pumpDelete(tester, {asset}); + await tester.pumpAndSettle(); + await tester.tap(find.text('Undo')); + await tester.pump(); + + verify(() => assetService.restoreTrash([asset.id])).called(1); + }); }); group('permanent', () { @@ -118,6 +129,15 @@ void main() { verifyNever(() => assetService.trash(any())); }); + testWidgets('offers no undo for a permanent delete', (tester) async { + await pumpDelete(tester, {owned()}, trashEnabled: false); + await respondToDialog(tester, confirm: true); + await tester.pumpAndSettle(); + + expect(find.byType(SnackBar), findsOneWidget); + expect(find.text('Undo'), findsNothing); + }); + testWidgets('permanently deletes a merged asset and removes its device copy', (tester) async { final asset = owned(localId: 'local'); diff --git a/mobile/test/unit/presentation/actions/lock_action_test.dart b/mobile/test/unit/presentation/actions/lock_action_test.dart index 9bad0f5a43..9bbbcaed7a 100644 --- a/mobile/test/unit/presentation/actions/lock_action_test.dart +++ b/mobile/test/unit/presentation/actions/lock_action_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; @@ -94,6 +95,25 @@ void main() { verifyNever(() => assetService.deleteLocal(any())); }); + testWidgets('offers an undo that locks the unlocked assets again', (tester) async { + final asset = owned(visibility: .locked); + + await pumpLock(tester, {asset}); + await tester.pumpAndSettle(); + await tester.tap(find.text('Undo')); + await tester.pump(); + + verify(() => assetService.update([asset.id], visibility: const .some(.locked))).called(1); + }); + + testWidgets('offers no undo for locking', (tester) async { + await pumpLock(tester, {owned()}); + await tester.pumpAndSettle(); + + expect(find.byType(SnackBar), findsOneWidget); + expect(find.text('Undo'), findsNothing); + }); + testWidgets('clears the selection once the update succeeds', (tester) async { await pumpLock(tester, {owned()}); await tester.pumpAndSettle(); diff --git a/mobile/test/unit/presentation/actions/restore_action_test.dart b/mobile/test/unit/presentation/actions/restore_action_test.dart index 530e1b76e9..5c11d20403 100644 --- a/mobile/test/unit/presentation/actions/restore_action_test.dart +++ b/mobile/test/unit/presentation/actions/restore_action_test.dart @@ -62,6 +62,17 @@ void main() { expect(find.byType(ImmichIconButton), findsNothing, reason: 'an empty selection hides the action'); }); + testWidgets('offers an undo that puts the assets back in the trash', (tester) async { + final asset = owned(); + + await pumpRestore(tester, {asset}); + await tester.pumpAndSettle(); + await tester.tap(find.text('Undo')); + await tester.pump(); + + verify(() => assetService.trash([asset.id])).called(1); + }); + testWidgets('is hidden when no owned asset is trashed', (tester) async { await tester.pumpTestWidget( context,