mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
Merge 304116c452 into f9c05af45f
This commit is contained in:
commit
7bf8c68eec
12 changed files with 156 additions and 28 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue