From 00f2bfb8d8964fefb57fedda2f000f9761d57ec4 Mon Sep 17 00:00:00 2001 From: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Date: Sun, 28 Jun 2026 02:00:08 +0530 Subject: [PATCH] feat: archive action --- mobile/lib/constants/enums.dart | 2 - mobile/lib/domain/services/asset.service.dart | 10 +++ .../presentation/actions/archive.action.dart | 42 ++++++++++ .../add_action_button.widget.dart | 58 ++++---------- .../archive_action_button.widget.dart | 59 -------------- .../unarchive_action_button.widget.dart | 61 --------------- .../archive_bottom_sheet.widget.dart | 5 +- .../favorite_bottom_sheet.widget.dart | 5 +- .../general_bottom_sheet.widget.dart | 5 +- .../remote_album_bottom_sheet.widget.dart | 5 +- .../infrastructure/action.provider.dart | 22 ------ .../repositories/asset_api.repository.dart | 17 ++-- mobile/lib/services/action.service.dart | 14 +--- mobile/lib/utils/action_button.utils.dart | 11 +-- .../unit/factories/remote_asset_factory.dart | 9 ++- mobile/test/unit/mocks.dart | 4 + .../actions/archive_action_test.dart | 78 +++++++++++++++++++ 17 files changed, 180 insertions(+), 227 deletions(-) create mode 100644 mobile/lib/presentation/actions/archive.action.dart delete mode 100644 mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart delete mode 100644 mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart create mode 100644 mobile/test/unit/presentation/actions/archive_action_test.dart diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index 72479416a8..d59c48c045 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -9,8 +9,6 @@ enum SortOrder { enum TextSearchType { context, filename, description, ocr } -enum AssetVisibilityEnum { timeline, hidden, archive, locked } - enum ActionSource { timeline, viewer } enum ShareAssetType { original, preview } diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index ca16d1f980..bba29de3aa 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -77,4 +77,14 @@ class AssetService { await _apiRepository.updateFavorite(remoteIds, isFavorite); await _remoteRepository.updateFavorite(remoteIds, isFavorite); } + + Future updateArchive(List remoteIds, bool isArchived) async { + if (remoteIds.isEmpty) { + return; + } + + final visibility = isArchived ? AssetVisibility.archive : AssetVisibility.timeline; + await _apiRepository.updateVisibility(remoteIds, visibility); + await _remoteRepository.updateVisibility(remoteIds, visibility); + } } diff --git a/mobile/lib/presentation/actions/archive.action.dart b/mobile/lib/presentation/actions/archive.action.dart new file mode 100644 index 0000000000..449ebdfdad --- /dev/null +++ b/mobile/lib/presentation/actions/archive.action.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.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/routes.provider.dart'; +import 'package:immich_ui/immich_ui.dart'; + +class ArchiveAction extends AssetAction { + final bool shouldArchive; + + ArchiveAction({required super.assets}) + : shouldArchive = assets.any((asset) => asset is RemoteAsset && asset.visibility != .archive); + + @override + IconData get icon => shouldArchive ? Icons.archive_outlined : Icons.unarchive_outlined; + + @override + String label(ActionScope scope) => shouldArchive ? scope.context.t.archive : scope.context.t.unarchive; + + @override + Iterable filter(ActionScope scope) => assets.whereType().where( + (asset) => + asset.ownerId == scope.authUser.id && + asset.visibility == (shouldArchive ? AssetVisibility.timeline : AssetVisibility.archive), + ); + + @override + bool isVisible(ActionScope scope) => !scope.ref.watch(inLockedViewProvider) && filter(scope).isNotEmpty; + + @override + Future onAction(ActionScope scope) async { + final ActionScope(:ref) = scope; + final assets = filter(scope).map((asset) => asset.id).toList(growable: false); + + await ref.read(assetServiceProvider).updateArchive(assets, shouldArchive); + final message = shouldArchive + ? StaticTranslations.instance.archive_action_prompt(count: assets.length) + : StaticTranslations.instance.unarchive_action_prompt(count: assets.length); + snackbar.success(message); + } +} diff --git a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart index 1ab3f2039d..65d9a4bc0f 100644 --- a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart @@ -1,26 +1,24 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; -import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; -import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; -import 'package:immich_mobile/providers/routes.provider.dart'; -import 'package:immich_mobile/widgets/common/immich_toast.dart'; -import 'package:immich_mobile/providers/user.provider.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/constants/enums.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.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/archive.action.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_ui/immich_ui.dart'; -enum AddToMenuItem { album, archive, unarchive, lockedFolder } +enum AddToMenuItem { album, lockedFolder } class AddActionButton extends ConsumerStatefulWidget { const AddActionButton({super.key, this.originalTheme}); @@ -37,12 +35,6 @@ class _AddActionButtonState extends ConsumerState { case AddToMenuItem.album: _openAlbumSelector(); break; - case AddToMenuItem.archive: - performArchiveAction(context, ref, source: ActionSource.viewer); - break; - case AddToMenuItem.unarchive: - performUnArchiveAction(context, ref, source: ActionSource.viewer); - break; case AddToMenuItem.lockedFolder: performMoveToLockFolderAction(context, ref, source: ActionSource.viewer); break; @@ -57,11 +49,6 @@ class _AddActionButtonState extends ConsumerState { final user = ref.read(currentUserProvider); final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; - final isInLockedView = ref.watch(inLockedViewProvider); - final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive; - final hasRemote = asset is RemoteAsset; - final showArchive = isOwner && !isInLockedView && hasRemote && !isArchived; - final showUnarchive = isOwner && !isInLockedView && hasRemote && isArchived; return [ Padding( @@ -81,20 +68,7 @@ class _AddActionButtonState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Text("move_to".tr(), style: context.textTheme.labelMedium), ), - if (showArchive) - BaseActionButton( - iconData: Icons.archive_outlined, - label: "archive".tr(), - menuItem: true, - onPressed: () => _handleMenuSelection(AddToMenuItem.archive), - ), - if (showUnarchive) - BaseActionButton( - iconData: Icons.unarchive_outlined, - label: "unarchive".tr(), - menuItem: true, - onPressed: () => _handleMenuSelection(AddToMenuItem.unarchive), - ), + ActionMenuItemWidget(action: ArchiveAction(assets: [asset])), BaseActionButton( iconData: Icons.lock_outline, label: "locked_folder".tr(), @@ -184,7 +158,7 @@ class _AddActionButtonState extends ConsumerState { final themeData = widget.originalTheme ?? context.themeData; - return MenuAnchor( + return ImmichMenu( consumeOutsideTap: true, style: MenuStyle( backgroundColor: WidgetStatePropertyAll(themeData.scaffoldBackgroundColor), @@ -195,7 +169,7 @@ class _AddActionButtonState extends ConsumerState { ), padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)), ), - menuChildren: widget.originalTheme != null + children: widget.originalTheme != null ? [ Theme( data: widget.originalTheme!, diff --git a/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart deleted file mode 100644 index bb2cae21ad..0000000000 --- a/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart +++ /dev/null @@ -1,59 +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'; - -// used to allow performing archive action from different sources (without duplicating code) -Future performArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async { - if (!context.mounted) { - return; - } - - if (source == ActionSource.viewer) { - EventStream.shared.emit(const ViewerReloadAssetEvent()); - } - - final result = await ref.read(actionProvider.notifier).archive(source); - ref.read(multiSelectProvider.notifier).reset(); - - final successMessage = 'archive_action_prompt'.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, - ); - } -} - -class ArchiveActionButton extends ConsumerWidget { - final ActionSource source; - final bool iconOnly; - final bool menuItem; - - const ArchiveActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); - - Future _onTap(BuildContext context, WidgetRef ref) async { - await performArchiveAction(context, ref, source: source); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - return BaseActionButton( - iconData: Icons.archive_outlined, - label: "to_archive".t(context: context), - iconOnly: iconOnly, - menuItem: menuItem, - onPressed: () => _onTap(context, ref), - ); - } -} diff --git a/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart deleted file mode 100644 index 57221303a8..0000000000 --- a/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart +++ /dev/null @@ -1,61 +0,0 @@ -// dart -// File: `lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart` -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/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'; -import 'package:immich_mobile/domain/models/events.model.dart'; -import 'package:immich_mobile/domain/utils/event_stream.dart'; - -// used to allow performing unarchive action from different sources (without duplicating code) -Future performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async { - if (!context.mounted) { - return; - } - - if (source == ActionSource.viewer) { - EventStream.shared.emit(const ViewerReloadAssetEvent()); - } - - final result = await ref.read(actionProvider.notifier).unArchive(source); - ref.read(multiSelectProvider.notifier).reset(); - - final successMessage = 'unarchive_action_prompt'.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, - ); - } -} - -class UnArchiveActionButton extends ConsumerWidget { - final ActionSource source; - final bool iconOnly; - final bool menuItem; - - const UnArchiveActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); - - Future _onTap(BuildContext context, WidgetRef ref) async { - await performUnArchiveAction(context, ref, source: source); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - return BaseActionButton( - iconData: Icons.unarchive_outlined, - label: "unarchive".t(context: context), - iconOnly: iconOnly, - menuItem: menuItem, - onPressed: () => _onTap(context, ref), - ); - } -} diff --git a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart index 3c9c0c692e..f55faeea1d 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/archive.action.dart'; import 'package:immich_mobile/presentation/actions/favorite.action.dart'; import 'package:immich_mobile/presentation/actions/timeline.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; @@ -16,7 +17,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; @@ -77,7 +77,7 @@ class _ArchiveBottomSheetState extends ConsumerState { } final assets = multiselect.selectedAssets.toList(growable: false); - final actions = [FavoriteAction(assets: assets)]; + final actions = [FavoriteAction(assets: assets), ArchiveAction(assets: assets)]; return BaseBottomSheet( controller: sheetController, @@ -88,7 +88,6 @@ class _ArchiveBottomSheetState extends ConsumerState { const ShareActionButton(source: ActionSource.timeline), if (multiselect.hasRemote) ...[ const ShareLinkActionButton(source: ActionSource.timeline), - const UnArchiveActionButton(source: ActionSource.timeline), ...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))), if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline), isTrashEnable diff --git a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart index 8438d5e8ac..fb11b77cd8 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart @@ -5,9 +5,9 @@ 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/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/archive.action.dart'; import 'package:immich_mobile/presentation/actions/favorite.action.dart'; import 'package:immich_mobile/presentation/actions/timeline.action.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/archive_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/download_action_button.widget.dart'; @@ -68,7 +68,7 @@ class FavoriteBottomSheet extends ConsumerWidget { } final assets = multiselect.selectedAssets.toList(growable: false); - final actions = [FavoriteAction(assets: assets)]; + final actions = [FavoriteAction(assets: assets), ArchiveAction(assets: assets)]; return BaseBottomSheet( initialChildSize: 0.4, @@ -79,7 +79,6 @@ class FavoriteBottomSheet extends ConsumerWidget { if (multiselect.hasRemote) ...[ const ShareLinkActionButton(source: ActionSource.timeline), ...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))), - const ArchiveActionButton(source: ActionSource.timeline), if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline), isTrashEnable ? const TrashActionButton(source: ActionSource.timeline) diff --git a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart index 1949a79495..6c368a98e9 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart @@ -4,9 +4,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/archive.action.dart'; import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; import 'package:immich_mobile/presentation/actions/timeline.action.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_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'; @@ -84,7 +84,7 @@ class _GeneralBottomSheetState extends ConsumerState { } final assets = multiselect.selectedAssets.toList(growable: false); - final actions = [AssetDebugAction(assets: assets)]; + final actions = [AssetDebugAction(assets: assets), ArchiveAction(assets: assets)]; return BaseBottomSheet( controller: sheetController, @@ -102,7 +102,6 @@ class _GeneralBottomSheetState extends ConsumerState { ? const TrashActionButton(source: ActionSource.timeline) : const DeletePermanentActionButton(source: ActionSource.timeline), const FavoriteActionButton(source: ActionSource.timeline), - const ArchiveActionButton(source: ActionSource.timeline), if (tagsEnabled) const BulkTagAssetsActionButton(source: ActionSource.timeline), const EditDateTimeActionButton(source: ActionSource.timeline), const EditLocationActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart index a292c1899c..05b3aa1e41 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart @@ -4,9 +4,9 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/archive.action.dart'; import 'package:immich_mobile/presentation/actions/favorite.action.dart'; import 'package:immich_mobile/presentation/actions/timeline.action.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/archive_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/download_action_button.widget.dart'; @@ -86,7 +86,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState } final assets = multiselect.selectedAssets.toList(growable: false); - final actions = [FavoriteAction(assets: assets)]; + final actions = [FavoriteAction(assets: assets), ArchiveAction(assets: assets)]; return BaseBottomSheet( controller: sheetController, @@ -100,7 +100,6 @@ class _RemoteAlbumBottomSheetState extends ConsumerState const ShareLinkActionButton(source: ActionSource.timeline), if (ownsAlbum) ...[ - const ArchiveActionButton(source: ActionSource.timeline), ...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))), ], const DownloadActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index ed62b9a0e8..e3115e9370 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -178,28 +178,6 @@ class ActionNotifier extends Notifier { } } - Future archive(ActionSource source) async { - final ids = _getOwnedRemoteIdsForSource(source); - try { - await _service.archive(ids); - return ActionResult(count: ids.length, success: true); - } catch (error, stack) { - _logger.severe('Failed to archive assets', error, stack); - return ActionResult(count: ids.length, success: false, error: error.toString()); - } - } - - Future unArchive(ActionSource source) async { - final ids = _getOwnedRemoteIdsForSource(source); - try { - await _service.unArchive(ids); - return ActionResult(count: ids.length, success: true); - } catch (error, stack) { - _logger.severe('Failed to unarchive assets', error, stack); - return ActionResult(count: ids.length, success: false, error: error.toString()); - } - } - Future moveToLockFolder(ActionSource source) async { final ids = _getOwnedRemoteIdsForSource(source); final localIds = _getLocalIdsForSource(source, ignoreLocalOnly: true); diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 40233e90c4..110812ecec 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -1,12 +1,13 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart'; -import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset_edit.model.dart' hide AssetEditAction; import 'package:immich_mobile/domain/models/stack.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:openapi/api.dart'; +import 'package:openapi/api.dart' as api show AssetVisibility; +import 'package:openapi/api.dart' hide AssetVisibility; final assetApiRepositoryProvider = Provider( (ref) => AssetApiRepository( @@ -41,7 +42,7 @@ class AssetApiRepository extends ApiRepository { return response?.count ?? 0; } - Future updateVisibility(List ids, AssetVisibilityEnum visibility) async { + Future updateVisibility(List ids, AssetVisibility visibility) async { return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: Optional.present(_mapVisibility(visibility)))); } @@ -77,11 +78,11 @@ class AssetApiRepository extends ApiRepository { return _api.downloadAssetWithHttpInfo(id, edited: edited); } - _mapVisibility(AssetVisibilityEnum visibility) => switch (visibility) { - AssetVisibilityEnum.timeline => AssetVisibility.timeline, - AssetVisibilityEnum.hidden => AssetVisibility.hidden, - AssetVisibilityEnum.locked => AssetVisibility.locked, - AssetVisibilityEnum.archive => AssetVisibility.archive, + _mapVisibility(AssetVisibility visibility) => switch (visibility) { + AssetVisibility.timeline => api.AssetVisibility.timeline, + AssetVisibility.hidden => api.AssetVisibility.hidden, + AssetVisibility.locked => api.AssetVisibility.locked, + AssetVisibility.archive => api.AssetVisibility.archive, }; Future getAssetMIMEType(String assetId) async { diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 8e01777c5d..f10c7e829e 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -78,18 +78,8 @@ class ActionService { await _remoteAssetRepository.updateFavorite(remoteIds, false); } - Future archive(List remoteIds) async { - await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.archive); - await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.archive); - } - - Future unArchive(List remoteIds) async { - await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline); - await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline); - } - Future moveToLockFolder(List remoteIds, List localIds) async { - await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked); + await _assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked); await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked); // Ask user if they want to delete local copies @@ -99,7 +89,7 @@ class ActionService { } Future removeFromLockFolder(List remoteIds) async { - await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline); + await _assetApiRepository.updateVisibility(remoteIds, AssetVisibility.timeline); await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline); } diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 0e5a3123e7..44eb7d58cb 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -8,8 +8,8 @@ import 'package:immich_mobile/domain/models/events.model.dart'; 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/archive.action.dart'; import 'package:immich_mobile/presentation/actions/asset_debug.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'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; @@ -29,7 +29,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_act import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/slideshow_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -200,12 +199,8 @@ enum ActionButtonType { menuItem: menuItem, ), ActionButtonType.slideshow => SlideshowActionButton(iconOnly: iconOnly, menuItem: menuItem), - ActionButtonType.archive => ArchiveActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), - ActionButtonType.unarchive => UnArchiveActionButton( - source: context.source, - iconOnly: iconOnly, - menuItem: menuItem, - ), + ActionButtonType.archive || + ActionButtonType.unarchive => ActionMenuItemWidget(action: ArchiveAction(assets: [context.asset])), ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.restoreTrash => RestoreActionButton( diff --git a/mobile/test/unit/factories/remote_asset_factory.dart b/mobile/test/unit/factories/remote_asset_factory.dart index 669eb3998a..4df8ffca2a 100644 --- a/mobile/test/unit/factories/remote_asset_factory.dart +++ b/mobile/test/unit/factories/remote_asset_factory.dart @@ -5,7 +5,13 @@ import '../../utils.dart'; class RemoteAssetFactory { const RemoteAssetFactory(); - static RemoteAsset create({String? id, String? name, String? ownerId, bool isFavorite = false}) { + static RemoteAsset create({ + String? id, + String? name, + String? ownerId, + bool isFavorite = false, + AssetVisibility visibility = AssetVisibility.timeline, + }) { id = TestUtils.uuid(id); return RemoteAsset( @@ -17,6 +23,7 @@ class RemoteAssetFactory { createdAt: TestUtils.yesterday(), updatedAt: TestUtils.now(), isFavorite: isFavorite, + visibility: visibility, isEdited: false, ); } diff --git a/mobile/test/unit/mocks.dart b/mobile/test/unit/mocks.dart index 06993f854d..70fc5c6f03 100644 --- a/mobile/test/unit/mocks.dart +++ b/mobile/test/unit/mocks.dart @@ -89,6 +89,7 @@ class ServiceMocks { void _stubAssetService() { when(asset.updateFavorite).thenAnswer((_) async {}); + when(asset.updateArchive).thenAnswer((_) async {}); } } @@ -167,6 +168,9 @@ extension type const UserServiceStub(MockUserService service) implements Stub { Future Function() get updateFavorite => () => service.updateFavorite(any(), any()); + + Future Function() get updateArchive => + () => service.updateArchive(any(), any()); } extension type const NativeSyncApiStub(MockNativeSyncApi api) implements Stub { diff --git a/mobile/test/unit/presentation/actions/archive_action_test.dart b/mobile/test/unit/presentation/actions/archive_action_test.dart new file mode 100644 index 0000000000..b530c59384 --- /dev/null +++ b/mobile/test/unit/presentation/actions/archive_action_test.dart @@ -0,0 +1,78 @@ +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/archive.action.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../../domain/service.mock.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({AssetVisibility visibility = AssetVisibility.timeline}) => + RemoteAssetFactory.create(ownerId: context.currentUser.id, visibility: visibility); + + group('ArchiveAction', () { + testWidgets('archives the eligible owned assets', (tester) async { + final asset = owned(); + + await tester.pumpTestAction(context, ArchiveAction(assets: [asset])); + + verify(() => assetService.updateArchive([asset.id], true)).called(1); + }); + + testWidgets('unarchive the eligible owned assets', (tester) async { + final asset = owned(visibility: AssetVisibility.archive); + + await tester.pumpTestAction(context, ArchiveAction(assets: [asset])); + + verify(() => assetService.updateArchive([asset.id], false)).called(1); + }); + + testWidgets('ignores assets owned by someone else', (tester) async { + final mine = owned(); + final theirs = RemoteAssetFactory.create(); + + await tester.pumpTestAction(context, ArchiveAction(assets: [mine, theirs])); + + verify(() => assetService.updateArchive([mine.id], true)).called(1); + }); + + testWidgets('batches every eligible owned asset into a single call', (tester) async { + final first = owned(); + final second = owned(); + + await tester.pumpTestAction(context, ArchiveAction(assets: [first, second])); + + verify(() => assetService.updateArchive([first.id, second.id], true)).called(1); + }); + + testWidgets('skips owned assets already in the target state', (tester) async { + final stale = owned(); + final alreadyArchived = owned(visibility: AssetVisibility.archive); + + await tester.pumpTestAction(context, ArchiveAction(assets: [stale, alreadyArchived])); + + verify(() => assetService.updateArchive([stale.id], true)).called(1); + }); + + testWidgets('shows a confirmation snackbar on success', (tester) async { + await tester.pumpTestAction(context, ArchiveAction(assets: [owned()])); + await tester.pumpUntilFound(find.byType(SnackBar)); + + expect(find.byType(SnackBar), findsOneWidget); + }); + }); +}