refactor: mobile delete action

This commit is contained in:
shenlong-tanwen
2026-07-27 15:07:45 +05:30
parent 5c9ff18d80
commit 46474b9d52
21 changed files with 519 additions and 771 deletions
@@ -0,0 +1,229 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.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/store.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/cleanup.service.dart';
import 'package:immich_mobile/utils/error_handler.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
typedef _State = ({List<String> localIds, List<String> remoteIds, bool trash});
final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, source) {
final AssetsActionState(:assets) = ref.watch(assetsActionProvider(source));
final authUserId = ref.watch(authUserProvider).id;
final localIds = <String>[];
final ownedRemote = <RemoteAsset>[];
for (final asset in assets) {
if (asset.localId case final localId?) {
localIds.add(localId);
}
if (asset case final RemoteAsset remote when remote.ownerId == authUserId) {
ownedRemote.add(remote);
}
}
if (localIds.isEmpty && ownedRemote.isEmpty) {
return null;
}
final trashEnabled = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
// Assets already in the trash or in the locked folder are deleted outright, irrespective of the server setting.
final trash = trashEnabled && !ownedRemote.every((asset) => asset.isTrashed || asset.isLocked);
return (localIds: localIds, remoteIds: ownedRemote.map((asset) => asset.id).toList(growable: false), trash: trash);
});
class DeleteAction extends AssetActionBuilder {
const DeleteAction({required super.source});
@override
ActionData? build(BuildContext context, WidgetRef ref) {
final trash = ref.watch(_stateProvider(source).select((state) => state?.trash));
if (trash == null) {
return null;
}
return .new(
icon: Icons.delete_outline,
label: trash ? context.t.trash : context.t.delete,
onAction: () => _delete(context, ref),
);
}
Future<void> _delete(BuildContext context, WidgetRef ref) async {
final state = ref.read(_stateProvider(source));
if (state == null) {
return;
}
final (:localIds, :remoteIds, :trash) = state;
final toast = ref.read(toastRepositoryProvider);
final selection = ref.read(assetsActionProvider(source).notifier);
try {
final String? message;
if (remoteIds.isEmpty) {
message = await _removeLocalAssets(context, ref, localIds);
} else if (trash) {
message = await _moveToTrash(context, ref, remoteIds, localIds);
} else {
message = await _deletePermanently(context, ref, remoteIds, localIds);
}
if (message == null) {
return;
}
toast.success(message);
selection.clearSelect();
} catch (error, stack) {
handleError(error, stack: stack, description: "Failed to delete assets");
}
}
Future<String?> _removeLocalAssets(BuildContext context, WidgetRef ref, List<String> localIds) async {
final count = await _cleanupLocalAssets(context, ref, localIds);
if (count <= 0 || !context.mounted) {
return null;
}
return context.t.cleanup_deleted_assets(count: count);
}
Future<String?> _moveToTrash(
BuildContext context,
WidgetRef ref,
List<String> remoteIds,
List<String> localIds,
) async {
final service = ref.read(assetServiceProvider);
if (localIds.isNotEmpty) {
await _cleanupLocalAssets(context, ref, localIds);
if (!context.mounted) {
return null;
}
}
final message = context.t.trash_action_prompt(count: remoteIds.length);
await service.trash(remoteIds);
return message;
}
Future<String?> _deletePermanently(
BuildContext context,
WidgetRef ref,
List<String> remoteIds,
List<String> localIds,
) async {
final service = ref.read(assetServiceProvider);
final confirmed = await showDialog<bool>(
context: context,
builder: (_) =>
const ConfirmDialog(title: 'delete_dialog_title', content: 'delete_dialog_alert', ok: 'delete_permanently'),
);
if (confirmed != true || !context.mounted) {
return null;
}
final message = context.t.delete_permanently_action_prompt(count: remoteIds.length);
// Server first, so a failed request will not remove the local copy
await service.delete(remoteIds);
if (localIds.isNotEmpty && context.mounted) {
await _cleanupLocalAssets(context, ref, localIds, requestCustomPrompt: false);
}
return message;
}
}
final _cleanupStateProvider = Provider.family.autoDispose<List<String>?, ActionSource>((ref, source) {
final AssetsActionState(:assets) = ref.watch(assetsActionProvider(source));
final assetIds = assets.backedUp().map((asset) => asset.localId).nonNulls.toList(growable: false);
return assetIds.isEmpty ? null : assetIds;
});
class CleanupLocalAction extends AssetActionBuilder {
const CleanupLocalAction({required super.source});
@override
ActionData? build(BuildContext context, WidgetRef ref) {
final isVisible = ref.watch(_cleanupStateProvider(source).select((state) => state != null));
if (!isVisible) {
return null;
}
return .new(
icon: Icons.no_cell_outlined,
label: context.t.control_bottom_app_bar_delete_from_local,
onAction: () => _cleanup(context, ref),
);
}
Future<void> _cleanup(BuildContext context, WidgetRef ref) async {
final assetIds = ref.read(_cleanupStateProvider(source));
if (assetIds == null) {
return;
}
final toast = ref.read(toastRepositoryProvider);
final selection = ref.read(assetsActionProvider(source).notifier);
try {
final count = await _cleanupLocalAssets(context, ref, assetIds);
if (count <= 0 || !context.mounted) {
return;
}
toast.success(context.t.cleanup_deleted_assets(count: count));
selection.clearSelect();
} catch (error, stack) {
handleError(error, stack: stack, description: "Failed to remove the device copies");
}
}
}
/// Removes the device copies of [assetIds], returning how many were deleted.
///
/// iOS and Android without MANAGE_MEDIA prompt the user
/// with MANAGE_MEDIA, we do it ourselves unless [requestCustomPrompt] is false.
Future<int> _cleanupLocalAssets(
BuildContext context,
WidgetRef ref,
List<String> assetIds, {
bool requestCustomPrompt = true,
}) async {
if (assetIds.isEmpty) {
return 0;
}
final cleanupService = ref.read(cleanupServiceProvider);
final requiresPrompt =
requestCustomPrompt &&
CurrentPlatform.isAndroid &&
ref.read(storeServiceProvider).get(.manageLocalMediaAndroid, false);
if (requiresPrompt) {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => ConfirmDialog(
title: context.t.move_to_device_trash,
content: context.t.free_up_space_description,
ok: context.t.ok,
),
);
if (confirmed != true) {
return 0;
}
}
return cleanupService.deleteLocalAssets(assetIds);
}
@@ -1,100 +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/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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';
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';
/// This delete action has the following behavior:
/// - Set the deletedAt information, put the asset in the trash in the server
/// which will be permanently deleted after the number of days configure by the admin
/// - Prompt to delete the asset locally
class DeleteActionButton extends ConsumerWidget {
final ActionSource source;
final bool showConfirmation;
final bool iconOnly;
final bool menuItem;
const DeleteActionButton({
super.key,
required this.source,
this.showConfirmation = false,
this.iconOnly = false,
this.menuItem = false,
});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
if (showConfirmation) {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('delete'.t(context: context)),
content: Text('delete_action_confirmation_message'.t(context: context)),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text('cancel'.t(context: context)),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(
'confirm'.t(context: context),
style: TextStyle(color: context.colorScheme.error),
),
),
],
),
);
if (confirm != true) {
return;
}
}
final currentAsset = ref.read(assetViewerProvider).currentAsset;
final stackIndex = ref.read(assetViewerProvider).stackIndex;
final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer && result.success) {
final shouldRefreshStack = currentAsset is RemoteAsset && currentAsset.stackId != null;
EventStream.shared.emit(
shouldRefreshStack ? ViewerStackAssetDeletedEvent(stackIndex: stackIndex) : const ViewerReloadAssetEvent(),
);
}
final successMessage = 'delete_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: 110.0,
iconData: Icons.delete_sweep_outlined,
label: "delete".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}
@@ -1,68 +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';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
/// This delete action has the following behavior:
/// - Prompt to delete the asset locally
class DeleteLocalActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const DeleteLocalActionButton({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).deleteLocal(source, context);
if (result == null) {
return;
}
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
if (result.count == 0) {
return;
}
ref.invalidate(localAlbumProvider);
final successMessage = 'delete_local_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: 95.0,
iconData: Icons.no_cell_outlined,
label: "control_bottom_app_bar_delete_from_local".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}
@@ -1,80 +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/asset_grid/permanent_delete_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
/// This delete action has the following behavior:
/// - Delete permanently on the server
/// - Prompt to delete the asset locally
class DeletePermanentActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
final bool useShortLabel;
const DeletePermanentActionButton({
super.key,
required this.source,
this.iconOnly = false,
this.menuItem = false,
this.useShortLabel = false,
});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final count = source == ActionSource.viewer ? 1 : ref.read(multiSelectProvider).selectedAssets.length;
final confirm =
await showDialog<bool>(
context: context,
builder: (context) => PermanentDeleteDialog(count: count),
) ??
false;
if (!confirm) {
return;
}
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'delete_permanently_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: 110.0,
iconData: Icons.delete_forever,
label: useShortLabel ? "delete".t(context: context) : "delete_permanently".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}
@@ -1,67 +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/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/permanent_delete_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
/// This delete action has the following behavior:
/// - Delete permanently on the server
/// - Prompt to delete the asset locally
///
/// This action is used when the asset is selected in multi-selection mode in the trash page
class DeleteTrashActionButton extends ConsumerWidget {
final ActionSource source;
const DeleteTrashActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final selectCount = ref.watch(multiSelectProvider.select((s) => s.selectedAssets.length));
final confirmDelete =
await showDialog<bool>(
context: context,
builder: (context) => PermanentDeleteDialog(count: selectCount),
) ??
false;
if (!confirmDelete) {
return;
}
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'assets_permanently_deleted_count'.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 TextButton.icon(
icon: Icon(Icons.delete_forever, color: Colors.red[400]),
label: Text(
"delete".t(context: context),
style: TextStyle(fontSize: 14, color: Colors.red[400], fontWeight: FontWeight.bold),
),
onPressed: () => _onTap(context, ref),
);
}
}
@@ -1,58 +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';
/// This delete action has the following behavior:
/// - Set the deletedAt information, put the asset in the trash in the server
/// which will be permanently deleted after the number of days configure by the admin
class TrashActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const TrashActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).trash(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'trash_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: 85.0,
iconData: Icons.delete_outline_rounded,
label: "control_bottom_app_bar_trash_from_immich".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}
@@ -1,15 +1,12 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
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.dart';
import 'package:immich_mobile/presentation/actions/delete.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';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_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/upload_action_button.widget.dart';
@@ -19,7 +16,6 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
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';
@@ -43,8 +39,6 @@ class ViewerBottomBar extends ConsumerWidget {
}
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final user = ref.watch(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final isInLockedView = ref.watch(inLockedViewProvider);
final serverInfo = ref.watch(serverInfoProvider);
@@ -64,14 +58,7 @@ class ViewerBottomBar extends ConsumerWidget {
const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
],
if (isOwner) ...[
if (asset.isLocalOnly)
const DeleteLocalActionButton(source: ActionSource.viewer)
else if (asset.isTrashed)
const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true)
else
const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
],
..._actionColumnButtons(context, ref, const [DeleteAction(source: .viewer)]),
],
];
@@ -4,22 +4,19 @@ 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/delete.action.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';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
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/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/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';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -48,7 +45,6 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
@override
Widget build(BuildContext context) {
final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
Future<void> addToAlbum(RemoteAlbum album) async {
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album);
@@ -86,15 +82,13 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
const ActionColumnButton(action: ArchiveAction(source: .timeline)),
const ActionColumnButton(action: FavoriteAction(source: .timeline)),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const ActionColumnButton(action: DeleteAction(source: .timeline)),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const ActionColumnButton(action: LockAction(source: .timeline)),
const ActionColumnButton(action: StackAction(source: .timeline)),
],
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
const ActionColumnButton(action: CleanupLocalAction(source: .timeline)),
],
slivers: [
const AddToAlbumHeader(),
@@ -5,22 +5,19 @@ 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/delete.action.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';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
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/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/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';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -30,7 +27,6 @@ class FavoriteBottomSheet extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
Future<void> addAssetsToAlbum(RemoteAlbum album) async {
final selectedAssets = multiselect.selectedAssets;
@@ -83,15 +79,13 @@ class FavoriteBottomSheet extends ConsumerWidget {
const ActionColumnButton(action: FavoriteAction(source: .timeline)),
const ActionColumnButton(action: ArchiveAction(source: .timeline)),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const ActionColumnButton(action: DeleteAction(source: .timeline)),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const ActionColumnButton(action: LockAction(source: .timeline)),
const ActionColumnButton(action: StackAction(source: .timeline)),
],
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
const ActionColumnButton(action: CleanupLocalAction(source: .timeline)),
],
slivers: multiselect.hasRemote
? [const AddToAlbumHeader(), AlbumSelector(onAlbumSelected: addAssetsToAlbum)]
@@ -4,26 +4,22 @@ 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/delete.action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
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/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/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/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';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -52,7 +48,6 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
@override
Widget build(BuildContext context) {
final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final tagsEnabled = ref.watch(
userMetadataPreferencesProvider.select((value) => value.valueOrNull?.tagsEnabled ?? false),
);
@@ -92,9 +87,6 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const ActionColumnButton(action: FavoriteAction(source: .timeline)),
const ActionColumnButton(action: ArchiveAction(source: .timeline)),
if (tagsEnabled) const BulkTagAssetsActionButton(source: ActionSource.timeline),
@@ -102,10 +94,9 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
const EditLocationActionButton(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),
],
if (multiselect.onlyLocal || multiselect.hasMerged)
const DeleteLocalActionButton(source: ActionSource.timeline),
const ActionColumnButton(action: DeleteAction(source: .timeline)),
const ActionColumnButton(action: CleanupLocalAction(source: .timeline)),
if (multiselect.onlyLocal) const UploadActionButton(source: ActionSource.timeline),
],
slivers: [
@@ -3,7 +3,8 @@ import 'package:flutter/material.dart';
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/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_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';
@@ -66,7 +67,8 @@ class _LocalAlbumBottomSheetState extends ConsumerState<LocalAlbumBottomSheet> {
shouldCloseOnMinExtent: false,
actions: const [
ShareActionButton(source: ActionSource.timeline),
DeleteLocalActionButton(source: ActionSource.timeline),
ActionColumnButton(action: DeleteAction(source: .timeline)),
ActionColumnButton(action: CleanupLocalAction(source: .timeline)),
UploadActionButton(source: ActionSource.timeline),
],
slivers: [
@@ -2,8 +2,8 @@ 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/delete.action.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/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
@@ -20,7 +20,7 @@ class LockedFolderBottomSheet extends ConsumerWidget {
actions: [
ShareActionButton(source: ActionSource.timeline),
DownloadActionButton(source: ActionSource.timeline),
DeletePermanentActionButton(source: ActionSource.timeline),
ActionColumnButton(action: DeleteAction(source: .timeline)),
ActionColumnButton(action: LockAction(source: .timeline)),
],
);
@@ -4,12 +4,11 @@ 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/delete.action.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';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
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';
@@ -17,11 +16,9 @@ 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/trash_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';
import 'package:immich_mobile/providers/server_info.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';
@@ -52,7 +49,6 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
@override
Widget build(BuildContext context) {
final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final ownsAlbum = ref.watch(currentUserProvider)?.id == widget.album.ownerId;
Future<void> addToAlbum(RemoteAlbum album) async {
@@ -100,16 +96,14 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
],
const DownloadActionButton(source: ActionSource.timeline),
if (ownsAlbum) ...[
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const ActionColumnButton(action: DeleteAction(source: .timeline)),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const ActionColumnButton(action: LockAction(source: .timeline)),
const ActionColumnButton(action: StackAction(source: .timeline)),
],
],
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
const ActionColumnButton(action: CleanupLocalAction(source: .timeline)),
if (ownsAlbum) RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id),
if (ownsAlbum && multiselect.selectedAssets.length == 1)
SetAlbumCoverActionButton(source: ActionSource.timeline, albumId: widget.album.id),
@@ -1,10 +1,9 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/delete.action.dart';
import 'package:immich_mobile/presentation/actions/restore.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_trash_action_button.widget.dart';
class TrashBottomBar extends ConsumerWidget {
const TrashBottomBar({super.key});
@@ -21,7 +20,7 @@ class TrashBottomBar extends ConsumerWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
DeleteTrashActionButton(source: ActionSource.timeline),
ActionColumnButton(action: DeleteAction(source: .timeline)),
ActionColumnButton(action: RestoreAction(source: .timeline)),
],
),
@@ -21,7 +21,6 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
@@ -64,24 +63,6 @@ class ActionNotifier extends Notifier<void> {
return _getAssets(source).whereType<RemoteAsset>().toIds().toList(growable: false);
}
List<String> _getLocalIdsForSource(ActionSource source, {bool ignoreLocalOnly = false}) {
final Set<BaseAsset> assets = _getAssets(source);
final List<String> localIds = [];
for (final asset in assets) {
if (ignoreLocalOnly && asset.storage != AssetState.merged) {
continue;
}
if (asset is LocalAsset) {
localIds.add(asset.id);
} else if (asset is RemoteAsset && asset.localId != null) {
localIds.add(asset.localId!);
}
}
return localIds;
}
List<String> _getOwnedRemoteIdsForSource(ActionSource source) {
final ownerId = ref.read(currentUserProvider)?.id;
return _getAssets(source).whereType<RemoteAsset>().ownedAssets(ownerId).toIds().toList(growable: false);
@@ -118,18 +99,6 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult> trash(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
await _service.trash(ids);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to trash assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> emptyTrash(String userId) async {
try {
final count = await _service.emptyTrash(userId);
@@ -150,60 +119,6 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult> trashRemoteAndDeleteLocal(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
final localIds = _getLocalIdsForSource(source);
try {
await _service.trashRemoteAndDeleteLocal(ids, localIds);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to delete assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> deleteRemoteAndLocal(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
final localIds = _getLocalIdsForSource(source);
try {
await _service.deleteRemoteAndLocal(ids, localIds);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to delete assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult?> deleteLocal(ActionSource source, BuildContext context) async {
final assets = _getAssets(source);
bool? backedUpOnly = assets.every((asset) => asset.storage == AssetState.merged)
? true
: await showDialog<bool>(
context: context,
builder: (BuildContext context) => DeleteLocalOnlyDialog(onDeleteLocal: (_) {}),
);
if (backedUpOnly == null) {
// User cancelled the dialog
return null;
}
final List<String> ids;
if (backedUpOnly) {
ids = assets.where((asset) => asset.storage == AssetState.merged).map((asset) => asset.localId!).toList();
} else {
ids = _getLocalIdsForSource(source);
}
try {
final deletedCount = await _service.deleteLocal(ids);
return ActionResult(count: deletedCount, success: true);
} catch (error, stack) {
_logger.severe('Failed to delete assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult?> editLocation(ActionSource source, BuildContext context) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
-51
View File
@@ -6,14 +6,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/tag.service.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_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
@@ -31,10 +26,8 @@ final actionServiceProvider = Provider<ActionService>(
(ref) => ActionService(
ref.watch(assetApiRepositoryProvider),
ref.watch(remoteAssetRepositoryProvider),
ref.watch(localAssetRepository),
ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(remoteAlbumRepository),
ref.watch(trashedLocalAssetRepository),
ref.watch(assetMediaRepositoryProvider),
ref.watch(downloadRepositoryProvider),
ref.watch(tagServiceProvider),
@@ -44,10 +37,8 @@ final actionServiceProvider = Provider<ActionService>(
class ActionService {
final AssetApiRepository _assetApiRepository;
final RemoteAssetRepository _remoteAssetRepository;
final DriftLocalAssetRepository _localAssetRepository;
final DriftAlbumApiRepository _albumApiRepository;
final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository;
final DownloadRepository _downloadRepository;
final TagService _tagService;
@@ -55,10 +46,8 @@ class ActionService {
const ActionService(
this._assetApiRepository,
this._remoteAssetRepository,
this._localAssetRepository,
this._albumApiRepository,
this._remoteAlbumRepository,
this._trashedLocalAssetRepository,
this._assetMediaRepository,
this._downloadRepository,
this._tagService,
@@ -68,11 +57,6 @@ class ActionService {
unawaited(context.pushRoute(SharedLinkEditRoute(assetsList: remoteIds)));
}
Future<void> trash(List<String> remoteIds) async {
await _assetApiRepository.delete(remoteIds, false);
await _remoteAssetRepository.trash(remoteIds);
}
Future<int> emptyTrash(String userId) async {
final count = await _assetApiRepository.emptyTrash();
await _remoteAssetRepository.emptyTrash(userId);
@@ -85,28 +69,6 @@ class ActionService {
return count;
}
Future<void> trashRemoteAndDeleteLocal(List<String> remoteIds, List<String> localIds) async {
await _assetApiRepository.delete(remoteIds, false);
await _remoteAssetRepository.trash(remoteIds);
if (localIds.isNotEmpty) {
await _deleteLocalAssets(localIds);
}
}
Future<void> deleteRemoteAndLocal(List<String> remoteIds, List<String> localIds) async {
await _assetApiRepository.delete(remoteIds, true);
await _remoteAssetRepository.delete(remoteIds);
if (localIds.isNotEmpty) {
await _deleteLocalAssets(localIds);
}
}
Future<int> deleteLocal(List<String> localIds) async {
return await _deleteLocalAssets(localIds);
}
Future<bool> editLocation(List<String> remoteIds, BuildContext context) async {
maplibre.LatLng? initialLatLng;
if (remoteIds.length == 1) {
@@ -262,17 +224,4 @@ class ActionService {
await _assetApiRepository.editAsset(remoteId, edits);
}
}
Future<int> _deleteLocalAssets(List<String> localIds) async {
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
if (deletedIds.isEmpty) {
return 0;
}
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _trashedLocalAssetRepository.applyTrashedAssets(deletedIds);
} else {
await _localAssetRepository.delete(deletedIds);
}
return deletedIds.length;
}
}
+5 -36
View File
@@ -10,14 +10,12 @@ 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/delete.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';
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart';
@@ -28,7 +26,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/slideshow_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -83,9 +80,7 @@ enum ActionButtonType {
removeFromLockFolder,
removeFromAlbum,
restoreTrash,
trash,
deleteLocal,
deletePermanent,
delete,
advancedInfo;
@@ -110,25 +105,12 @@ enum ActionButtonType {
!context.isInLockedView && //
context.asset.hasRemote && //
!context.asset.hasLocal,
ActionButtonType.trash =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote && //
context.isTrashEnabled && //
context.timelineOrigin != TimelineOrigin.trash,
ActionButtonType.restoreTrash =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote && //
context.timelineOrigin == TimelineOrigin.trash,
ActionButtonType.deletePermanent =>
context.isOwner && //
context.asset.hasRemote && //
(!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView),
ActionButtonType.delete =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote,
ActionButtonType.delete => true,
ActionButtonType.moveToLockFolder =>
context.isOwner && //
!context.isInLockedView && //
@@ -139,7 +121,7 @@ enum ActionButtonType {
context.asset.hasRemote,
ActionButtonType.deleteLocal =>
!context.isInLockedView && //
context.asset.hasLocal,
context.asset.isMerged,
ActionButtonType.upload =>
!context.isInLockedView && //
context.asset.storage == AssetState.local,
@@ -201,21 +183,11 @@ enum ActionButtonType {
ActionButtonType.archive ||
ActionButtonType.unarchive => ActionMenuItem(action: ArchiveAction(source: context.source)),
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.restoreTrash => ActionMenuItem(action: RestoreAction(source: context.source)),
ActionButtonType.deletePermanent => DeletePermanentActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.delete => DeleteActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.delete => ActionMenuItem(action: DeleteAction(source: context.source)),
ActionButtonType.moveToLockFolder ||
ActionButtonType.removeFromLockFolder => ActionMenuItem(action: LockAction(source: context.source)),
ActionButtonType.deleteLocal => DeleteLocalActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.deleteLocal => ActionMenuItem(action: CleanupLocalAction(source: context.source)),
ActionButtonType.upload => UploadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.removeFromAlbum => RemoveFromAlbumActionButton(
albumId: context.currentAlbum!.id,
@@ -276,8 +248,6 @@ enum ActionButtonType {
// 0: info
ActionButtonType.openInfo => 0,
// 10: move, remove, and delete
ActionButtonType.trash => 10,
ActionButtonType.deletePermanent => 10,
ActionButtonType.removeFromLockFolder => 10,
ActionButtonType.removeFromAlbum => 10,
ActionButtonType.unstack => 10,
@@ -305,7 +275,6 @@ class ActionButtonBuilder {
ActionButtonType.archive,
ActionButtonType.unarchive,
ActionButtonType.restoreTrash,
ActionButtonType.deletePermanent,
};
static List<Widget> build(ActionButtonContext context) {
@@ -2,7 +2,6 @@ import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
@@ -18,10 +17,8 @@ void main() {
late MockAssetApiRepository assetApiRepository;
late MockRemoteAssetRepository remoteAssetRepository;
late MockDriftLocalAssetRepository localAssetRepository;
late MockDriftAlbumApiRepository albumApiRepository;
late MockRemoteAlbumRepository remoteAlbumRepository;
late MockTrashedLocalAssetRepository trashedLocalAssetRepository;
late MockAssetMediaRepository assetMediaRepository;
late MockDownloadRepository downloadRepository;
late MockTagService tagService;
@@ -45,10 +42,8 @@ void main() {
setUp(() {
assetApiRepository = MockAssetApiRepository();
remoteAssetRepository = MockRemoteAssetRepository();
localAssetRepository = MockDriftLocalAssetRepository();
albumApiRepository = MockDriftAlbumApiRepository();
remoteAlbumRepository = MockRemoteAlbumRepository();
trashedLocalAssetRepository = MockTrashedLocalAssetRepository();
assetMediaRepository = MockAssetMediaRepository();
downloadRepository = MockDownloadRepository();
tagService = MockTagService();
@@ -56,10 +51,8 @@ void main() {
sut = ActionService(
assetApiRepository,
remoteAssetRepository,
localAssetRepository,
albumApiRepository,
remoteAlbumRepository,
trashedLocalAssetRepository,
assetMediaRepository,
downloadRepository,
tagService,
@@ -138,50 +131,4 @@ void main() {
verify(() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: null)).called(1);
});
});
group('ActionService.deleteLocal', () {
test('routes deleted ids to trashed repository when Android trash handling is enabled', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
const ids = ['a', 'b'];
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
when(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).thenAnswer((_) async {});
final result = await sut.deleteLocal(ids);
expect(result, ids.length);
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
verify(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).called(1);
verifyNever(() => localAssetRepository.delete(any()));
});
test('deletes locally when Android trash handling is disabled', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, false);
const ids = ['c'];
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
when(() => localAssetRepository.delete(ids)).thenAnswer((_) async {});
final result = await sut.deleteLocal(ids);
expect(result, ids.length);
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
verify(() => localAssetRepository.delete(ids)).called(1);
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
});
test('short-circuits when nothing was deleted', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
const ids = ['x'];
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => <String>[]);
final result = await sut.deleteLocal(ids);
expect(result, 0);
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
verifyNever(() => localAssetRepository.delete(any()));
});
});
}
@@ -0,0 +1,257 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:mocktail/mocktail.dart';
import '../../../service.mocks.dart';
import '../../factories/local_asset_factory.dart';
import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
late MockAssetService assetService;
late MockCleanupService cleanupService;
setUp(() async {
context = await PresentationContext.create();
assetService = context.service.asset.service;
cleanupService = context.service.cleanup.service;
});
tearDown(() async {
debugDefaultTargetPlatformOverride = null;
await StoreService.I.put(StoreKey.manageLocalMediaAndroid, false);
context.dispose();
});
RemoteAsset owned({AssetVisibility visibility = .timeline, DateTime? deletedAt, String? localId}) =>
RemoteAssetFactory.create(
ownerId: context.currentUser.id,
visibility: visibility,
deletedAt: deletedAt,
localId: localId,
);
Future<void> pumpDelete(WidgetTester tester, Set<BaseAsset> selection, {bool trashEnabled = true}) async {
if (!trashEnabled) {
when(
() => context.service.serverInfo.getServerFeatures(),
).thenAnswer((_) async => const .new(trash: false, map: true, oauthEnabled: false, passwordLogin: true));
}
await tester.pumpTestWidget(
context,
const ActionIconButton(action: DeleteAction(source: .timeline)),
overrides: context.selected(selection),
);
if (!trashEnabled) {
final scope = ProviderScope.containerOf(tester.element(find.byType(ActionIconButton)), listen: false);
await scope.read(serverInfoProvider.notifier).getServerFeatures();
await tester.pumpAndSettle();
}
await tester.tap(find.byType(ImmichIconButton));
await tester.pump();
}
Future<void> respondToDialog(WidgetTester tester, {required bool confirm}) async {
await tester.pump(const Duration(milliseconds: 300));
expect(find.byType(ConfirmDialog), findsOneWidget);
await tester.tap(find.byType(TextButton).at(confirm ? 1 : 0)); // [cancel, ok]
await tester.pumpAndSettle();
}
group('DeleteAction', () {
group('trash', () {
testWidgets('trashes a remote-only owned asset', (tester) async {
final asset = owned();
await pumpDelete(tester, {asset});
await tester.pumpAndSettle();
verify(() => assetService.trash([asset.id])).called(1);
verifyNever(() => assetService.delete(any()));
verifyNever(() => cleanupService.deleteLocalAssets(any()));
});
testWidgets('ignores assets owned by someone else', (tester) async {
final mine = owned();
final theirs = RemoteAssetFactory.create();
await pumpDelete(tester, {mine, theirs});
await tester.pumpAndSettle();
verify(() => assetService.trash([mine.id])).called(1);
});
testWidgets('trashes a merged asset and removes its device copy', (tester) async {
final asset = owned(localId: 'local');
await pumpDelete(tester, {asset});
await tester.pumpAndSettle();
verify(() => cleanupService.deleteLocalAssets(['local'])).called(1);
verify(() => assetService.trash([asset.id])).called(1);
});
});
group('permanent', () {
testWidgets('permanently deletes when the trash feature is disabled', (tester) async {
final asset = owned();
await pumpDelete(tester, {asset}, trashEnabled: false);
await respondToDialog(tester, confirm: true);
verify(() => assetService.delete([asset.id])).called(1);
verifyNever(() => assetService.trash(any()));
});
testWidgets('permanently deletes a merged asset and removes its device copy', (tester) async {
final asset = owned(localId: 'local');
await pumpDelete(tester, {asset}, trashEnabled: false);
await respondToDialog(tester, confirm: true);
verify(() => assetService.delete([asset.id])).called(1);
verify(() => cleanupService.deleteLocalAssets(['local'])).called(1);
});
testWidgets('permanently deletes already trashed assets even with trash enabled', (tester) async {
final asset = owned(deletedAt: DateTime(2024));
await pumpDelete(tester, {asset});
await respondToDialog(tester, confirm: true);
verify(() => assetService.delete([asset.id])).called(1);
verifyNever(() => assetService.trash(any()));
});
testWidgets('permanently deletes locked folder assets even with trash enabled', (tester) async {
final asset = owned(visibility: .locked, localId: 'local');
await pumpDelete(tester, {asset});
await respondToDialog(tester, confirm: true);
verify(() => assetService.delete([asset.id])).called(1);
verify(() => cleanupService.deleteLocalAssets(['local'])).called(1);
});
testWidgets('does nothing when the confirmation is cancelled', (tester) async {
final asset = owned(visibility: .locked, localId: 'local');
await pumpDelete(tester, {asset});
await respondToDialog(tester, confirm: false);
verifyNever(() => assetService.delete(any()));
verifyNever(() => cleanupService.deleteLocalAssets(any()));
});
});
group('local only', () {
testWidgets('removes the device copy with no remote call', (tester) async {
final asset = LocalAssetFactory.create();
await pumpDelete(tester, {asset});
await tester.pumpAndSettle();
verify(() => cleanupService.deleteLocalAssets([asset.id])).called(1);
verifyNever(() => assetService.trash(any()));
verifyNever(() => assetService.delete(any()));
});
});
group('prompt handling', () {
testWidgets('permanent delete shows a single app dialog', (tester) async {
final asset = owned(localId: 'local');
await pumpDelete(tester, {asset}, trashEnabled: false);
await tester.pump(const Duration(milliseconds: 300));
expect(find.text(StaticTranslations.instance.delete_dialog_title), findsOneWidget);
await tester.tap(find.byType(TextButton).at(1));
await tester.pumpAndSettle();
expect(find.text(StaticTranslations.instance.move_to_device_trash), findsNothing);
verify(() => assetService.delete([asset.id])).called(1);
verify(() => cleanupService.deleteLocalAssets(['local'])).called(1);
});
testWidgets('local only delete on Android with MANAGE_MEDIA shows the prompt', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
await StoreService.I.put(StoreKey.manageLocalMediaAndroid, true);
final asset = LocalAssetFactory.create();
await pumpDelete(tester, {asset});
await tester.pump(const Duration(milliseconds: 300));
expect(find.text(StaticTranslations.instance.move_to_device_trash), findsOneWidget);
await tester.tap(find.byType(TextButton).at(1)); // confirm
await tester.pumpAndSettle();
verify(() => cleanupService.deleteLocalAssets([asset.id])).called(1);
// Has to be cleared inside the body; the framework asserts on it before tearDown runs.
debugDefaultTargetPlatformOverride = null;
});
testWidgets('local only delete on Android with MANAGE_MEDIA deletes nothing when cancelled', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
await StoreService.I.put(StoreKey.manageLocalMediaAndroid, true);
final asset = LocalAssetFactory.create();
await pumpDelete(tester, {asset});
await respondToDialog(tester, confirm: false);
verifyNever(() => cleanupService.deleteLocalAssets(any()));
debugDefaultTargetPlatformOverride = null;
});
});
testWidgets('is hidden when nothing can be deleted', (tester) async {
await tester.pumpTestWidget(
context,
const ActionIconButton(action: DeleteAction(source: .timeline)),
overrides: context.selected({RemoteAssetFactory.create()}),
);
expect(find.byType(ImmichIconButton), findsNothing);
});
});
group('CleanupLocalAction', () {
testWidgets('deletes only backed up device copies', (tester) async {
final backedUp = LocalAssetFactory.create(remoteId: 'remote');
final localOnly = LocalAssetFactory.create();
await tester.pumpTestAction(
context,
const CleanupLocalAction(source: .timeline),
overrides: context.selected({backedUp, localOnly}),
);
await tester.pumpAndSettle();
verify(() => cleanupService.deleteLocalAssets([backedUp.id])).called(1);
});
testWidgets('is hidden when no backed up assets are selected', (tester) async {
await tester.pumpTestWidget(
context,
const ActionIconButton(action: CleanupLocalAction(source: .timeline)),
overrides: context.selected({LocalAssetFactory.create()}),
);
expect(find.byType(ImmichIconButton), findsNothing);
});
});
}
@@ -19,6 +19,7 @@ import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/cleanup.service.dart';
import 'package:immich_mobile/services/gcast.service.dart';
import 'package:immich_mobile/services/server_info.service.dart';
import 'package:immich_ui/immich_ui.dart';
@@ -48,6 +49,7 @@ class PresentationContext {
List<Override> get overrides => [
currentUserProvider.overrideWith((ref) => CurrentUserProvider(service.user.service)),
assetServiceProvider.overrideWithValue(service.asset.service),
cleanupServiceProvider.overrideWithValue(service.cleanup.service),
partnerServiceProvider.overrideWithValue(service.partner.service),
gCastServiceProvider.overrideWithValue(service.cast),
serverInfoServiceProvider.overrideWithValue(service.serverInfo),
@@ -427,60 +427,6 @@ void main() {
});
});
group('trash button', () {
test('should show when owner, not locked, has remote, and trash enabled', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.trash.shouldShow(context), isTrue);
});
test('should not show when trash disabled', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: false,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.trash.shouldShow(context), isFalse);
});
test('should not show when asset is already trashed', () {
final remoteAsset = createRemoteAsset(deletedAt: DateTime(2024));
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
timelineOrigin: TimelineOrigin.trash,
);
expect(ActionButtonType.trash.shouldShow(context), isFalse);
});
});
group('restoreTrash button', () {
test('should show when owner, not locked, has remote, and is in trash timeline', () {
final remoteAsset = createRemoteAsset();
@@ -519,60 +465,6 @@ void main() {
});
});
group('deletePermanent button', () {
test('should show when owner, not locked, has remote, and trash disabled', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: false,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.deletePermanent.shouldShow(context), isTrue);
});
test('should not show when trash enabled', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.deletePermanent.shouldShow(context), isFalse);
});
test('should show when asset is trashed even with trash enabled', () {
final remoteAsset = createRemoteAsset(deletedAt: DateTime(2024));
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
timelineOrigin: TimelineOrigin.trash,
);
expect(ActionButtonType.deletePermanent.shouldShow(context), isTrue);
});
});
group('delete button', () {
test('should show when owner, not locked, and has remote', () {
final remoteAsset = createRemoteAsset();
@@ -612,7 +504,7 @@ void main() {
});
group('deleteLocal button', () {
test('should show when not locked and asset is local only', () {
test('should not show when asset is local only, as there is no backup to fall back on', () {
final localAsset = createLocalAsset();
final context = ActionButtonContext(
asset: localAsset,
@@ -626,7 +518,7 @@ void main() {
source: ActionSource.timeline,
);
expect(ActionButtonType.deleteLocal.shouldShow(context), isTrue);
expect(ActionButtonType.deleteLocal.shouldShow(context), isFalse);
});
test('should not show when asset is not local only', () {