diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index f35c962ff0..47573e0fc1 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -2,10 +2,15 @@ import 'package:immich_mobile/domain/models/album/local_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/models/exif.model.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_exif.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/utils/option.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -14,12 +19,16 @@ class AssetService { final RemoteExifRepository _exifRepository; final DriftLocalAssetRepository _localRepository; final AssetApiRepository _apiRepository; + final AssetMediaRepository _mediaRepository; + final DriftTrashedLocalAssetRepository _trashedLocalRepository; const AssetService({ required this._remoteRepository, required this._exifRepository, required this._localRepository, required this._apiRepository, + required this._mediaRepository, + required this._trashedLocalRepository, }); Future getAsset(BaseAsset asset) { @@ -167,7 +176,24 @@ class AssetService { } } - // TODO(shenlong): remove after action migration + Future deleteLocal(List localIds) async { + if (localIds.isEmpty) { + return 0; + } + + final deletedIds = await _mediaRepository.deleteAll(localIds); + if (deletedIds.isEmpty) { + return 0; + } + + if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) { + await _trashedLocalRepository.applyTrashedAssets(deletedIds); + } else { + await _localRepository.delete(deletedIds); + } + return deletedIds.length; + } + Future getLocalAsset(String id) { return _localRepository.get(id); } diff --git a/mobile/lib/presentation/actions/lock.action.dart b/mobile/lib/presentation/actions/lock.action.dart new file mode 100644 index 0000000000..d3389c6037 --- /dev/null +++ b/mobile/lib/presentation/actions/lock.action.dart @@ -0,0 +1,71 @@ +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/utils/error_handler.dart'; + +typedef _State = ({bool shouldLock, List assetIds, List localIds}); + +final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, source) { + final AssetsActionState(:ownedAssets) = ref.watch(assetsActionProvider(source)); + if (ownedAssets.isEmpty) { + return null; + } + + final shouldLock = ownedAssets.locked(isLocked: false).isNotEmpty; + final targets = ownedAssets.locked(isLocked: !shouldLock); + return ( + shouldLock: shouldLock, + assetIds: targets.map((asset) => asset.id).toList(growable: false), + // Only locking has an on-device copy to clean up; unlocking leaves the device alone. + localIds: shouldLock ? targets.map((asset) => asset.localId).nonNulls.toList(growable: false) : const [], + ); +}); + +class LockAction extends AssetActionBuilder { + const LockAction({required super.source}); + + @override + ActionData? build(BuildContext context, WidgetRef ref) { + final shouldLock = ref.watch(_stateProvider(source).select((state) => state?.shouldLock)); + if (shouldLock == null) { + return null; + } + + return .new( + icon: shouldLock ? Icons.lock_rounded : Icons.lock_open_rounded, + label: shouldLock ? context.t.move_to_locked_folder : context.t.remove_from_locked_folder, + onAction: () => _lock(context, ref), + ); + } + + Future _lock(BuildContext context, WidgetRef ref) async { + final state = ref.read(_stateProvider(source)); + if (state == null) { + return; + } + + final (:shouldLock, :assetIds, :localIds) = state; + final message = shouldLock + ? context.t.move_to_lock_folder_action_prompt(count: assetIds.length) + : context.t.remove_from_lock_folder_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(shouldLock ? .locked : .timeline)); + if (localIds.isNotEmpty) { + // A locked asset still sits in the device gallery, so offer to remove the local copy. + await service.deleteLocal(localIds); + } + toast.success(message); + selection.clearSelect(); + } catch (error, stack) { + handleError(error, stack: stack, description: "Failed to update the locked folder 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 943d29bbff..667a609abe 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 @@ -4,6 +4,7 @@ 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/presentation/actions/lock.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/providers/asset_viewer/asset_viewer.provider.dart'; @@ -17,11 +18,10 @@ 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/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, lockedFolder } +enum AddToMenuItem { album } class AddActionButton extends ConsumerStatefulWidget { const AddActionButton({super.key, this.originalTheme}); @@ -38,9 +38,6 @@ class _AddActionButtonState extends ConsumerState { case AddToMenuItem.album: _openAlbumSelector(); break; - case AddToMenuItem.lockedFolder: - performMoveToLockFolderAction(context, ref, source: ActionSource.viewer); - break; } } @@ -72,12 +69,7 @@ class _AddActionButtonState extends ConsumerState { child: Text("move_to".tr(), style: context.textTheme.labelMedium), ), const ActionMenuItem(action: ArchiveAction(source: .viewer)), - BaseActionButton( - iconData: Icons.lock_outline, - label: "locked_folder".tr(), - menuItem: true, - onPressed: () => _handleMenuSelection(AddToMenuItem.lockedFolder), - ), + const ActionMenuItem(action: LockAction(source: .viewer)), ], ]; } diff --git a/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart deleted file mode 100644 index 56191e9055..0000000000 --- a/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart +++ /dev/null @@ -1,63 +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'; - -// Reusable helper: move to locked folder from any source (e.g called from menu) -Future performMoveToLockFolderAction(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).moveToLockFolder(source); - ref.read(multiSelectProvider.notifier).reset(); - - final successMessage = 'move_to_lock_folder_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 MoveToLockFolderActionButton extends ConsumerWidget { - final ActionSource source; - final bool iconOnly; - final bool menuItem; - - const MoveToLockFolderActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); - - Future _onTap(BuildContext context, WidgetRef ref) async { - await performMoveToLockFolderAction(context, ref, source: source); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - return BaseActionButton( - maxWidth: 115.0, - iconData: Icons.lock_outline_rounded, - label: "move_to_locked_folder".t(context: context), - iconOnly: iconOnly, - menuItem: menuItem, - onPressed: () => _onTap(context, ref), - ); - } -} diff --git a/mobile/lib/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart deleted file mode 100644 index 17d2a76af7..0000000000 --- a/mobile/lib/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart +++ /dev/null @@ -1,57 +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 RemoveFromLockFolderActionButton extends ConsumerWidget { - final ActionSource source; - final bool iconOnly; - final bool menuItem; - - const RemoveFromLockFolderActionButton({ - 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).removeFromLockFolder(source); - ref.read(multiSelectProvider.notifier).reset(); - - final successMessage = 'remove_from_lock_folder_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( - maxWidth: 100.0, - iconData: Icons.lock_open_rounded, - label: "remove_from_locked_folder".t(context: context), - iconOnly: iconOnly, - menuItem: menuItem, - onPressed: () => _onTap(context, ref), - ); - } -} diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 14aa768626..41c82faf39 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -4,7 +4,7 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/presentation/actions/restore.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; @@ -22,6 +22,15 @@ import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/utils/semver.dart'; import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart'; +import 'package:immich_ui/immich_ui.dart'; + +// Resolved here rather than as an ActionColumnButton so a hidden restore +// takes no slot in the spaceEvenly row below. +List _actionColumnButtons(BuildContext context, WidgetRef ref, List actions) => actions + .map((a) => a.build(context, ref)) + .nonNulls + .map((data) => ImmichColumnButton(icon: data.icon, label: data.label, onPressed: data.onAction)) + .toList(growable: false); class ViewerBottomBar extends ConsumerWidget { const ViewerBottomBar({super.key}); @@ -44,7 +53,7 @@ class ViewerBottomBar extends ConsumerWidget { final originalTheme = context.themeData; final actions = [ - const ActionColumnButton(action: RestoreAction(source: .viewer)), + ..._actionColumnButtons(context, ref, const [RestoreAction(source: .viewer)]), const ShareActionButton(source: .viewer), if (!isInLockedView) ...[ 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 8207850f7a..68f3debcdd 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/lock.action.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'; @@ -12,7 +13,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permane import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_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/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'; @@ -91,7 +91,7 @@ class _ArchiveBottomSheetState extends ConsumerState { : const DeletePermanentActionButton(source: ActionSource.timeline), const EditDateTimeActionButton(source: ActionSource.timeline), const EditLocationActionButton(source: ActionSource.timeline), - const MoveToLockFolderActionButton(source: ActionSource.timeline), + const ActionColumnButton(action: LockAction(source: .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 23c987d128..534974ca05 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/lock.action.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'; @@ -13,7 +14,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permane import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_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/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'; @@ -88,7 +88,7 @@ class FavoriteBottomSheet extends ConsumerWidget { : const DeletePermanentActionButton(source: ActionSource.timeline), const EditDateTimeActionButton(source: ActionSource.timeline), const EditLocationActionButton(source: ActionSource.timeline), - const MoveToLockFolderActionButton(source: ActionSource.timeline), + const ActionColumnButton(action: LockAction(source: .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 4359bdef8a..ed5d8791a7 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/lock.action.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'; @@ -14,7 +15,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permane import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_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/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'; @@ -100,7 +100,7 @@ class _GeneralBottomSheetState extends ConsumerState { if (tagsEnabled) const BulkTagAssetsActionButton(source: ActionSource.timeline), const EditDateTimeActionButton(source: ActionSource.timeline), const EditLocationActionButton(source: ActionSource.timeline), - const MoveToLockFolderActionButton(source: ActionSource.timeline), + const ActionColumnButton(action: LockAction(source: .timeline)), const ActionColumnButton(action: StackAction(source: .timeline)), if (multiselect.onlyLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline), ], diff --git a/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart index a644e6a035..9b4fef1a13 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/lock.action.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'; -import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_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/bottom_sheet/base_bottom_sheet.widget.dart'; @@ -20,7 +21,7 @@ class LockedFolderBottomSheet extends ConsumerWidget { ShareActionButton(source: ActionSource.timeline), DownloadActionButton(source: ActionSource.timeline), DeletePermanentActionButton(source: ActionSource.timeline), - RemoveFromLockFolderActionButton(source: ActionSource.timeline), + ActionColumnButton(action: LockAction(source: .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 5783092731..de81b8fe8b 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/lock.action.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'; @@ -12,7 +13,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permane import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_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/action_buttons/remove_from_album_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; @@ -105,7 +105,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState : const DeletePermanentActionButton(source: ActionSource.timeline), const EditDateTimeActionButton(source: ActionSource.timeline), const EditLocationActionButton(source: ActionSource.timeline), - const MoveToLockFolderActionButton(source: ActionSource.timeline), + const ActionColumnButton(action: LockAction(source: .timeline)), const ActionColumnButton(action: StackAction(source: .timeline)), ], ], diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 0f3fee2dbe..32da490772 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -118,29 +118,6 @@ class ActionNotifier extends Notifier { } } - Future moveToLockFolder(ActionSource source) async { - final ids = _getOwnedRemoteIdsForSource(source); - final localIds = _getLocalIdsForSource(source, ignoreLocalOnly: true); - try { - await _service.moveToLockFolder(ids, localIds); - return ActionResult(count: ids.length, success: true); - } catch (error, stack) { - _logger.severe('Failed to move assets to lock folder', error, stack); - return ActionResult(count: ids.length, success: false, error: error.toString()); - } - } - - Future removeFromLockFolder(ActionSource source) async { - final ids = _getOwnedRemoteIdsForSource(source); - try { - await _service.removeFromLockFolder(ids); - return ActionResult(count: ids.length, success: true); - } catch (error, stack) { - _logger.severe('Failed to remove assets from lock folder', error, stack); - return ActionResult(count: ids.length, success: false, error: error.toString()); - } - } - Future trash(ActionSource source) async { final ids = _getOwnedRemoteIdsForSource(source); diff --git a/mobile/lib/providers/infrastructure/asset.provider.dart b/mobile/lib/providers/infrastructure/asset.provider.dart index fed55208e4..8ca0ca0ec9 100644 --- a/mobile/lib/providers/infrastructure/asset.provider.dart +++ b/mobile/lib/providers/infrastructure/asset.provider.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.re import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; final localAssetRepository = Provider( (ref) => DriftLocalAssetRepository(ref.watch(driftProvider)), @@ -28,6 +29,8 @@ final assetServiceProvider = Provider( exifRepository: ref.watch(remoteExifRepositoryProvider), localRepository: ref.watch(localAssetRepository), apiRepository: ref.watch(assetApiRepositoryProvider), + mediaRepository: ref.watch(assetMediaRepositoryProvider), + trashedLocalRepository: ref.watch(trashedLocalAssetRepository), ), ); diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index c9ab950f6a..ee8675c642 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -68,21 +68,6 @@ class ActionService { unawaited(context.pushRoute(SharedLinkEditRoute(assetsList: remoteIds))); } - Future moveToLockFolder(List remoteIds, List localIds) async { - await _assetApiRepository.updateVisibility(remoteIds, .locked); - await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked); - - // Ask user if they want to delete local copies - if (localIds.isNotEmpty) { - await _deleteLocalAssets(localIds); - } - } - - Future removeFromLockFolder(List remoteIds) async { - await _assetApiRepository.updateVisibility(remoteIds, .timeline); - await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline); - } - Future trash(List remoteIds) async { await _assetApiRepository.delete(remoteIds, false); await _remoteAssetRepository.trash(remoteIds); diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 3e70205702..2924907423 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/archive.action.dart'; import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; +import 'package:immich_mobile/presentation/actions/lock.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/base_action_button.widget.dart'; @@ -19,10 +20,8 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_a 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'; import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_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/action_buttons/open_in_browser_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; @@ -210,16 +209,8 @@ enum ActionButtonType { menuItem: menuItem, ), ActionButtonType.delete => DeleteActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), - ActionButtonType.moveToLockFolder => MoveToLockFolderActionButton( - source: context.source, - iconOnly: iconOnly, - menuItem: menuItem, - ), - ActionButtonType.removeFromLockFolder => RemoveFromLockFolderActionButton( - source: context.source, - iconOnly: iconOnly, - menuItem: menuItem, - ), + ActionButtonType.moveToLockFolder || + ActionButtonType.removeFromLockFolder => ActionMenuItem(action: LockAction(source: context.source)), ActionButtonType.deleteLocal => DeleteLocalActionButton( source: context.source, iconOnly: iconOnly, diff --git a/mobile/test/unit/mocks.dart b/mobile/test/unit/mocks.dart index d8eadda7ae..ee193f2b97 100644 --- a/mobile/test/unit/mocks.dart +++ b/mobile/test/unit/mocks.dart @@ -167,6 +167,7 @@ class ServiceMocks { when(asset.trash).thenAnswer((_) async {}); when(asset.delete).thenAnswer((_) async {}); when(asset.applyEdits).thenAnswer((_) async {}); + when(asset.deleteLocal).thenAnswer((_) async => 0); } void _stubRemoteAlbumService() { @@ -342,6 +343,9 @@ extension type const AssetServiceStub(MockAssetService service) implements Stub< Future Function() get applyEdits => () => service.applyEdits(any(), any()); + + Future Function() get deleteLocal => + () => service.deleteLocal(any()); } extension type const RemoteAlbumServiceStub(MockRemoteAlbumService service) implements Stub { diff --git a/mobile/test/unit/presentation/actions/lock_action_test.dart b/mobile/test/unit/presentation/actions/lock_action_test.dart new file mode 100644 index 0000000000..4ee669d137 --- /dev/null +++ b/mobile/test/unit/presentation/actions/lock_action_test.dart @@ -0,0 +1,123 @@ +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/lock.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({AssetVisibility visibility = .timeline}) => + RemoteAssetFactory.create(ownerId: context.currentUser.id, visibility: visibility); + + Future pumpLock(WidgetTester tester, Set selection) => + tester.pumpTestAction(context, const LockAction(source: .timeline), overrides: context.selected(selection)); + + group('LockAction', () { + testWidgets('locks the eligible owned assets', (tester) async { + final asset = owned(); + + await pumpLock(tester, {asset}); + + verify(() => assetService.update([asset.id], visibility: const .some(.locked))).called(1); + }); + + testWidgets('unlocks the eligible owned assets', (tester) async { + final asset = owned(visibility: .locked); + + await pumpLock(tester, {asset}); + + verify(() => assetService.update([asset.id], visibility: const .some(.timeline))).called(1); + }); + + testWidgets('prioritizes lock when mixed state', (tester) async { + final unlocked = owned(); + final locked = owned(visibility: .locked); + + await pumpLock(tester, {unlocked, locked}); + + verify(() => assetService.update([unlocked.id], visibility: const .some(.locked))).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 pumpLock(tester, {mine, theirs}); + + verify(() => assetService.update([mine.id], visibility: const .some(.locked))).called(1); + }); + + testWidgets('batches every eligible owned asset into a single call', (tester) async { + final first = owned(); + final second = owned(); + + await pumpLock(tester, {first, second}); + + verify(() => assetService.update([first.id, second.id], visibility: const .some(.locked))).called(1); + }); + + testWidgets('locks only the owned assets not already locked', (tester) async { + final stale = owned(); + final alreadyLocked = owned(visibility: .locked); + + await pumpLock(tester, {stale, alreadyLocked}); + + verify(() => assetService.update([stale.id], visibility: const .some(.locked))).called(1); + }); + + testWidgets('removes the on-device copies of the assets it locks', (tester) async { + final merged = RemoteAssetFactory.create(ownerId: context.currentUser.id, localId: 'local-1'); + final remoteOnly = owned(); + + await pumpLock(tester, {merged, remoteOnly}); + + verify(() => assetService.deleteLocal(['local-1'])).called(1); + }); + + testWidgets('leaves the on-device copies alone when unlocking', (tester) async { + final merged = RemoteAssetFactory.create( + ownerId: context.currentUser.id, + localId: 'local-1', + visibility: .locked, + ); + + await pumpLock(tester, {merged}); + + verifyNever(() => assetService.deleteLocal(any())); + }); + + testWidgets('clears the selection once the update succeeds', (tester) async { + await pumpLock(tester, {owned()}); + await tester.pumpAndSettle(); + + expect(find.byType(ImmichIconButton), findsNothing, reason: 'an empty selection hides the action'); + }); + + testWidgets('is hidden when none of the selected assets are owned', (tester) async { + await tester.pumpTestWidget( + context, + const ActionIconButton(action: LockAction(source: .timeline)), + overrides: context.selected({RemoteAssetFactory.create()}), + ); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + }); +} diff --git a/mobile/test/unit/services/asset_service_test.dart b/mobile/test/unit/services/asset_service_test.dart index 5e473d8f66..a09465701b 100644 --- a/mobile/test/unit/services/asset_service_test.dart +++ b/mobile/test/unit/services/asset_service_test.dart @@ -24,6 +24,8 @@ void main() { exifRepository: exifRepository, localRepository: MockDriftLocalAssetRepository(), apiRepository: apiRepository, + mediaRepository: mocks.assetMedia.api, + trashedLocalRepository: mocks.trashedAsset, ); });