diff --git a/mobile/lib/presentation/actions/archive.action.dart b/mobile/lib/presentation/actions/archive.action.dart new file mode 100644 index 0000000000..312879d92b --- /dev/null +++ b/mobile/lib/presentation/actions/archive.action.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.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/providers/routes.provider.dart'; +import 'package:immich_mobile/utils/error_handler.dart'; + +typedef _State = ({bool shouldArchive, List assetIds}); + +final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, source) { + if (ref.watch(inLockedViewProvider)) { + return null; + } + + final AssetsActionState(:ownedAssets) = ref.watch(assetsActionProvider(source)); + final shouldArchive = ownedAssets.notVisibility(.archive).isNotEmpty; + final assetIds = ownedAssets + .visibility(shouldArchive ? .timeline : .archive) + .map((asset) => asset.id) + .toList(growable: false); + return assetIds.isEmpty ? null : (shouldArchive: shouldArchive, assetIds: assetIds); +}); + +class ArchiveAction extends AssetActionBuilder { + const ArchiveAction({required super.source}); + + @override + ActionData? build(BuildContext context, WidgetRef ref) { + final shouldArchive = ref.watch(_stateProvider(source).select((state) => state?.shouldArchive)); + if (shouldArchive == null) { + return null; + } + + return .new( + icon: shouldArchive ? Icons.archive_outlined : Icons.unarchive_outlined, + label: shouldArchive ? context.t.archive : context.t.unarchive, + onAction: () => _archive(context, ref), + ); + } + + Future _archive(BuildContext context, WidgetRef ref) async { + final state = ref.read(_stateProvider(source)); + if (state == null) { + return; + } + + final (:shouldArchive, :assetIds) = state; + final message = shouldArchive + ? context.t.archive_action_prompt(count: assetIds.length) + : context.t.unarchive_action_prompt(count: assetIds.length); + final service = ref.read(assetServiceProvider); + final toast = ref.read(toastRepositoryProvider); + final selection = ref.read(assetsActionProvider(source).notifier); + + try { + await service.update(assetIds, visibility: .some(shouldArchive ? .archive : .timeline)); + toast.success(message); + selection.clearSelect(); + } catch (error, stack) { + handleError(error, stack: stack, description: "Failed to update the archive status for assets"); + } + } +} 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 dc48ed57ec..943d29bbff 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 @@ -2,14 +2,14 @@ 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/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/archive.action.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/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'; @@ -17,11 +17,11 @@ 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/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.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}); @@ -38,12 +38,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; @@ -58,11 +52,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( @@ -82,20 +71,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), - ), + const ActionMenuItem(action: ArchiveAction(source: .viewer)), BaseActionButton( iconData: Icons.lock_outline, label: "locked_folder".tr(), @@ -192,7 +168,7 @@ class _AddActionButtonState extends ConsumerState { final themeData = widget.originalTheme ?? context.themeData; - return MenuAnchor( + return ImmichMenu( consumeOutsideTap: true, style: MenuStyle( backgroundColor: WidgetStatePropertyAll(themeData.scaffoldBackgroundColor), @@ -203,7 +179,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 765f2cbfc5..8207850f7a 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/stack.action.dart'; import 'package:immich_mobile/presentation/actions/favorite.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; @@ -15,7 +16,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_f import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_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/album/album_selector.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; @@ -83,7 +83,7 @@ class _ArchiveBottomSheetState extends ConsumerState { const ShareActionButton(source: ActionSource.timeline), if (multiselect.hasRemote) ...[ const ShareLinkActionButton(source: ActionSource.timeline), - const UnArchiveActionButton(source: ActionSource.timeline), + const ActionColumnButton(action: ArchiveAction(source: .timeline)), const ActionColumnButton(action: FavoriteAction(source: .timeline)), 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 4d2b29c742..23c987d128 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/stack.action.dart'; import 'package:immich_mobile/presentation/actions/favorite.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'; @@ -81,7 +81,7 @@ class FavoriteBottomSheet extends ConsumerWidget { if (multiselect.hasRemote) ...[ const ShareLinkActionButton(source: ActionSource.timeline), const ActionColumnButton(action: FavoriteAction(source: .timeline)), - const ArchiveActionButton(source: ActionSource.timeline), + const ActionColumnButton(action: ArchiveAction(source: .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 f479288824..4359bdef8a 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/stack.action.dart'; import 'package:immich_mobile/presentation/actions/favorite.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'; @@ -96,7 +96,7 @@ class _GeneralBottomSheetState extends ConsumerState { ? const TrashActionButton(source: ActionSource.timeline) : const DeletePermanentActionButton(source: ActionSource.timeline), const ActionColumnButton(action: FavoriteAction(source: .timeline)), - const ArchiveActionButton(source: ActionSource.timeline), + const ActionColumnButton(action: ArchiveAction(source: .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 dd5be41d2f..5783092731 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/stack.action.dart'; import 'package:immich_mobile/presentation/actions/favorite.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'; @@ -95,7 +95,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState const ShareLinkActionButton(source: ActionSource.timeline), if (ownsAlbum) ...[ - const ArchiveActionButton(source: ActionSource.timeline), + const ActionColumnButton(action: ArchiveAction(source: .timeline)), const ActionColumnButton(action: FavoriteAction(source: .timeline)), ], const DownloadActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 7ec3d2fb22..0f3fee2dbe 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -118,28 +118,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/services/action.service.dart b/mobile/lib/services/action.service.dart index 72987d1a27..c9ab950f6a 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -68,16 +68,6 @@ class ActionService { unawaited(context.pushRoute(SharedLinkEditRoute(assetsList: remoteIds))); } - Future archive(List remoteIds) async { - await _assetApiRepository.updateVisibility(remoteIds, .archive); - await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.archive); - } - - Future unArchive(List remoteIds) async { - await _assetApiRepository.updateVisibility(remoteIds, .timeline); - await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline); - } - Future moveToLockFolder(List remoteIds, List localIds) async { await _assetApiRepository.updateVisibility(remoteIds, .locked); await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked); diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 8b51a5ea6a..3e70205702 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -8,10 +8,10 @@ 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/actions/restore.action.dart'; import 'package:immich_mobile/presentation/actions/stack.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'; @@ -30,7 +30,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/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 => ActionMenuItem(action: ArchiveAction(source: context.source)), ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.restoreTrash => ActionMenuItem(action: RestoreAction(source: context.source)), 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..dde2dbed03 --- /dev/null +++ b/mobile/test/unit/presentation/actions/archive_action_test.dart @@ -0,0 +1,127 @@ +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/archive.action.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; +import 'package:immich_mobile/utils/option.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({AssetVisibility visibility = .timeline}) => + RemoteAssetFactory.create(ownerId: context.currentUser.id, visibility: visibility); + + Future pumpArchive(WidgetTester tester, Set selection) => + tester.pumpTestAction(context, const ArchiveAction(source: .timeline), overrides: context.selected(selection)); + + group('ArchiveAction', () { + testWidgets('archives the eligible owned assets', (tester) async { + final asset = owned(); + + await pumpArchive(tester, {asset}); + + verify(() => assetService.update([asset.id], visibility: const Option.some(AssetVisibility.archive))).called(1); + }); + + testWidgets('unarchive the eligible owned assets', (tester) async { + final asset = owned(visibility: .archive); + + await pumpArchive(tester, {asset}); + + verify(() => assetService.update([asset.id], visibility: const .some(.timeline))).called(1); + }); + + testWidgets('prioritizes archive when mixed state', (tester) async { + final onTimeline = owned(); + final archived = owned(visibility: .archive); + + await pumpArchive(tester, {onTimeline, archived}); + + verify(() => assetService.update([onTimeline.id], visibility: const .some(.archive))).called(1); + verifyNever(() => assetService.update(any(), visibility: const .some(.timeline))); + }); + + testWidgets('ignores assets owned by someone else', (tester) async { + final mine = owned(); + final theirs = RemoteAssetFactory.create(); + + await pumpArchive(tester, {mine, theirs}); + + verify(() => assetService.update([mine.id], visibility: const .some(.archive))).called(1); + }); + + testWidgets('batches every eligible owned asset into a single call', (tester) async { + final first = owned(); + final second = owned(); + + await pumpArchive(tester, {first, second}); + + verify(() => assetService.update([first.id, second.id], visibility: const .some(.archive))).called(1); + }); + + testWidgets('skips owned assets already in the target state', (tester) async { + final stale = owned(); + final alreadyArchived = owned(visibility: .archive); + + await pumpArchive(tester, {stale, alreadyArchived}); + + verify(() => assetService.update([stale.id], visibility: const .some(.archive))).called(1); + }); + + testWidgets('clears the selection once the update succeeds', (tester) async { + await pumpArchive(tester, {owned()}); + await tester.pumpAndSettle(); + + expect(find.byType(ImmichIconButton), findsNothing, reason: 'an empty selection hides the action'); + }); + + testWidgets('is hidden for locked assets, which belong to neither direction', (tester) async { + await tester.pumpTestWidget( + context, + const ActionIconButton(action: ArchiveAction(source: .timeline)), + overrides: context.selected({owned(visibility: .locked)}), + ); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + + testWidgets('is hidden inside the locked folder view', (tester) async { + await tester.pumpTestWidget( + context, + const ActionIconButton(action: ArchiveAction(source: .timeline)), + overrides: [ + ...context.selected({owned()}), + inLockedViewProvider.overrideWithValue(true), + ], + ); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + + testWidgets('is hidden when none of the selected assets are owned', (tester) async { + await tester.pumpTestWidget( + context, + const ActionIconButton(action: ArchiveAction(source: .timeline)), + overrides: context.selected({RemoteAssetFactory.create()}), + ); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + }); +} diff --git a/mobile/test/unit/presentation/actions/restore_action_test.dart b/mobile/test/unit/presentation/actions/restore_action_test.dart index 03760b0c6f..f6bbb15cda 100644 --- a/mobile/test/unit/presentation/actions/restore_action_test.dart +++ b/mobile/test/unit/presentation/actions/restore_action_test.dart @@ -23,7 +23,7 @@ void main() { }); RemoteAsset owned({bool trashed = true}) => - RemoteAssetFactory.create(ownerId: context.currentUser.id, deletedAt: trashed ? DateTime(2020) : null); + RemoteAssetFactory.create(ownerId: context.currentUser.id, deletedAt: trashed ? .new(2020) : null); Future pumpRestore(WidgetTester tester, Set selection) => tester.pumpTestAction(context, const RestoreAction(source: .timeline), overrides: context.selected(selection)); @@ -39,7 +39,7 @@ void main() { testWidgets('ignores assets owned by someone else', (tester) async { final mine = owned(); - final theirs = RemoteAssetFactory.create(deletedAt: DateTime(2020)); + final theirs = RemoteAssetFactory.create(deletedAt: .new(2020)); await pumpRestore(tester, {mine, theirs});