feat(mobile): add bulk download functionality (#18878)

* feat(mobile): add bulk download functionality and update UI messages

- Added `downloadAll` method to `IDownloadRepository` and its implementation in `DownloadRepository` to handle multiple asset downloads.
- Implemented `downloadAllAsset` in `DownloadStateNotifier` to trigger bulk downloads.
- Updated `DownloadService` to create download tasks for all selected assets.
- Enhanced UI with new download success and failure messages in `en.json`.
- Added download button to `ControlBottomAppBar` and integrated download functionality in `MultiselectGrid`.

* translations use i18n method t()

* Update mobile/lib/services/download.service.dart

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>

* fix(mobile): update download logic in DownloadService

- Changed the download method to utilize downloadAll for handling multiple tasks.
- Simplified remoteId check by removing unnecessary condition.

* sort i18n keys

* remove the download signature from interface and logic as we use the downloadAll now

---------

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
This commit is contained in:
JobiJoba 2025-06-04 21:49:43 +07:00 committed by GitHub
parent 1fb8861e35
commit 8733d1e554
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 86 additions and 19 deletions

View File

@ -463,6 +463,8 @@
"assets_count": "{count, plural, one {# asset} other {# assets}}", "assets_count": "{count, plural, one {# asset} other {# assets}}",
"assets_deleted_permanently": "{count} asset(s) deleted permanently", "assets_deleted_permanently": "{count} asset(s) deleted permanently",
"assets_deleted_permanently_from_server": "{count} asset(s) deleted permanently from the Immich server", "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_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_permanently_deleted_count": "Permanently deleted {count, plural, one {# asset} other {# assets}}",
"assets_removed_count": "Removed {count, plural, one {# asset} other {# assets}}", "assets_removed_count": "Removed {count, plural, one {# asset} other {# assets}}",
@ -1170,7 +1172,7 @@
"look": "Look", "look": "Look",
"loop_videos": "Loop videos", "loop_videos": "Loop videos",
"loop_videos_description": "Enable to automatically loop a video in the detail viewer.", "loop_videos_description": "Enable to automatically loop a video in the detail viewer.",
"main_branch_warning": "Youre 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", "main_menu": "Main menu",
"make": "Make", "make": "Make",
"manage_shared_links": "Manage shared links", "manage_shared_links": "Manage shared links",
@ -1435,7 +1437,7 @@
"purchase_lifetime_description": "Lifetime purchase", "purchase_lifetime_description": "Lifetime purchase",
"purchase_option_title": "PURCHASE OPTIONS", "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_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 were committed not to add paywalls, this purchase will not grant you any additional features in Immich. We rely on users like you to support Immichs 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_panel_title": "Support the project",
"purchase_per_server": "Per server", "purchase_per_server": "Per server",
"purchase_per_user": "Per user", "purchase_per_user": "Per user",

View File

@ -7,7 +7,8 @@ abstract interface class IDownloadRepository {
void Function(TaskProgressUpdate)? onTaskProgress; void Function(TaskProgressUpdate)? onTaskProgress;
Future<List<TaskRecord>> getLiveVideoTasks(); Future<List<TaskRecord>> getLiveVideoTasks();
Future<bool> download(DownloadTask task); Future<List<bool>> downloadAll(List<DownloadTask> tasks);
Future<bool> cancel(String id); Future<bool> cancel(String id);
Future<void> deleteAllTrackingRecords(); Future<void> deleteAllTrackingRecords();
Future<void> deleteRecordsWithIds(List<String> id); Future<void> deleteRecordsWithIds(List<String> id);

View File

@ -35,7 +35,7 @@ class AssetSelectionState {
@override @override
String toString() => String toString() =>
'SelectionAssetState(hasRemote: $hasRemote, hasMerged: $hasMerged, hasMerged: $hasMerged, selectedCount: $selectedCount)'; 'SelectionAssetState(hasRemote: $hasRemote, hasLocal: $hasLocal, hasMerged: $hasMerged, selectedCount: $selectedCount)';
@override @override
bool operator ==(covariant AssetSelectionState other) { bool operator ==(covariant AssetSelectionState other) {

View File

@ -140,6 +140,10 @@ class DownloadStateNotifier extends StateNotifier<DownloadState> {
}); });
} }
Future<List<bool>> downloadAllAsset(List<Asset> assets) async {
return await _downloadService.downloadAll(assets);
}
void downloadAsset(Asset asset, BuildContext context) async { void downloadAsset(Asset asset, BuildContext context) async {
await _downloadService.download(asset); await _downloadService.download(asset);
} }

View File

@ -39,8 +39,8 @@ class DownloadRepository implements IDownloadRepository {
} }
@override @override
Future<bool> download(DownloadTask task) { Future<List<bool>> downloadAll(List<DownloadTask> tasks) {
return FileDownloader().enqueue(task); return FileDownloader().enqueueAll(tasks);
} }
@override @override

