diff --git a/i18n/en.json b/i18n/en.json index a43d288954b..2eb04cfedfb 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -463,6 +463,8 @@ "assets_count": "{count, plural, one {# asset} other {# assets}}", "assets_deleted_permanently": "{count} asset(s) deleted permanently", "assets_deleted_permanently_from_server": "{count} asset(s) deleted permanently from the Immich server", + "assets_downloaded_failed": "{count, plural, one {Downloaded # file - {error} file failed} other {Downloaded # files - {error} files failed}}", + "assets_downloaded_successfully": "{count, plural, one {Downloaded # file successfully} other {Downloaded # files successfully}}", "assets_moved_to_trash_count": "Moved {count, plural, one {# asset} other {# assets}} to trash", "assets_permanently_deleted_count": "Permanently deleted {count, plural, one {# asset} other {# assets}}", "assets_removed_count": "Removed {count, plural, one {# asset} other {# assets}}", @@ -1170,7 +1172,7 @@ "look": "Look", "loop_videos": "Loop videos", "loop_videos_description": "Enable to automatically loop a video in the detail viewer.", - "main_branch_warning": "You’re using a development version; we strongly recommend using a release version!", + "main_branch_warning": "You're using a development version; we strongly recommend using a release version!", "main_menu": "Main menu", "make": "Make", "manage_shared_links": "Manage shared links", @@ -1435,7 +1437,7 @@ "purchase_lifetime_description": "Lifetime purchase", "purchase_option_title": "PURCHASE OPTIONS", "purchase_panel_info_1": "Building Immich takes a lot of time and effort, and we have full-time engineers working on it to make it as good as we possibly can. Our mission is for open-source software and ethical business practices to become a sustainable income source for developers and to create a privacy-respecting ecosystem with real alternatives to exploitative cloud services.", - "purchase_panel_info_2": "As we’re committed not to add paywalls, this purchase will not grant you any additional features in Immich. We rely on users like you to support Immich’s ongoing development.", + "purchase_panel_info_2": "As we're committed not to add paywalls, this purchase will not grant you any additional features in Immich. We rely on users like you to support Immich's ongoing development.", "purchase_panel_title": "Support the project", "purchase_per_server": "Per server", "purchase_per_user": "Per user", diff --git a/mobile/lib/interfaces/download.interface.dart b/mobile/lib/interfaces/download.interface.dart index dc4f0f57f8c..beb063d6a2c 100644 --- a/mobile/lib/interfaces/download.interface.dart +++ b/mobile/lib/interfaces/download.interface.dart @@ -7,7 +7,8 @@ abstract interface class IDownloadRepository { void Function(TaskProgressUpdate)? onTaskProgress; Future> getLiveVideoTasks(); - Future download(DownloadTask task); + Future> downloadAll(List tasks); + Future cancel(String id); Future deleteAllTrackingRecords(); Future deleteRecordsWithIds(List id); diff --git a/mobile/lib/models/asset_selection_state.dart b/mobile/lib/models/asset_selection_state.dart index b8a38ecee14..b080dca003c 100644 --- a/mobile/lib/models/asset_selection_state.dart +++ b/mobile/lib/models/asset_selection_state.dart @@ -35,7 +35,7 @@ class AssetSelectionState { @override String toString() => - 'SelectionAssetState(hasRemote: $hasRemote, hasMerged: $hasMerged, hasMerged: $hasMerged, selectedCount: $selectedCount)'; + 'SelectionAssetState(hasRemote: $hasRemote, hasLocal: $hasLocal, hasMerged: $hasMerged, selectedCount: $selectedCount)'; @override bool operator ==(covariant AssetSelectionState other) { diff --git a/mobile/lib/providers/asset_viewer/download.provider.dart b/mobile/lib/providers/asset_viewer/download.provider.dart index d699c7c7637..7750d6511a6 100644 --- a/mobile/lib/providers/asset_viewer/download.provider.dart +++ b/mobile/lib/providers/asset_viewer/download.provider.dart @@ -140,6 +140,10 @@ class DownloadStateNotifier extends StateNotifier { }); } + Future> downloadAllAsset(List assets) async { + return await _downloadService.downloadAll(assets); + } + void downloadAsset(Asset asset, BuildContext context) async { await _downloadService.download(asset); } diff --git a/mobile/lib/repositories/download.repository.dart b/mobile/lib/repositories/download.repository.dart index 5b42f66b021..f7ba612045c 100644 --- a/mobile/lib/repositories/download.repository.dart +++ b/mobile/lib/repositories/download.repository.dart @@ -39,8 +39,8 @@ class DownloadRepository implements IDownloadRepository { } @override - Future download(DownloadTask task) { - return FileDownloader().enqueue(task); + Future> downloadAll(List tasks) { + return FileDownloader().enqueueAll(tasks); } @override diff --git a/mobile/lib/services/download.service.dart b/mobile/lib/services/download.service.dart index 45297853f69..e7ecff175ce 100644 --- a/mobile/lib/services/download.service.dart +++ b/mobile/lib/services/download.service.dart @@ -159,9 +159,19 @@ class DownloadService { return await FileDownloader().cancelTaskWithId(id); } + Future> downloadAll(List assets) async { + return await _downloadRepository + .downloadAll(assets.expand(_createDownloadTasks).toList()); + } + Future download(Asset asset) async { + final tasks = _createDownloadTasks(asset); + await _downloadRepository.downloadAll(tasks); + } + + List _createDownloadTasks(Asset asset) { if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) { - await _downloadRepository.download( + return [ _buildDownloadTask( asset.remoteId!, asset.fileName, @@ -171,9 +181,6 @@ class DownloadService { id: asset.remoteId!, ).toJson(), ), - ); - - await _downloadRepository.download( _buildDownloadTask( asset.livePhotoVideoId!, asset.fileName @@ -185,16 +192,20 @@ class DownloadService { id: asset.remoteId!, ).toJson(), ), - ); - } else { - await _downloadRepository.download( - _buildDownloadTask( - asset.remoteId!, - asset.fileName, - group: asset.isImage ? downloadGroupImage : downloadGroupVideo, - ), - ); + ]; } + + if (asset.remoteId == null) { + return []; + } + + return [ + _buildDownloadTask( + asset.remoteId!, + asset.fileName, + group: asset.isImage ? downloadGroupImage : downloadGroupVideo, + ), + ]; } DownloadTask _buildDownloadTask( diff --git a/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart b/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart index 892e7e5b8a5..309de3ae281 100644 --- a/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart +++ b/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart @@ -39,6 +39,7 @@ class ControlBottomAppBar extends HookConsumerWidget { final void Function()? onEditLocation; final void Function()? onRemoveFromAlbum; final void Function()? onToggleLocked; + final void Function()? onDownload; final bool enabled; final bool unfavorite; @@ -56,6 +57,7 @@ class ControlBottomAppBar extends HookConsumerWidget { required this.onAddToAlbum, required this.onCreateNewAlbum, required this.onUpload, + this.onDownload, this.onStack, this.onEditTime, this.onEditLocation, @@ -158,6 +160,15 @@ class ControlBottomAppBar extends HookConsumerWidget { label: (unfavorite ? "unfavorite" : "favorite").tr(), onPressed: enabled ? onFavorite : null, ), + if (hasRemote && onDownload != null) + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 90), + child: ControlBoxButton( + iconData: Icons.download, + label: "download".tr(), + onPressed: onDownload, + ), + ), if (hasLocal && hasRemote && onDelete != null && !isInLockedView) ConstrainedBox( constraints: const BoxConstraints(maxWidth: 90), diff --git a/mobile/lib/widgets/asset_grid/multiselect_grid.dart b/mobile/lib/widgets/asset_grid/multiselect_grid.dart index 8cc725ab77e..99044475694 100644 --- a/mobile/lib/widgets/asset_grid/multiselect_grid.dart +++ b/mobile/lib/widgets/asset_grid/multiselect_grid.dart @@ -14,6 +14,7 @@ import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/models/asset_selection_state.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; @@ -23,6 +24,7 @@ import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/stack.service.dart'; import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/utils/selection_handlers.dart'; +import 'package:immich_mobile/utils/translation.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/widgets/asset_grid/control_bottom_app_bar.dart'; import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart'; @@ -44,6 +46,7 @@ class MultiselectGrid extends HookConsumerWidget { this.editEnabled = false, this.unarchive = false, this.unfavorite = false, + this.downloadEnabled = true, this.emptyIndicator, }); @@ -57,6 +60,7 @@ class MultiselectGrid extends HookConsumerWidget { final bool archiveEnabled; final bool unarchive; final bool deleteEnabled; + final bool downloadEnabled; final bool favoriteEnabled; final bool unfavorite; final bool editEnabled; @@ -239,6 +243,39 @@ class MultiselectGrid extends HookConsumerWidget { } } + void onDownload() async { + processing.value = true; + try { + final toDownload = selection.value.toList(); + + final results = await ref + .read(downloadStateProvider.notifier) + .downloadAllAsset(toDownload); + + final totalCount = toDownload.length; + final successCount = results.where((e) => e).length; + final failedCount = totalCount - successCount; + + final msg = failedCount > 0 + ? t('assets_downloaded_failed', { + 'count': successCount, + 'error': failedCount, + }) + : t('assets_downloaded_successfully', { + 'count': successCount, + }); + + ImmichToast.show( + context: context, + msg: msg, + gravity: ToastGravity.BOTTOM, + ); + } finally { + processing.value = false; + selectionEnabledHook.value = false; + } + } + void onDeleteRemote([bool shouldDeletePermanently = false]) async { processing.value = true; try { @@ -474,6 +511,7 @@ class MultiselectGrid extends HookConsumerWidget { onArchive: archiveEnabled ? onArchiveAsset : null, onDelete: deleteEnabled ? onDelete : null, onDeleteServer: deleteEnabled ? onDeleteRemote : null, + onDownload: downloadEnabled ? onDownload : 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