mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-04 03:27:09 -05:00 
			
		
		
		
	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:
		
							parent
							
								
									1fb8861e35
								
							
						
					
					
						commit
						8733d1e554
					
				@ -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",
 | 
			
		||||
 | 
			
		||||
@ -7,7 +7,8 @@ abstract interface class IDownloadRepository {
 | 
			
		||||
  void Function(TaskProgressUpdate)? onTaskProgress;
 | 
			
		||||
 | 
			
		||||
  Future<List<TaskRecord>> getLiveVideoTasks();
 | 
			
		||||
  Future<bool> download(DownloadTask task);
 | 
			
		||||
  Future<List<bool>> downloadAll(List<DownloadTask> tasks);
 | 
			
		||||
 | 
			
		||||
  Future<bool> cancel(String id);
 | 
			
		||||
  Future<void> deleteAllTrackingRecords();
 | 
			
		||||
  Future<void> deleteRecordsWithIds(List<String> id);
 | 
			
		||||
 | 
			
		||||
@ -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) {
 | 
			
		||||
 | 
			
		||||
@ -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 {
 | 
			
		||||
    await _downloadService.download(asset);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -39,8 +39,8 @@ class DownloadRepository implements IDownloadRepository {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Future<bool> download(DownloadTask task) {
 | 
			
		||||
    return FileDownloader().enqueue(task);
 | 
			
		||||
  Future<List<bool>> downloadAll(List<DownloadTask> tasks) {
 | 
			
		||||
    return FileDownloader().enqueueAll(tasks);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
 | 
			
		||||
@ -159,9 +159,19 @@ class DownloadService {
 | 
			
		||||
    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 {
 | 
			
		||||
    final tasks = _createDownloadTasks(asset);
 | 
			
		||||
    await _downloadRepository.downloadAll(tasks);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  List<DownloadTask> _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(
 | 
			
		||||
      ];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (asset.remoteId == null) {
 | 
			
		||||
      return [];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return [
 | 
			
		||||
      _buildDownloadTask(
 | 
			
		||||
        asset.remoteId!,
 | 
			
		||||
        asset.fileName,
 | 
			
		||||
        group: asset.isImage ? downloadGroupImage : downloadGroupVideo,
 | 
			
		||||
      ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    ];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  DownloadTask _buildDownloadTask(
 | 
			
		||||
 | 
			
		||||
@ -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),
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user