mirror of
https://github.com/immich-app/immich.git
synced 2026-07-28 14:47:30 -07:00
refactor: mobile lock action
This commit is contained in:
@@ -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<BaseAsset?> getAsset(BaseAsset asset) {
|
||||
@@ -167,7 +176,24 @@ class AssetService {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(shenlong): remove after action migration
|
||||
Future<int> deleteLocal(List<String> 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<LocalAsset?> getLocalAsset(String id) {
|
||||
return _localRepository.get(id);
|
||||
}
|
||||
|
||||
@@ -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<String> assetIds, List<String> 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<void> _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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<AddActionButton> {
|
||||
case AddToMenuItem.album:
|
||||
_openAlbumSelector();
|
||||
break;
|
||||
case AddToMenuItem.lockedFolder:
|
||||
performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,12 +69,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
||||
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)),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
-63
@@ -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<void> 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<void> _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),
|
||||
);
|
||||
}
|
||||
}
|
||||
-57
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Widget> _actionColumnButtons(BuildContext context, WidgetRef ref, List<ActionBuilder> 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 = <Widget>[
|
||||
const ActionColumnButton(action: RestoreAction(source: .viewer)),
|
||||
..._actionColumnButtons(context, ref, const [RestoreAction(source: .viewer)]),
|
||||
const ShareActionButton(source: .viewer),
|
||||
|
||||
if (!isInLockedView) ...[
|
||||
|
||||
@@ -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<ArchiveBottomSheet> {
|
||||
: 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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<GeneralBottomSheet> {
|
||||
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),
|
||||
],
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<RemoteAlbumBottomSheet>
|
||||
: 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)),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -118,29 +118,6 @@ class ActionNotifier extends Notifier<void> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> 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<ActionResult> 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<ActionResult> trash(ActionSource source) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
|
||||
|
||||
@@ -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<DriftLocalAssetRepository>(
|
||||
(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),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -68,21 +68,6 @@ class ActionService {
|
||||
unawaited(context.pushRoute(SharedLinkEditRoute(assetsList: remoteIds)));
|
||||
}
|
||||
|
||||
Future<void> moveToLockFolder(List<String> remoteIds, List<String> 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<void> removeFromLockFolder(List<String> remoteIds) async {
|
||||
await _assetApiRepository.updateVisibility(remoteIds, .timeline);
|
||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
|
||||
}
|
||||
|
||||
Future<void> trash(List<String> remoteIds) async {
|
||||
await _assetApiRepository.delete(remoteIds, false);
|
||||
await _remoteAssetRepository.trash(remoteIds);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> Function() get applyEdits =>
|
||||
() => service.applyEdits(any(), any());
|
||||
|
||||
Future<int> Function() get deleteLocal =>
|
||||
() => service.deleteLocal(any());
|
||||
}
|
||||
|
||||
extension type const RemoteAlbumServiceStub(MockRemoteAlbumService service) implements Stub<MockRemoteAlbumService> {
|
||||
|
||||
@@ -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<void> pumpLock(WidgetTester tester, Set<BaseAsset> 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -24,6 +24,8 @@ void main() {
|
||||
exifRepository: exifRepository,
|
||||
localRepository: MockDriftLocalAssetRepository(),
|
||||
apiRepository: apiRepository,
|
||||
mediaRepository: mocks.assetMedia.api,
|
||||
trashedLocalRepository: mocks.trashedAsset,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user