diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index f205f22620..0f8ed9dff7 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -141,7 +141,9 @@ "control_bottom_app_bar_album_info_shared": "{} items · Shared", "control_bottom_app_bar_archive": "Archive", "control_bottom_app_bar_create_new_album": "Create new album", - "control_bottom_app_bar_delete": "Delete", + "control_bottom_app_bar_delete_from_immich": "Delete from Immich", + "control_bottom_app_bar_delete_from_local": "Delete from device", + "control_bottom_app_bar_delete": "Delete Everywhere", "control_bottom_app_bar_edit_location": "Edit Location", "control_bottom_app_bar_edit_time": "Edit Date & Time", "control_bottom_app_bar_favorite": "Favorite", @@ -162,8 +164,14 @@ "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", "delete_dialog_alert": "These items will be permanently deleted from Immich and from your device", + "delete_dialog_alert_remote": "These items will be permanently deleted from the Immich server", + "delete_dialog_alert_local": "These items will be permanently removed from your device but still be available on the Immich server", + "delete_dialog_alert_local_non_backed_up": "Some of the items aren't backed up to Immich and will be permanently removed from your device", "delete_dialog_cancel": "Cancel", "delete_dialog_ok": "Delete", + "delete_dialog_ok_force": "Delete Anyway", + "delete_local_dialog_ok_force": "Delete Anyway", + "delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only", "delete_dialog_title": "Delete Permanently", "delete_shared_link_dialog_content": "Are you sure you want to delete this shared link?", "delete_shared_link_dialog_title": "Delete Shared Link", @@ -190,6 +198,7 @@ "home_page_archive_err_partner": "Can not archive partner assets, skipping", "home_page_building_timeline": "Building the timeline", "home_page_delete_err_partner": "Can not delete partner assets, skipping", + "home_page_delete_remote_err_local": "Local assets in delete remote selection, skipping", "home_page_favorite_err_local": "Can not favorite local assets yet, skipping", "home_page_favorite_err_partner": "Can not favorite partner assets yet, skipping", "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", diff --git a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart index 7b22aaa83f..9b3280f532 100644 --- a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart +++ b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart @@ -16,7 +16,9 @@ class ControlBottomAppBar extends ConsumerWidget { final void Function(bool shareLocal) onShare; final void Function()? onFavorite; final void Function()? onArchive; - final void Function()? onDelete; + final void Function([bool force])? onDelete; + final void Function([bool force])? onDeleteServer; + final void Function(bool onlyBackedUp)? onDeleteLocal; final Function(Album album) onAddToAlbum; final void Function() onCreateNewAlbum; final void Function() onUpload; @@ -36,6 +38,8 @@ class ControlBottomAppBar extends ConsumerWidget { this.onFavorite, this.onArchive, this.onDelete, + this.onDeleteServer, + this.onDeleteLocal, required this.onAddToAlbum, required this.onCreateNewAlbum, required this.onUpload, @@ -51,14 +55,35 @@ class ControlBottomAppBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - var hasRemote = + final hasRemote = selectionAssetState.hasRemote || selectionAssetState.hasMerged; - var hasLocal = selectionAssetState.hasLocal; + final hasLocal = + selectionAssetState.hasLocal || selectionAssetState.hasMerged; final trashEnabled = ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList(); final sharedAlbums = ref.watch(sharedAlbumProvider); + void handleRemoteDelete( + bool force, + Function(bool) deleteCb, { + String? alertMsg, + }) { + if (!force) { + deleteCb(force); + return; + } + showDialog( + context: context, + builder: (BuildContext context) { + return DeleteDialog( + alert: alertMsg, + onDelete: () => deleteCb(force), + ); + }, + ); + } + List renderActionButtons() { return [ if (hasRemote) @@ -92,26 +117,55 @@ class ControlBottomAppBar extends ConsumerWidget { .tr(), onPressed: enabled ? onFavorite : null, ), - if (onDelete != null) - ControlBoxButton( - iconData: Icons.delete_outline_rounded, - label: "control_bottom_app_bar_delete".tr(), - onPressed: enabled - ? () { - if (!trashEnabled) { + if (hasRemote && onDeleteServer != null) + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 85), + child: ControlBoxButton( + iconData: Icons.cloud_off_outlined, + label: "control_bottom_app_bar_delete_from_immich".tr(), + onPressed: enabled + ? () => handleRemoteDelete( + !trashEnabled, + onDeleteServer!, + alertMsg: "delete_dialog_alert_remote", + ) + : null, + ), + ), + if (hasLocal && onDeleteLocal != null) + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 85), + child: ControlBoxButton( + iconData: Icons.no_cell_rounded, + label: "control_bottom_app_bar_delete_from_local".tr(), + onPressed: enabled + ? () { + if (!selectionAssetState.hasLocal) { + return onDeleteLocal?.call(true); + } + showDialog( context: context, builder: (BuildContext context) { - return DeleteDialog( - onDelete: onDelete!, + return DeleteLocalOnlyDialog( + onDeleteLocal: onDeleteLocal!, ); }, ); - } else { - onDelete!(); } - } - : null, + : null, + ), + ), + if (hasLocal && hasRemote && onDelete != null) + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 90), + child: ControlBoxButton( + iconData: Icons.delete_sweep_outlined, + label: "control_bottom_app_bar_delete".tr(), + onPressed: enabled + ? () => handleRemoteDelete(!trashEnabled, onDelete!) + : null, + ), ), if (hasRemote && onEditTime != null) ControlBoxButton( @@ -185,9 +239,9 @@ class ControlBottomAppBar extends ConsumerWidget { children: [ const SizedBox(height: 12), const CustomDraggingHandle(), - const SizedBox(height: 12), + const SizedBox(height: 24), SizedBox( - height: 70, + height: 90, child: ListView( shrinkWrap: true, scrollDirection: Axis.horizontal, @@ -201,7 +255,7 @@ class ControlBottomAppBar extends ConsumerWidget { thickness: 1, ), if (hasRemote) - AddToAlbumTitleRow( + _AddToAlbumTitleRow( onCreateNewAlbum: enabled ? onCreateNewAlbum : null, ), ], @@ -225,9 +279,8 @@ class ControlBottomAppBar extends ConsumerWidget { } } -class AddToAlbumTitleRow extends StatelessWidget { - const AddToAlbumTitleRow({ - super.key, +class _AddToAlbumTitleRow extends StatelessWidget { + const _AddToAlbumTitleRow({ required this.onCreateNewAlbum, }); diff --git a/mobile/lib/modules/home/ui/delete_dialog.dart b/mobile/lib/modules/home/ui/delete_dialog.dart index 7d290cd1a7..9f20e696ba 100644 --- a/mobile/lib/modules/home/ui/delete_dialog.dart +++ b/mobile/lib/modules/home/ui/delete_dialog.dart @@ -1,16 +1,80 @@ +// ignore_for_file: prefer-single-widget-per-file + +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/shared/ui/confirm_dialog.dart'; class DeleteDialog extends ConfirmDialog { - final Function onDelete; - - const DeleteDialog({Key? key, required this.onDelete}) + const DeleteDialog({Key? key, String? alert, required Function onDelete}) : super( key: key, title: "delete_dialog_title", - content: "delete_dialog_alert", + content: alert ?? "delete_dialog_alert", cancel: "delete_dialog_cancel", ok: "delete_dialog_ok", onOk: onDelete, ); } + +class DeleteLocalOnlyDialog extends StatelessWidget { + final void Function(bool onlyMerged) onDeleteLocal; + + const DeleteLocalOnlyDialog({ + super.key, + required this.onDeleteLocal, + }); + + @override + Widget build(BuildContext context) { + void onDeleteBackedUpOnly() { + context.pop(); + onDeleteLocal(true); + } + + void onForceDelete() { + context.pop(); + onDeleteLocal(false); + } + + return AlertDialog( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(10)), + ), + title: const Text("delete_dialog_title").tr(), + content: const Text("delete_dialog_alert_local_non_backed_up").tr(), + actions: [ + TextButton( + onPressed: () => context.pop(), + child: Text( + "delete_dialog_cancel", + style: TextStyle( + color: context.primaryColor, + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + TextButton( + onPressed: onDeleteBackedUpOnly, + child: Text( + "delete_local_dialog_ok_backed_up_only", + style: TextStyle( + color: context.colorScheme.tertiary, + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + TextButton( + onPressed: onForceDelete, + child: Text( + "delete_local_dialog_ok_force", + style: TextStyle( + color: Colors.red[400], + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + ], + ); + } +} diff --git a/mobile/lib/shared/providers/asset.provider.dart b/mobile/lib/shared/providers/asset.provider.dart index 86bc35ccce..f874a6d5ad 100644 --- a/mobile/lib/shared/providers/asset.provider.dart +++ b/mobile/lib/shared/providers/asset.provider.dart @@ -91,6 +91,90 @@ class AssetNotifier extends StateNotifier { await _syncService.syncNewAssetToDb(newAsset); } + Future deleteLocalOnlyAssets( + Iterable deleteAssets, { + bool onlyBackedUp = false, + }) async { + _deleteInProgress = true; + state = true; + try { + final assets = onlyBackedUp + ? deleteAssets.where((e) => e.storage == AssetState.merged) + : deleteAssets; + final localDeleted = await _deleteLocalAssets(assets); + if (localDeleted.isNotEmpty) { + final localOnlyIds = deleteAssets + .where((e) => e.storage == AssetState.local) + .map((e) => e.id) + .toList(); + // Update merged assets to remote only + final mergedAssets = + deleteAssets.where((e) => e.storage == AssetState.merged).map((e) { + e.localId = null; + return e; + }).toList(); + await _db.writeTxn(() async { + if (mergedAssets.isNotEmpty) { + await _db.assets.putAll(mergedAssets); + } + await _db.exifInfos.deleteAll(localOnlyIds); + await _db.assets.deleteAll(localOnlyIds); + }); + return true; + } + } finally { + _deleteInProgress = false; + state = false; + } + return false; + } + + Future deleteRemoteOnlyAssets( + Iterable deleteAssets, { + bool force = false, + }) async { + _deleteInProgress = true; + state = true; + try { + final remoteDeleted = await _deleteRemoteAssets(deleteAssets, force); + if (remoteDeleted.isNotEmpty) { + final assetsToUpdate = force + + /// If force, only update merged only assets and remove remote assets + ? remoteDeleted + .where((e) => e.storage == AssetState.merged) + .map((e) { + e.remoteId = null; + return e; + }) + // If not force, trash everything + : remoteDeleted.where((e) => e.isRemote).map((e) { + e.isTrashed = true; + return e; + }); + + await _db.writeTxn(() async { + if (assetsToUpdate.isNotEmpty) { + await _db.assets.putAll(assetsToUpdate.toList()); + } + if (force) { + final remoteOnly = remoteDeleted + .where((e) => e.storage == AssetState.remote) + .map((e) => e.id) + .toList(); + await _db.exifInfos.deleteAll(remoteOnly); + await _db.assets.deleteAll(remoteOnly); + } + }); + return true; + } + } finally { + _deleteInProgress = false; + state = false; + } + return false; + } + Future deleteAssets( Iterable deleteAssets, { bool force = false, @@ -98,8 +182,11 @@ class AssetNotifier extends StateNotifier { _deleteInProgress = true; state = true; try { + final hasLocal = deleteAssets.any((a) => a.storage != AssetState.remote); final localDeleted = await _deleteLocalAssets(deleteAssets); - final remoteDeleted = await _deleteRemoteAssets(deleteAssets, force); + final remoteDeleted = (hasLocal && localDeleted.isNotEmpty) || !hasLocal + ? await _deleteRemoteAssets(deleteAssets, force) + : []; if (localDeleted.isNotEmpty || remoteDeleted.isNotEmpty) { final dbIds = []; final dbUpdates = []; @@ -192,14 +279,14 @@ class AssetNotifier extends StateNotifier { return []; } - Future> _deleteRemoteAssets( + Future> _deleteRemoteAssets( Iterable assetsToDelete, bool? force, ) async { final Iterable remote = assetsToDelete.where((e) => e.isRemote); final isSuccess = await _assetService.deleteAssets(remote, force: force); - return isSuccess ? remote : []; + return isSuccess ? remote.toList() : []; } Future toggleFavorite(List assets, [bool? status]) async { diff --git a/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart b/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart index c0b479f2e8..0dafc435bd 100644 --- a/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart +++ b/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart @@ -22,7 +22,6 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart'; -import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/user.provider.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart'; @@ -115,10 +114,10 @@ class MultiselectGrid extends HookConsumerWidget { }) { final assets = selection.value; return assets - .remoteOnly(errorCallback: errorBuilder(ownerErrorMessage)) + .remoteOnly(errorCallback: errorBuilder(localErrorMessage)) .ownedOnly( currentUser, - errorCallback: errorBuilder(localErrorMessage), + errorCallback: errorBuilder(ownerErrorMessage), ); } @@ -176,11 +175,9 @@ class MultiselectGrid extends HookConsumerWidget { } } - void onDelete() async { + void onDelete([bool force = false]) async { processing.value = true; try { - final trashEnabled = - ref.read(serverInfoProvider.select((v) => v.serverFeatures.trash)); final toDelete = selection.value .ownedOnly( currentUser, @@ -192,23 +189,77 @@ class MultiselectGrid extends HookConsumerWidget { errorBuilder('asset_action_delete_err_read_only'.tr()), ) .toList(); - await ref + final isDeleted = await ref .read(assetProvider.notifier) - .deleteAssets(toDelete, force: !trashEnabled); + .deleteAssets(toDelete, force: force); - final hasRemote = toDelete.any((a) => a.isRemote); - final assetOrAssets = toDelete.length > 1 ? 'assets' : 'asset'; - final trashOrRemoved = - !trashEnabled ? 'deleted permanently' : 'trashed'; - if (hasRemote) { + if (isDeleted) { + final assetOrAssets = toDelete.length > 1 ? 'assets' : 'asset'; + final trashOrRemoved = force ? 'deleted permanently' : 'trashed'; ImmichToast.show( context: context, msg: '${selection.value.length} $assetOrAssets $trashOrRemoved', gravity: ToastGravity.BOTTOM, ); } - selectionEnabledHook.value = false; } finally { + selectionEnabledHook.value = false; + processing.value = false; + } + } + + void onDeleteLocal(bool onlyBackedUp) async { + processing.value = true; + try { + final localIds = selection.value.where((a) => a.isLocal).toList(); + + final isDeleted = await ref + .read(assetProvider.notifier) + .deleteLocalOnlyAssets(localIds, onlyBackedUp: onlyBackedUp); + if (isDeleted) { + final assetOrAssets = localIds.length > 1 ? 'assets' : 'asset'; + ImmichToast.show( + context: context, + msg: + '${localIds.length} $assetOrAssets removed permanently from your device', + gravity: ToastGravity.BOTTOM, + ); + } + } finally { + selectionEnabledHook.value = false; + processing.value = false; + } + } + + void onDeleteRemote([bool force = false]) async { + processing.value = true; + try { + final toDelete = ownedRemoteSelection( + localErrorMessage: 'home_page_delete_remote_err_local'.tr(), + ownerErrorMessage: 'home_page_delete_err_partner'.tr(), + ) + // Cannot delete readOnly / external assets. They are handled through library offline jobs + .writableOnly( + errorCallback: + errorBuilder('asset_action_delete_err_read_only'.tr()), + ) + .toList(); + + final isDeleted = await ref + .read(assetProvider.notifier) + .deleteRemoteOnlyAssets(toDelete, force: force); + if (isDeleted) { + final assetOrAssets = toDelete.length > 1 ? 'assets' : 'asset'; + final trashOrRemoved = force ? 'deleted permanently' : 'trashed'; + ImmichToast.show( + context: context, + msg: + '${toDelete.length} $assetOrAssets $trashOrRemoved from the Immich server', + gravity: ToastGravity.BOTTOM, + ); + } + } finally { + selectionEnabledHook.value = false; processing.value = false; } } @@ -401,6 +452,11 @@ class MultiselectGrid extends HookConsumerWidget { onFavorite: favoriteEnabled ? onFavoriteAssets : null, onArchive: archiveEnabled ? onArchiveAsset : null, onDelete: deleteEnabled ? onDelete : null, + onDeleteServer: deleteEnabled ? onDeleteRemote : null, + + /// local file deletion is allowed irrespective of [deleteEnabled] since it has + /// nothing to do with the state of the asset in the Immich server + onDeleteLocal: onDeleteLocal, onAddToAlbum: onAddToAlbum, onCreateNewAlbum: onCreateNewAlbum, onUpload: onUpload, diff --git a/mobile/lib/shared/ui/confirm_dialog.dart b/mobile/lib/shared/ui/confirm_dialog.dart index 4dd3cabbf2..e08c2ae618 100644 --- a/mobile/lib/shared/ui/confirm_dialog.dart +++ b/mobile/lib/shared/ui/confirm_dialog.dart @@ -1,9 +1,8 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -class ConfirmDialog extends ConsumerWidget { +class ConfirmDialog extends StatelessWidget { final Function onOk; final String title; final String content; @@ -20,9 +19,16 @@ class ConfirmDialog extends ConsumerWidget { }) : super(key: key); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { + void onOkPressed() { + onOk(); + context.pop(true); + } + return AlertDialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(10)), + ), title: Text(title).tr(), content: Text(content).tr(), actions: [ @@ -37,10 +43,7 @@ class ConfirmDialog extends ConsumerWidget { ).tr(), ), TextButton( - onPressed: () { - onOk(); - context.pop(true); - }, + onPressed: onOkPressed, child: Text( ok, style: TextStyle( diff --git a/mobile/lib/shared/ui/drag_sheet.dart b/mobile/lib/shared/ui/drag_sheet.dart index b9da9ce735..fd4a3c2cf9 100644 --- a/mobile/lib/shared/ui/drag_sheet.dart +++ b/mobile/lib/shared/ui/drag_sheet.dart @@ -37,14 +37,16 @@ class ControlBoxButton extends StatelessWidget { onPressed: onPressed, minWidth: 75.0, child: Column( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ Icon(iconData, size: 24), - const SizedBox(height: 4), + const SizedBox(height: 8), Text( label, style: const TextStyle(fontSize: 12.0), + maxLines: 2, + textAlign: TextAlign.center, ), ], ),