feat: actions undo handling

# Conflicts:
#	mobile/lib/main.dart
This commit is contained in:
shenlong-tanwen 2026-08-02 02:43:34 +05:30
parent ff5da0f84f
commit 304116c452
12 changed files with 156 additions and 28 deletions

View file

@ -272,7 +272,11 @@ class ImmichAppState extends ConsumerState<ImmichApp> 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(

View file

@ -57,7 +57,8 @@ class ArchiveAction extends AssetActionBuilder {
try {
await assetService.update(assetIds, visibility: .some(shouldArchive ? .archive : .timeline));
toastService.success(message);
Future<void> 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");

View file

@ -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");

View file

@ -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<String> assetIds, List<String> 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");

View file

@ -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");

View file

@ -13,14 +13,25 @@ class ToastService {
const ToastService();
FutureOr<void> success(String message, {ToastOption? toast}) {
snackbar.success(message, duration: toast?.timeout);
snackbar.success(message, duration: toast?.timeout, action: toast?.action);
}
FutureOr<void> info(String message, {ToastOption? toast}) {
snackbar.info(message, duration: toast?.timeout);
snackbar.info(message, duration: toast?.timeout, action: toast?.action);
}
FutureOr<void> 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);
}
}

View file

@ -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<ScaffoldMessengerState>();
class SnackbarAction {
final String? label;
final FutureOr<void> 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>() ?? 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<SnackBar, SnackBarClosedReason>? info(String message, {Duration? duration}) =>
show(message, .info, duration: duration);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(
String message, {
Duration? duration,
SnackbarAction? action,
}) => show(message, .info, duration: duration, action: action);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message, {Duration? duration}) =>
show(message, .success, duration: duration);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(
String message, {
Duration? duration,
SnackbarAction? action,
}) => show(message, .success, duration: duration, action: action);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message, {Duration? duration}) =>
show(message, .error, duration: duration);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(
String message, {
Duration? duration,
SnackbarAction? action,
}) => show(message, .error, duration: duration, action: action);
}
const snackbar = SnackbarManager();

View file

@ -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<ImmichTranslationProvider>();
return provider?.translations ?? ImmichTranslations();
return provider?.translations ?? const .new();
}
@override

View file

@ -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,

View file

@ -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');

View file

@ -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();

View file

@ -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,