From fe4ed2a4470d1f358215d4e5c5d4500e098e6ff3 Mon Sep 17 00:00:00 2001 From: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:09:31 +0530 Subject: [PATCH] refactor: mobile stack action --- .../presentation/actions/favorite.action.dart | 2 +- .../presentation/actions/stack.action.dart | 72 ++++++++++++ .../stack_action_button.widget.dart | 50 --------- .../unstack_action_button.widget.dart | 48 -------- .../archive_bottom_sheet.widget.dart | 6 +- .../favorite_bottom_sheet.widget.dart | 6 +- .../general_bottom_sheet.widget.dart | 6 +- .../remote_album_bottom_sheet.widget.dart | 6 +- .../infrastructure/action.provider.dart | 48 -------- .../timeline/multiselect.provider.dart | 2 - mobile/lib/services/action.service.dart | 10 -- mobile/lib/utils/action_button.utils.dart | 4 +- .../actions/stack_action_test.dart | 103 ++++++++++++++++++ 13 files changed, 186 insertions(+), 177 deletions(-) create mode 100644 mobile/lib/presentation/actions/stack.action.dart delete mode 100644 mobile/lib/presentation/widgets/action_buttons/stack_action_button.widget.dart delete mode 100644 mobile/lib/presentation/widgets/action_buttons/unstack_action_button.widget.dart create mode 100644 mobile/test/unit/presentation/actions/stack_action_test.dart diff --git a/mobile/lib/presentation/actions/favorite.action.dart b/mobile/lib/presentation/actions/favorite.action.dart index 101ce3ed36..2ac1aa230c 100644 --- a/mobile/lib/presentation/actions/favorite.action.dart +++ b/mobile/lib/presentation/actions/favorite.action.dart @@ -43,7 +43,7 @@ class FavoriteAction extends AssetActionBuilder { return; } - final _State(:shouldFavorite, :assetIds) = state; + final (:shouldFavorite, :assetIds) = state; final message = shouldFavorite ? context.t.favorite_action_prompt(count: assetIds.length) : context.t.unfavorite_action_prompt(count: assetIds.length); diff --git a/mobile/lib/presentation/actions/stack.action.dart b/mobile/lib/presentation/actions/stack.action.dart new file mode 100644 index 0000000000..ebee6e17da --- /dev/null +++ b/mobile/lib/presentation/actions/stack.action.dart @@ -0,0 +1,72 @@ +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/user.provider.dart'; +import 'package:immich_mobile/utils/error_handler.dart'; + +typedef _State = ({bool shouldStack, List assetIds, List stackIds}); + +final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, source) { + final AssetsActionState(:ownedAssets) = ref.watch(assetsActionProvider(source)); + final shouldStack = ownedAssets.stacked(isStacked: false).isNotEmpty; + // Stacking needs at least two assets; unstacking needs at least one stack. + if (shouldStack ? ownedAssets.elementAtOrNull(1) == null : ownedAssets.isEmpty) { + return null; + } + + return ( + shouldStack: shouldStack, + assetIds: ownedAssets.map((asset) => asset.id).toList(growable: false), + stackIds: ownedAssets.map((asset) => asset.stackId).nonNulls.toList(growable: false), + ); +}); + +class StackAction extends AssetActionBuilder { + const StackAction({required super.source}); + + @override + ActionData? build(BuildContext context, WidgetRef ref) { + final shouldStack = ref.watch(_stateProvider(source).select((state) => state?.shouldStack)); + if (shouldStack == null) { + return null; + } + + return .new( + icon: shouldStack ? Icons.filter_none_rounded : Icons.layers_clear_outlined, + label: shouldStack ? context.t.stack : context.t.unstack, + onAction: () => _stack(context, ref), + ); + } + + Future _stack(BuildContext context, WidgetRef ref) async { + final state = ref.read(_stateProvider(source)); + if (state == null) { + return; + } + + final (:shouldStack, :assetIds, :stackIds) = state; + final message = shouldStack + ? context.t.stacked_assets_count(count: assetIds.length) + : context.t.unstacked_assets_count(count: assetIds.length); + final service = ref.read(assetServiceProvider); + final userId = ref.read(authUserProvider).id; + final toast = ref.read(toastRepositoryProvider); + final selection = ref.read(assetsActionProvider(source).notifier); + + try { + if (shouldStack) { + await service.stack(userId, assetIds); + } else { + await service.unstack(stackIds); + } + toast.success(message); + selection.clearSelect(); + } catch (error, stack) { + handleError(error, stack: stack, description: "Failed to update the stack for assets"); + } + } +} diff --git a/mobile/lib/presentation/widgets/action_buttons/stack_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/stack_action_button.widget.dart deleted file mode 100644 index 22fccf5473..0000000000 --- a/mobile/lib/presentation/widgets/action_buttons/stack_action_button.widget.dart +++ /dev/null @@ -1,50 +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/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/providers/user.provider.dart'; -import 'package:immich_mobile/widgets/common/immich_toast.dart'; - -class StackActionButton extends ConsumerWidget { - final ActionSource source; - - const StackActionButton({super.key, required this.source}); - - void _onTap(BuildContext context, WidgetRef ref) async { - if (!context.mounted) { - return; - } - - final user = ref.watch(currentUserProvider); - if (user == null) { - throw Exception('User must be logged in to access stack action'); - } - - final result = await ref.read(actionProvider.notifier).stack(user.id, source); - ref.read(multiSelectProvider.notifier).reset(); - - final successMessage = 'stack_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, - ); - } - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - return BaseActionButton( - iconData: Icons.filter_none_rounded, - label: "stack".t(context: context), - onPressed: () => _onTap(context, ref), - ); - } -} diff --git a/mobile/lib/presentation/widgets/action_buttons/unstack_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/unstack_action_button.widget.dart deleted file mode 100644 index e7badf129f..0000000000 --- a/mobile/lib/presentation/widgets/action_buttons/unstack_action_button.widget.dart +++ /dev/null @@ -1,48 +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/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 UnStackActionButton extends ConsumerWidget { - final ActionSource source; - final bool iconOnly; - final bool menuItem; - - const UnStackActionButton({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).unStack(source); - ref.read(multiSelectProvider.notifier).reset(); - - final successMessage = 'unstack_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, - ); - } - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - return BaseActionButton( - iconData: Icons.layers_clear_outlined, - label: "unstack".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 85fa8b4563..765f2cbfc5 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/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'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart'; @@ -13,10 +14,8 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_ import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_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/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'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; @@ -93,8 +92,7 @@ class _ArchiveBottomSheetState extends ConsumerState { const EditDateTimeActionButton(source: ActionSource.timeline), const EditLocationActionButton(source: ActionSource.timeline), const MoveToLockFolderActionButton(source: ActionSource.timeline), - if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline), - if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline), + const ActionColumnButton(action: StackAction(source: .timeline)), ], if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline), ], 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 da9b5f1d17..4d2b29c742 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,6 +5,7 @@ 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/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'; @@ -15,9 +16,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_ import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_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/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/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'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; @@ -90,8 +89,7 @@ class FavoriteBottomSheet extends ConsumerWidget { const EditDateTimeActionButton(source: ActionSource.timeline), const EditLocationActionButton(source: ActionSource.timeline), const MoveToLockFolderActionButton(source: ActionSource.timeline), - if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline), - if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline), + const ActionColumnButton(action: StackAction(source: .timeline)), ], if (multiselect.hasMerged) const DeleteLocalActionButton(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 2f1ffe4cbc..f479288824 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,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/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'; @@ -16,9 +17,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_ import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_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/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/unstack_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_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'; @@ -102,8 +101,7 @@ class _GeneralBottomSheetState extends ConsumerState { const EditDateTimeActionButton(source: ActionSource.timeline), const EditLocationActionButton(source: ActionSource.timeline), const MoveToLockFolderActionButton(source: ActionSource.timeline), - if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline), - if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline), + const ActionColumnButton(action: StackAction(source: .timeline)), if (multiselect.onlyLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline), ], if (multiselect.onlyLocal || multiselect.hasMerged) 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 f6cbc5eac9..dd5be41d2f 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,6 +4,7 @@ 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/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'; @@ -16,9 +17,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_al import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart'; 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/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/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'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; @@ -107,8 +106,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState const EditDateTimeActionButton(source: ActionSource.timeline), const EditLocationActionButton(source: ActionSource.timeline), const MoveToLockFolderActionButton(source: ActionSource.timeline), - if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline), - if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline), + const ActionColumnButton(action: StackAction(source: .timeline)), ], ], if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 26a8c8461a..7ec3d2fb22 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -7,12 +7,10 @@ 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/models/asset_edit.model.dart'; -import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider; import 'package:immich_mobile/providers/infrastructure/tag.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; @@ -53,7 +51,6 @@ class ActionNotifier extends Notifier { final Logger _logger = Logger('ActionNotifier'); late ActionService _service; late ForegroundUploadService _foregroundUploadService; - late AssetService _assetService; ActionNotifier() : super(); @@ -61,7 +58,6 @@ class ActionNotifier extends Notifier { void build() { _foregroundUploadService = ref.watch(foregroundUploadServiceProvider); _service = ref.watch(actionServiceProvider); - _assetService = ref.watch(assetServiceProvider); } List _getRemoteIdsForSource(ActionSource source) { @@ -91,21 +87,6 @@ class ActionNotifier extends Notifier { return _getAssets(source).whereType().ownedAssets(ownerId).toIds().toList(growable: false); } - List _getOwnedRemoteAssetsForSource(ActionSource source) { - final ownerId = ref.read(currentUserProvider)?.id; - return _getIdsForSource(source).ownedAssets(ownerId).toList(); - } - - Iterable _getIdsForSource(ActionSource source) { - final Set assets = _getAssets(source); - return switch (T) { - const (RemoteAsset) => assets.whereType(), - const (LocalAsset) => assets.whereType(), - _ => const [], - } - as Iterable; - } - Set _getAssets(ActionSource source) { return switch (source) { ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets, @@ -438,35 +419,6 @@ class ActionNotifier extends Notifier { } } - Future stack(String userId, ActionSource source) async { - final ids = _getOwnedRemoteIdsForSource(source); - try { - await _service.stack(userId, ids); - return ActionResult(count: ids.length, success: true); - } catch (error, stack) { - _logger.severe('Failed to stack assets', error, stack); - return ActionResult(count: ids.length, success: false, error: error.toString()); - } - } - - Future unStack(ActionSource source) async { - final assets = _getOwnedRemoteAssetsForSource(source); - try { - await _service.unStack(assets.map((e) => e.stackId).nonNulls.toList()); - if (source == ActionSource.viewer) { - final updatedParent = await _assetService.getRemoteAsset(assets.first.id); - if (updatedParent != null) { - ref.read(assetViewerProvider.notifier).setAsset(updatedParent); - } - } - - return ActionResult(count: assets.length, success: true); - } catch (error, stack) { - _logger.severe('Failed to unstack assets', error, stack); - return ActionResult(count: assets.length, success: false); - } - } - Future shareAssets( ActionSource source, BuildContext context, { diff --git a/mobile/lib/providers/timeline/multiselect.provider.dart b/mobile/lib/providers/timeline/multiselect.provider.dart index 10c8bb86b6..b67831d028 100644 --- a/mobile/lib/providers/timeline/multiselect.provider.dart +++ b/mobile/lib/providers/timeline/multiselect.provider.dart @@ -22,8 +22,6 @@ class MultiSelectState { bool get hasRemote => selectedAssets.any((asset) => asset.storage == AssetState.remote || asset.storage == AssetState.merged); - bool get hasStacked => selectedAssets.any((asset) => asset is RemoteAsset && asset.stackId != null); - bool get hasMerged => selectedAssets.any((asset) => asset.storage == AssetState.merged); bool get onlyLocal => selectedAssets.any((asset) => asset.storage == AssetState.local); diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index a8cbc4d3f7..72987d1a27 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -253,16 +253,6 @@ class ActionService { return _tagService.bulkTagAssets(remoteIds, selectedTagIds.toList()); } - Future stack(String userId, List remoteIds) async { - final stack = await _assetApiRepository.stack(remoteIds); - await _remoteAssetRepository.stack(userId, stack); - } - - Future unStack(List stackIds) async { - await _remoteAssetRepository.unStack(stackIds); - await _assetApiRepository.unStack(stackIds); - } - Future shareAssets( List assets, BuildContext context, { diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index a838333475..8b51a5ea6a 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -10,6 +10,7 @@ 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/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'; @@ -30,7 +31,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos 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'; @@ -244,7 +244,7 @@ enum ActionButtonType { menuItem: menuItem, ), ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem), - ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), + ActionButtonType.unstack => ActionMenuItem(action: StackAction(source: context.source)), ActionButtonType.openInBrowser => OpenInBrowserActionButton( remoteId: context.asset.remoteId!, origin: context.timelineOrigin, diff --git a/mobile/test/unit/presentation/actions/stack_action_test.dart b/mobile/test/unit/presentation/actions/stack_action_test.dart new file mode 100644 index 0000000000..e2b37a3c44 --- /dev/null +++ b/mobile/test/unit/presentation/actions/stack_action_test.dart @@ -0,0 +1,103 @@ +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/stack.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({String? stackId}) => RemoteAssetFactory.create(ownerId: context.currentUser.id, stackId: stackId); + + Future pumpStack(WidgetTester tester, Set selection) => + tester.pumpTestAction(context, const StackAction(source: .timeline), overrides: context.selected(selection)); + + group('StackAction', () { + testWidgets('stacks the eligible owned assets', (tester) async { + final first = owned(); + final second = owned(); + + await pumpStack(tester, {first, second}); + + verify(() => assetService.stack(context.currentUser.id, [first.id, second.id])).called(1); + }); + + testWidgets('unstacks the eligible owned assets', (tester) async { + final asset = owned(stackId: 'stack'); + + await pumpStack(tester, {asset}); + + verify(() => assetService.unstack(['stack'])).called(1); + }); + + testWidgets('prioritizes stack when mixed state', (tester) async { + final first = owned(); + final second = owned(stackId: 'stack'); + + await pumpStack(tester, {first, second}); + + verify(() => assetService.stack(context.currentUser.id, [first.id, second.id])).called(1); + }); + + testWidgets('ignores assets owned by someone else', (tester) async { + final mine = owned(); + final other = owned(); + final theirs = RemoteAssetFactory.create(); + + await pumpStack(tester, {mine, other, theirs}); + + verify(() => assetService.stack(context.currentUser.id, [mine.id, other.id])).called(1); + }); + + testWidgets('unstacks every selected stack in a single call', (tester) async { + final first = owned(stackId: 'stack-1'); + final second = owned(stackId: 'stack-2'); + + await pumpStack(tester, {first, second}); + + verify(() => assetService.unstack(['stack-1', 'stack-2'])).called(1); + }); + + testWidgets('clears the selection once the stack succeeds', (tester) async { + await pumpStack(tester, {owned(), owned()}); + await tester.pumpAndSettle(); + + expect(find.byType(ImmichIconButton), findsNothing, reason: 'an empty selection hides the action'); + }); + + testWidgets('is hidden when a lone unstacked asset has nothing to stack onto', (tester) async { + await tester.pumpTestWidget( + context, + const ActionIconButton(action: StackAction(source: .timeline)), + overrides: context.selected({owned()}), + ); + + 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: StackAction(source: .timeline)), + overrides: context.selected({RemoteAssetFactory.create(), RemoteAssetFactory.create()}), + ); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + }); +}