View File

@ -159,9 +159,19 @@ class DownloadService {
return await FileDownloader().cancelTaskWithId(id); return await FileDownloader().cancelTaskWithId(id);
} }
Future<List<bool>> downloadAll(List<Asset> assets) async {
return await _downloadRepository
.downloadAll(assets.expand(_createDownloadTasks).toList());
}
Future<void> download(Asset asset) async { Future<void> download(Asset asset) async {
final tasks = _createDownloadTasks(asset);
await _downloadRepository.downloadAll(tasks);
}
List<DownloadTask> _createDownloadTasks(Asset asset) {
if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) { if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) {
await _downloadRepository.download( return [
_buildDownloadTask( _buildDownloadTask(
asset.remoteId!, asset.remoteId!,
asset.fileName, asset.fileName,
@ -171,9 +181,6 @@ class DownloadService {
id: asset.remoteId!, id: asset.remoteId!,
).toJson(), ).toJson(),
), ),
);
await _downloadRepository.download(
_buildDownloadTask( _buildDownloadTask(
asset.livePhotoVideoId!, asset.livePhotoVideoId!,
asset.fileName asset.fileName
@ -185,16 +192,20 @@ class DownloadService {
id: asset.remoteId!, id: asset.remoteId!,
).toJson(), ).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( DownloadTask _buildDownloadTask(

View File

@ -39,6 +39,7 @@ class ControlBottomAppBar extends HookConsumerWidget {
final void Function()? onEditLocation; final void Function()? onEditLocation;
final void Function()? onRemoveFromAlbum; final void Function()? onRemoveFromAlbum;
final void Function()? onToggleLocked; final void Function()? onToggleLocked;
final void Function()? onDownload;
final bool enabled; final bool enabled;
final bool unfavorite; final bool unfavorite;
@ -56,6 +57,7 @@ class ControlBottomAppBar extends HookConsumerWidget {
required this.onAddToAlbum, required this.onAddToAlbum,
required this.onCreateNewAlbum, required this.onCreateNewAlbum,
required this.onUpload, required this.onUpload,
this.onDownload,
this.onStack, this.onStack,
this.onEditTime, this.onEditTime,
this.onEditLocation, this.onEditLocation,
@ -158,6 +160,15 @@ class ControlBottomAppBar extends HookConsumerWidget {
label: (unfavorite ? "unfavorite" : "favorite").tr(), label: (unfavorite ? "unfavorite" : "favorite").tr(),
onPressed: enabled ? onFavorite : null, 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) if (hasLocal && hasRemote && onDelete != null && !isInLockedView)
ConstrainedBox( ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 90), constraints: const BoxConstraints(maxWidth: 90),

View File

@ -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/models/asset_selection_state.dart';
import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/asset.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/backup/manual_upload.provider.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/providers/routes.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/services/stack.service.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/utils/selection_handlers.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/asset_grid_data_structure.dart';
import 'package:immich_mobile/widgets/asset_grid/control_bottom_app_bar.dart'; import 'package:immich_mobile/widgets/asset_grid/control_bottom_app_bar.dart';
import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart';
@ -44,6 +46,7 @@ class MultiselectGrid extends HookConsumerWidget {
this.editEnabled = false, this.editEnabled = false,
this.unarchive = false, this.unarchive = false,
this.unfavorite = false, this.unfavorite = false,
this.downloadEnabled = true,
this.emptyIndicator, this.emptyIndicator,
}); });
@ -57,6 +60,7 @@ class MultiselectGrid extends HookConsumerWidget {
final bool archiveEnabled; final bool archiveEnabled;
final bool unarchive; final bool unarchive;
final bool deleteEnabled; final bool deleteEnabled;
final bool downloadEnabled;
final bool favoriteEnabled; final bool favoriteEnabled;
final bool unfavorite; final bool unfavorite;
final bool editEnabled; 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 { void onDeleteRemote([bool shouldDeletePermanently = false]) async {
processing.value = true; processing.value = true;
try { try {
@ -474,6 +511,7 @@ class MultiselectGrid extends HookConsumerWidget {
onArchive: archiveEnabled ? onArchiveAsset : null, onArchive: archiveEnabled ? onArchiveAsset : null,
onDelete: deleteEnabled ? onDelete : null, onDelete: deleteEnabled ? onDelete : null,
onDeleteServer: deleteEnabled ? onDeleteRemote : null, onDeleteServer: deleteEnabled ? onDeleteRemote : null,
onDownload: downloadEnabled ? onDownload : null,
/// local file deletion is allowed irrespective of [deleteEnabled] since it has /// local file deletion is allowed irrespective of [deleteEnabled] since it has
/// nothing to do with the state of the asset in the Immich server /// nothing to do with the state of the asset in the Immich server