From 84a2b7a3c82fc39185dc56d6c25c08641e9c2cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?In=C3=AAs=20Costa?= Date: Thu, 14 May 2026 08:19:00 +0100 Subject: [PATCH] fix(mobile): add restore option to trashed assets (#27442) --- .../restore_action_button.widget.dart | 55 +++++++++++++++++++ .../asset_viewer/bottom_bar.widget.dart | 22 +++++--- mobile/lib/utils/action_button.utils.dart | 17 +++++- .../action_button_utils_test.dart | 39 +++++++++++++ 4 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 mobile/lib/presentation/widgets/action_buttons/restore_action_button.widget.dart diff --git a/mobile/lib/presentation/widgets/action_buttons/restore_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/restore_action_button.widget.dart new file mode 100644 index 0000000000..1713718967 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/restore_action_button.widget.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; + +class RestoreActionButton extends ConsumerWidget { + final ActionSource source; + final bool iconOnly; + final bool menuItem; + + const RestoreActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); + + void _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + final result = await ref.read(actionProvider.notifier).restoreTrash(source); + ref.read(multiSelectProvider.notifier).reset(); + + if (source == ActionSource.viewer) { + EventStream.shared.emit(const ViewerReloadAssetEvent()); + } + + final successMessage = 'assets_restored_count'.t(context: context, args: {'count': result.count.toString()}); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: result.success ? ToastType.success : ToastType.error, + ); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BaseActionButton( + iconData: Icons.history_rounded, + label: 'restore'.t(context: context), + iconOnly: iconOnly, + menuItem: menuItem, + onPressed: () => _onTap(context, ref), + maxWidth: 100.0, + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index ff09d15496..21401f37e5 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -2,15 +2,18 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -33,19 +36,24 @@ class ViewerBottomBar extends ConsumerWidget { final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); final isInLockedView = ref.watch(inLockedViewProvider); final serverInfo = ref.watch(serverInfoProvider); + final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash; final originalTheme = context.themeData; final actions = [ - const ShareActionButton(source: ActionSource.viewer), + if (isInTrash && isOwner && asset.hasRemote) + const RestoreActionButton(source: ActionSource.viewer) + else + const ShareActionButton(source: ActionSource.viewer), if (!isInLockedView) ...[ - if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), - // edit sync was added in 2.6.0 - if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) - const EditImageActionButton(), - if (asset.hasRemote) AddActionButton(originalTheme: originalTheme), - + if (!isInTrash) ...[ + if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), + // edit sync was added in 2.6.0 + if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) + const EditImageActionButton(), + if (asset.hasRemote) AddActionButton(originalTheme: originalTheme), + ], if (isOwner) ...[ asset.isLocalOnly ? const DeleteLocalActionButton(source: ActionSource.viewer) diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index d527f3a59e..c38c536136 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -21,6 +21,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_f import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; @@ -81,6 +82,7 @@ enum ActionButtonType { moveToLockFolder, removeFromLockFolder, removeFromAlbum, + restoreTrash, trash, deleteLocal, deletePermanent, @@ -112,7 +114,13 @@ enum ActionButtonType { context.isOwner && // !context.isInLockedView && // context.asset.hasRemote && // - context.isTrashEnabled, + context.isTrashEnabled && // + context.timelineOrigin != TimelineOrigin.trash, + ActionButtonType.restoreTrash => + context.isOwner && // + !context.isInLockedView && // + context.asset.hasRemote && // + context.timelineOrigin == TimelineOrigin.trash, ActionButtonType.deletePermanent => context.isOwner && // context.asset.hasRemote && // @@ -201,6 +209,11 @@ enum ActionButtonType { ), ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), + ActionButtonType.restoreTrash => RestoreActionButton( + source: context.source, + iconOnly: iconOnly, + menuItem: menuItem, + ), ActionButtonType.deletePermanent => DeletePermanentActionButton( source: context.source, iconOnly: iconOnly, @@ -292,6 +305,7 @@ enum ActionButtonType { ActionButtonType.moveToLockFolder => 10, ActionButtonType.deleteLocal => 10, ActionButtonType.delete => 10, + ActionButtonType.restoreTrash => 10, // 90: advancedInfo ActionButtonType.advancedInfo => 90, // 1: others @@ -309,6 +323,7 @@ class ActionButtonBuilder { ActionButtonType.delete, ActionButtonType.archive, ActionButtonType.unarchive, + ActionButtonType.restoreTrash, }; static List build(ActionButtonContext context) { diff --git a/mobile/test/utils_legacy/action_button_utils_test.dart b/mobile/test/utils_legacy/action_button_utils_test.dart index 79f4e04b52..8bd078a433 100644 --- a/mobile/test/utils_legacy/action_button_utils_test.dart +++ b/mobile/test/utils_legacy/action_button_utils_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/utils/action_button.utils.dart'; LocalAsset createLocalAsset({ @@ -460,6 +461,44 @@ void main() { }); }); + group('restoreTrash button', () { + test('should show when owner, not locked, has remote, and is in trash timeline', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + timelineOrigin: TimelineOrigin.trash, + ); + + expect(ActionButtonType.restoreTrash.shouldShow(context), isTrue); + }); + + test('should not show when not in trash timeline', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: false, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + timelineOrigin: TimelineOrigin.main, + ); + + expect(ActionButtonType.restoreTrash.shouldShow(context), isFalse); + }); + }); + group('deletePermanent button', () { test('should show when owner, not locked, has remote, and trash disabled', () { final remoteAsset = createRemoteAsset();