diff --git a/mobile/lib/presentation/actions/restore.action.dart b/mobile/lib/presentation/actions/restore.action.dart index fb44cacb12..283954787b 100644 --- a/mobile/lib/presentation/actions/restore.action.dart +++ b/mobile/lib/presentation/actions/restore.action.dart @@ -1,35 +1,46 @@ import 'package:flutter/material.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; 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/utils/asset_filter.dart'; +import 'package:immich_mobile/utils/error_handler.dart'; -class RestoreAction extends BaseAction { - final List assetIds; +final _stateProvider = Provider.family.autoDispose?, ActionSource>((ref, source) { + final AssetsActionState(:ownedAssets) = ref.watch(assetsActionProvider(source)); + final assetIds = ownedAssets.trashed().map((asset) => asset.id).toList(growable: false); + return assetIds.isEmpty ? null : assetIds; +}); - const RestoreAction._({required this.assetIds, required super.scope, required super.icon, required super.label, super.isVisible}); - - factory RestoreAction({required Iterable assets, required ActionScope scope}) { - final assetIds = AssetFilter( - assets, - ).owned(scope.authUser.id).trashed().map((asset) => asset.id).toList(growable: false); - - return RestoreAction._( - assetIds: assetIds, - scope: scope, - icon: Icons.history_rounded, - label: scope.context.t.restore, - isVisible: assetIds.isNotEmpty, - ); - } +class RestoreAction extends AssetActionBuilder { + const RestoreAction({required super.source}); @override - Future onAction() async { - final ActionScope(:ref, :context) = scope; + ActionData? build(BuildContext context, WidgetRef ref) { + if (!ref.watch(_stateProvider(source).select((state) => state != null))) { + return null; + } - await ref.read(assetServiceProvider).restoreTrash(assetIds); - ref.read(toastRepositoryProvider).success(context.t.assets_restored_count(count: assetIds.length)); + return .new(icon: Icons.history_rounded, label: context.t.restore, onAction: () => _restore(context, ref)); + } + + Future _restore(BuildContext context, WidgetRef ref) async { + final assetIds = ref.read(_stateProvider(source)); + if (assetIds == null) { + return; + } + + final message = context.t.assets_restored_count(count: assetIds.length); + final toast = ref.read(toastRepositoryProvider); + final selection = ref.read(assetsActionProvider(source).notifier); + + try { + await ref.read(assetServiceProvider).restoreTrash(assetIds); + toast.success(message); + selection.clearSelect(); + } catch (error, stack) { + handleError(error, stack: stack, description: "Failed to restore assets"); + } } } 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 deleted file mode 100644 index 1713718967..0000000000 --- a/mobile/lib/presentation/widgets/action_buttons/restore_action_button.widget.dart +++ /dev/null @@ -1,55 +0,0 @@ -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/action_buttons/restore_trash_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/restore_trash_action_button.widget.dart deleted file mode 100644 index e7928bd325..0000000000 --- a/mobile/lib/presentation/widgets/action_buttons/restore_trash_action_button.widget.dart +++ /dev/null @@ -1,43 +0,0 @@ -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/extensions/translate_extensions.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 RestoreTrashActionButton extends ConsumerWidget { - final ActionSource source; - - const RestoreTrashActionButton({super.key, required this.source}); - - 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(); - - 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 TextButton.icon( - icon: const Icon(Icons.history_rounded), - label: Text('restore'.t(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), - onPressed: () => _onTap(context, ref), - ); - } -} 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 53a7aee700..14aa768626 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -4,12 +4,13 @@ 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/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/restore.action.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/delete_permanent_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/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart'; @@ -43,10 +44,8 @@ class ViewerBottomBar extends ConsumerWidget { final originalTheme = context.themeData; final actions = [ - if (isInTrash && isOwner && asset.hasRemote) - const RestoreActionButton(source: ActionSource.viewer) - else - const ShareActionButton(source: ActionSource.viewer), + const ActionColumnButton(action: RestoreAction(source: .viewer)), + const ShareActionButton(source: .viewer), if (!isInLockedView) ...[ if (!isInTrash) ...[ diff --git a/mobile/lib/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart index c96e680966..4e438884b3 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart @@ -2,8 +2,9 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/restore.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_trash_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/restore_trash_action_button.widget.dart'; class TrashBottomBar extends ConsumerWidget { const TrashBottomBar({super.key}); @@ -21,7 +22,7 @@ class TrashBottomBar extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ DeleteTrashActionButton(source: ActionSource.timeline), - RestoreTrashActionButton(source: ActionSource.timeline), + ActionColumnButton(action: RestoreAction(source: .timeline)), ], ), ), diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index fdadbba410..26a8c8461a 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -194,17 +194,6 @@ class ActionNotifier extends Notifier { } } - Future restoreTrash(ActionSource source) async { - final ids = _getOwnedRemoteIdsForSource(source); - try { - await _service.restoreTrash(ids); - return ActionResult(count: ids.length, success: true); - } catch (error, stack) { - _logger.severe('Failed to restore trash assets', error, stack); - return ActionResult(count: ids.length, success: false, error: error.toString()); - } - } - Future emptyTrash(String userId) async { try { final count = await _service.emptyTrash(userId); diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 5d7fe1822b..a8cbc4d3f7 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -98,11 +98,6 @@ class ActionService { await _remoteAssetRepository.trash(remoteIds); } - Future restoreTrash(List ids) async { - await _assetApiRepository.restoreTrash(ids); - await _remoteAssetRepository.restoreTrash(ids); - } - Future emptyTrash(String userId) async { final count = await _assetApiRepository.emptyTrash(); await _remoteAssetRepository.emptyTrash(userId); diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 4219e0aed7..a838333475 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -9,6 +9,7 @@ import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; +import 'package:immich_mobile/presentation/actions/restore.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart'; @@ -21,7 +22,6 @@ 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'; @@ -208,11 +208,7 @@ 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.restoreTrash => ActionMenuItem(action: RestoreAction(source: context.source)), ActionButtonType.deletePermanent => DeletePermanentActionButton( source: context.source, iconOnly: iconOnly, diff --git a/mobile/test/domain/services/sync_stream_service_test.dart b/mobile/test/domain/services/sync_stream_service_test.dart index e033229408..e528538303 100644 --- a/mobile/test/domain/services/sync_stream_service_test.dart +++ b/mobile/test/domain/services/sync_stream_service_test.dart @@ -36,7 +36,6 @@ class _AbortCallbackWrapper { class _MockAbortCallbackWrapper extends Mock implements _AbortCallbackWrapper {} - void main() { late SyncStreamService sut; late SyncStreamRepository mockSyncStreamRepo; diff --git a/mobile/test/unit/presentation/actions/restore_action_test.dart b/mobile/test/unit/presentation/actions/restore_action_test.dart new file mode 100644 index 0000000000..03760b0c6f --- /dev/null +++ b/mobile/test/unit/presentation/actions/restore_action_test.dart @@ -0,0 +1,84 @@ +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'; +import 'package:immich_mobile/presentation/actions/restore.action.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../../service.mocks.dart'; +import '../../factories/remote_asset_factory.dart'; +import '../presentation_context.dart'; + +void main() { + late PresentationContext context; + late MockAssetService assetService; + + setUp(() async { + context = await PresentationContext.create(); + assetService = context.service.asset.service; + }); + + tearDown(() { + context.dispose(); + }); + + RemoteAsset owned({bool trashed = true}) => + RemoteAssetFactory.create(ownerId: context.currentUser.id, deletedAt: trashed ? DateTime(2020) : null); + + Future pumpRestore(WidgetTester tester, Set selection) => + tester.pumpTestAction(context, const RestoreAction(source: .timeline), overrides: context.selected(selection)); + + group('RestoreAction', () { + testWidgets('restores the eligible owned trashed assets', (tester) async { + final asset = owned(); + + await pumpRestore(tester, {asset}); + + verify(() => assetService.restoreTrash([asset.id])).called(1); + }); + + testWidgets('ignores assets owned by someone else', (tester) async { + final mine = owned(); + final theirs = RemoteAssetFactory.create(deletedAt: DateTime(2020)); + + await pumpRestore(tester, {mine, theirs}); + + verify(() => assetService.restoreTrash([mine.id])).called(1); + }); + + testWidgets('skips owned assets that are not trashed', (tester) async { + final trashed = owned(); + final live = owned(trashed: false); + + await pumpRestore(tester, {trashed, live}); + + verify(() => assetService.restoreTrash([trashed.id])).called(1); + }); + + testWidgets('batches every eligible owned asset into a single call', (tester) async { + final first = owned(); + final second = owned(); + + await pumpRestore(tester, {first, second}); + + verify(() => assetService.restoreTrash([first.id, second.id])).called(1); + }); + + testWidgets('clears the selection once the restore succeeds', (tester) async { + await pumpRestore(tester, {owned()}); + await tester.pumpAndSettle(); + + expect(find.byType(ImmichIconButton), findsNothing, reason: 'an empty selection hides the action'); + }); + + testWidgets('is hidden when no owned asset is trashed', (tester) async { + await tester.pumpTestWidget( + context, + const ActionIconButton(action: RestoreAction(source: .timeline)), + overrides: context.selected({owned(trashed: false)}), + ); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + }); +}