From 3a5d82f7905f98b08f5b14b8630be54f9d3c1cb2 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 26 Jul 2025 13:51:18 -0500 Subject: [PATCH] chore: delete action button (#20261) --- i18n/en.json | 5 +- .../delete_action_button.widget.dart | 85 +++++++++++++++++++ .../delete_local_action_button.widget.dart | 6 ++ ...delete_permanent_action_button.widget.dart | 7 +- .../delete_trash_action_button.widget.dart | 5 ++ .../trash_action_button.widget.dart | 3 + .../asset_viewer/bottom_bar.widget.dart | 12 ++- .../asset_viewer/bottom_sheet.widget.dart | 2 + .../general_bottom_sheet.widget.dart | 2 + .../infrastructure/action.provider.dart | 20 ++++- mobile/lib/services/action.service.dart | 24 +++++- 11 files changed, 162 insertions(+), 9 deletions(-) create mode 100644 mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart diff --git a/i18n/en.json b/i18n/en.json index 8f34299a97..cfc8ffccee 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -744,7 +744,8 @@ "default_locale": "Default Locale", "default_locale_description": "Format dates and numbers based on your browser locale", "delete": "Delete", - "delete_action_prompt": "{count} deleted permanently", + "delete_action_confirmation_message": "Are you sure you want to delete this asset? This action will move the asset to the server's trash and will prompt if you want to delete it locally", + "delete_action_prompt": "{count} deleted", "delete_album": "Delete album", "delete_api_key_prompt": "Are you sure you want to delete this API key?", "delete_dialog_alert": "These items will be permanently deleted from Immich and from your device", @@ -762,6 +763,8 @@ "delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only", "delete_local_dialog_ok_force": "Delete Anyway", "delete_others": "Delete others", + "delete_permanently": "Delete permanently", + "delete_permanently_action_prompt": "{count} deleted permanently", "delete_shared_link": "Delete shared link", "delete_shared_link_dialog_title": "Delete Shared Link", "delete_tag": "Delete tag", diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart new file mode 100644 index 0000000000..f910a2a9e2 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart @@ -0,0 +1,85 @@ +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/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/presentation/widgets/asset_viewer/asset_viewer.state.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; + const DeleteActionButton({super.key, required this.source, this.showConfirmation = false}); + + void _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + if (showConfirmation) { + final confirm = await showDialog( + 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 result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source); + ref.read(multiSelectProvider.notifier).reset(); + + if (source == ActionSource.viewer) { + EventStream.shared.emit(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), + onPressed: () => _onTap(context, ref), + ); + } +} diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_local_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_local_action_button.widget.dart index af14befe61..7a9465dfb6 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_local_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_local_action_button.widget.dart @@ -10,6 +10,8 @@ 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: +/// - Prompt to delete the asset locally class DeleteLocalActionButton extends ConsumerWidget { final ActionSource source; @@ -27,6 +29,10 @@ class DeleteLocalActionButton extends ConsumerWidget { EventStream.shared.emit(const ViewerReloadAssetEvent()); } + if (result.count == 0) { + return; + } + final successMessage = 'delete_local_action_prompt'.t( context: context, args: {'count': result.count.toString()}, diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart index 1f8710b07f..4979df904c 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart @@ -10,6 +10,9 @@ 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: +/// - Delete permanently on the server +/// - Prompt to delete the asset locally class DeletePermanentActionButton extends ConsumerWidget { final ActionSource source; @@ -27,7 +30,7 @@ class DeletePermanentActionButton extends ConsumerWidget { EventStream.shared.emit(const ViewerReloadAssetEvent()); } - final successMessage = 'delete_action_prompt'.t( + final successMessage = 'delete_permanently_action_prompt'.t( context: context, args: {'count': result.count.toString()}, ); @@ -47,7 +50,7 @@ class DeletePermanentActionButton extends ConsumerWidget { return BaseActionButton( maxWidth: 110.0, iconData: Icons.delete_forever, - label: "delete_dialog_title".t(context: context), + label: "delete_permanently".t(context: context), onPressed: () => _onTap(context, ref), ); } diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_trash_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_trash_action_button.widget.dart index 6047eb90eb..dafbdbc78e 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_trash_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_trash_action_button.widget.dart @@ -7,6 +7,11 @@ 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: +/// - 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; diff --git a/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart index a6656e02a1..d26bdfad04 100644 --- a/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart @@ -10,6 +10,9 @@ 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; diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 9ea2035930..8c04fd5a85 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -4,6 +4,8 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_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/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; @@ -39,6 +41,14 @@ class ViewerBottomBar extends ConsumerWidget { const ShareActionButton(source: ActionSource.viewer), if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), if (asset.hasRemote && isOwner) const ArchiveActionButton(source: ActionSource.viewer), + asset.isLocalOnly + ? const DeleteLocalActionButton( + source: ActionSource.viewer, + ) + : const DeleteActionButton( + source: ActionSource.viewer, + showConfirmation: true, + ), ]; return IgnorePointer( @@ -60,7 +70,7 @@ class ViewerBottomBar extends ConsumerWidget { ), ), child: Container( - height: context.padding.bottom + (asset.isVideo ? 160 : 80), + height: context.padding.bottom + (asset.isVideo ? 160 : 90), color: Colors.black.withAlpha(125), padding: EdgeInsets.only(bottom: context.padding.bottom), child: Column( diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index b6d24724b4..e24bb3d7c0 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/domain/models/exif.model.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/archive_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_permanent_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/download_action_button.widget.dart'; @@ -56,6 +57,7 @@ class AssetDetailBottomSheet extends ConsumerWidget { isTrashEnable ? const TrashActionButton(source: ActionSource.viewer) : const DeletePermanentActionButton(source: ActionSource.viewer), + const DeleteActionButton(source: ActionSource.viewer), const MoveToLockFolderActionButton( source: ActionSource.viewer, ), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart index 12f03a0b25..d338cfa833 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_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_permanent_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/download_action_button.widget.dart'; @@ -80,6 +81,7 @@ class GeneralBottomSheet extends ConsumerWidget { : const DeletePermanentActionButton( source: ActionSource.timeline, ), + const DeleteActionButton(source: ActionSource.timeline), if (multiselect.hasLocal || multiselect.hasMerged) ...[ const DeleteLocalActionButton(source: ActionSource.timeline), ], diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 899bd7858b..26de1b4dba 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -230,6 +230,22 @@ class ActionNotifier extends Notifier { } } + Future 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 deleteRemoteAndLocal(ActionSource source) async { final ids = _getOwnedRemoteIdsForSource(source); final localIds = _getLocalIdsForSource(source); @@ -249,8 +265,8 @@ class ActionNotifier extends Notifier { Future deleteLocal(ActionSource source) async { final ids = _getLocalIdsForSource(source); try { - await _service.deleteLocal(ids); - return ActionResult(count: ids.length, success: true); + 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( diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index dde2b1cd54..4ed80d9f90 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -132,6 +132,19 @@ class ActionService { await _remoteAssetRepository.restoreTrash(ids); } + Future trashRemoteAndDeleteLocal(List remoteIds, List localIds) async { + await _assetApiRepository.delete(remoteIds, false); + await _remoteAssetRepository.trash(remoteIds); + + if (localIds.isNotEmpty) { + final deletedIds = await _assetMediaRepository.deleteAll(localIds); + + if (deletedIds.isNotEmpty) { + await _localAssetRepository.delete(deletedIds); + } + } + } + Future deleteRemoteAndLocal( List remoteIds, List localIds, @@ -148,9 +161,14 @@ class ActionService { } } - Future deleteLocal(List localIds) async { - await _assetMediaRepository.deleteAll(localIds); - await _localAssetRepository.delete(localIds); + Future deleteLocal(List localIds) async { + final deletedIds = await _assetMediaRepository.deleteAll(localIds); + if (deletedIds.isNotEmpty) { + await _localAssetRepository.delete(deletedIds); + return deletedIds.length; + } + + return 0; } Future editLocation(