From 261818ddd9684884b4091a494e4d76c63d257a61 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Sat, 19 Jul 2025 12:34:17 +0300 Subject: [PATCH] refactor(mobile): download button in new timeline (#20010) * download button * minor improvements --- i18n/en.json | 1 + .../download_action_button.widget.dart | 40 ++++++- .../asset_viewer/bottom_sheet.widget.dart | 3 +- .../archive_bottom_sheet.widget.dart | 2 +- .../favorite_bottom_sheet.widget.dart | 2 +- .../general_bottom_sheet.widget.dart | 2 +- .../locked_folder_bottom_sheet.widget.dart | 2 +- .../partner_detail_bottom_sheet.widget.dart | 2 +- .../remote_album_bottom_sheet.widget.dart | 2 +- .../infrastructure/action.provider.dart | 24 ++++- .../lib/repositories/download.repository.dart | 102 ++++++++++++++++-- mobile/lib/services/action.service.dart | 9 ++ 12 files changed, 170 insertions(+), 21 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index dfe2954c9f..77dc2e235c 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -782,6 +782,7 @@ "documentation": "Documentation", "done": "Done", "download": "Download", + "download_action_prompt": "Downloading {count} assets", "download_canceled": "Download canceled", "download_complete": "Download complete", "download_enqueue": "Download enqueued", diff --git a/mobile/lib/presentation/widgets/action_buttons/download_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/download_action_button.widget.dart index 53ea5d4946..c6eda703a5 100644 --- a/mobile/lib/presentation/widgets/action_buttons/download_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/download_action_button.widget.dart @@ -1,16 +1,54 @@ +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.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/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; class DownloadActionButton extends ConsumerWidget { - const DownloadActionButton({super.key}); + final ActionSource source; + + const DownloadActionButton({super.key, required this.source}); + + void _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + final result = await ref.read(actionProvider.notifier).downloadAll(source); + ref.read(multiSelectProvider.notifier).reset(); + + if (!context.mounted) { + return; + } + + if (!result.success) { + ImmichToast.show( + context: context, + msg: 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: ToastType.error, + ); + } else if (result.count > 0) { + ImmichToast.show( + context: context, + msg: 'download_action_prompt' + .t(context: context, args: {'count': result.count.toString()}), + gravity: ToastGravity.BOTTOM, + toastType: ToastType.success, + ); + } + } @override Widget build(BuildContext context, WidgetRef ref) { return BaseActionButton( iconData: Icons.download, label: "download".t(context: context), + onPressed: () => _onTap(context, ref), ); } } 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 a8b7c79588..89822fef91 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -52,7 +52,8 @@ class AssetDetailBottomSheet extends ConsumerWidget { if (asset.hasRemote) ...[ const ShareLinkActionButton(source: ActionSource.viewer), const ArchiveActionButton(source: ActionSource.viewer), - if (!asset.hasLocal) const DownloadActionButton(), + if (!asset.hasLocal) + const DownloadActionButton(source: ActionSource.viewer), isTrashEnable ? const TrashActionButton(source: ActionSource.viewer) : const DeletePermanentActionButton(source: ActionSource.viewer), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart index a3d24ec8ee..9ed35da4cd 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart @@ -38,7 +38,7 @@ class ArchiveBottomSheet extends ConsumerWidget { const ShareLinkActionButton(source: ActionSource.timeline), const UnArchiveActionButton(source: ActionSource.timeline), const FavoriteActionButton(source: ActionSource.timeline), - const DownloadActionButton(), + const DownloadActionButton(source: ActionSource.timeline), isTrashEnable ? const TrashActionButton(source: ActionSource.timeline) : const DeletePermanentActionButton( diff --git a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart index eefe19194c..a1e1255a9f 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart @@ -38,7 +38,7 @@ class FavoriteBottomSheet extends ConsumerWidget { const ShareLinkActionButton(source: ActionSource.timeline), const UnFavoriteActionButton(source: ActionSource.timeline), const ArchiveActionButton(source: ActionSource.timeline), - const DownloadActionButton(), + const DownloadActionButton(source: ActionSource.timeline), isTrashEnable ? const TrashActionButton(source: ActionSource.timeline) : const DeletePermanentActionButton( 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 f9a9dd3203..373d264d82 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 @@ -38,7 +38,7 @@ class GeneralBottomSheet extends ConsumerWidget { const ShareLinkActionButton(source: ActionSource.timeline), const ArchiveActionButton(source: ActionSource.timeline), const FavoriteActionButton(source: ActionSource.timeline), - const DownloadActionButton(), + const DownloadActionButton(source: ActionSource.timeline), isTrashEnable ? const TrashActionButton(source: ActionSource.timeline) : const DeletePermanentActionButton( diff --git a/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart index ef71f3a3a3..7f82f750f7 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart @@ -18,7 +18,7 @@ class LockedFolderBottomSheet extends ConsumerWidget { shouldCloseOnMinExtent: false, actions: [ ShareActionButton(source: ActionSource.timeline), - DownloadActionButton(), + DownloadActionButton(source: ActionSource.timeline), DeletePermanentActionButton(source: ActionSource.timeline), RemoveFromLockFolderActionButton(source: ActionSource.timeline), ], diff --git a/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart index 1cdf6f28d6..5e4dae34bc 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart @@ -16,7 +16,7 @@ class PartnerDetailBottomSheet extends ConsumerWidget { shouldCloseOnMinExtent: false, actions: [ ShareActionButton(source: ActionSource.timeline), - DownloadActionButton(), + DownloadActionButton(source: ActionSource.timeline), ], ); } diff --git a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart index b5fecdf7a4..cfb6fe4f1a 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart @@ -41,7 +41,7 @@ class RemoteAlbumBottomSheet extends ConsumerWidget { const ShareLinkActionButton(source: ActionSource.timeline), const ArchiveActionButton(source: ActionSource.timeline), const FavoriteActionButton(source: ActionSource.timeline), - const DownloadActionButton(), + const DownloadActionButton(source: ActionSource.timeline), isTrashEnable ? const TrashActionButton(source: ActionSource.timeline) : const DeletePermanentActionButton( diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index a0ebf448fc..cb025ef941 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -41,7 +41,8 @@ class ActionNotifier extends Notifier { } List _getRemoteIdsForSource(ActionSource source) { - return _getIdsForSource(source) + return _getAssets(source) + .whereType() .toIds() .toList(growable: false); } @@ -63,7 +64,8 @@ class ActionNotifier extends Notifier { List _getOwnedRemoteIdsForSource(ActionSource source) { final ownerId = ref.read(currentUserProvider)?.id; - return _getIdsForSource(source) + return _getAssets(source) + .whereType() .ownedAssets(ownerId) .toIds() .toList(growable: false); @@ -331,6 +333,24 @@ class ActionNotifier extends Notifier { ); } } + + Future downloadAll(ActionSource source) async { + final assets = + _getAssets(source).whereType().toList(growable: false); + + try { + final didEnqueue = await _service.downloadAll(assets); + final enqueueCount = didEnqueue.where((e) => e).length; + return ActionResult(count: enqueueCount, success: true); + } catch (error, stack) { + _logger.severe('Failed to download assets', error, stack); + return ActionResult( + count: assets.length, + success: false, + error: error.toString(), + ); + } + } } extension on Iterable { diff --git a/mobile/lib/repositories/download.repository.dart b/mobile/lib/repositories/download.repository.dart index 72f7e065ca..f1dae3c251 100644 --- a/mobile/lib/repositories/download.repository.dart +++ b/mobile/lib/repositories/download.repository.dart @@ -1,10 +1,28 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:background_downloader/background_downloader.dart'; +import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; +import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/download.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; final downloadRepositoryProvider = Provider((ref) => DownloadRepository()); class DownloadRepository { + static final _downloader = FileDownloader(); + static final _dummyTask = DownloadTask( + taskId: 'dummy', + url: '', + filename: 'dummy', + group: '', + updates: Updates.statusAndProgress, + ); + static final _dummyMetadata = {'part': LivePhotosPart.image, 'id': ''}; + void Function(TaskStatusUpdate)? onImageDownloadStatus; void Function(TaskStatusUpdate)? onVideoDownloadStatus; @@ -14,19 +32,19 @@ class DownloadRepository { void Function(TaskProgressUpdate)? onTaskProgress; DownloadRepository() { - FileDownloader().registerCallbacks( + _downloader.registerCallbacks( group: downloadGroupImage, taskStatusCallback: (update) => onImageDownloadStatus?.call(update), taskProgressCallback: (update) => onTaskProgress?.call(update), ); - FileDownloader().registerCallbacks( + _downloader.registerCallbacks( group: downloadGroupVideo, taskStatusCallback: (update) => onVideoDownloadStatus?.call(update), taskProgressCallback: (update) => onTaskProgress?.call(update), ); - FileDownloader().registerCallbacks( + _downloader.registerCallbacks( group: downloadGroupLivePhoto, taskStatusCallback: (update) => onLivePhotoDownloadStatus?.call(update), taskProgressCallback: (update) => onTaskProgress?.call(update), @@ -34,25 +52,87 @@ class DownloadRepository { } Future> downloadAll(List tasks) { - return FileDownloader().enqueueAll(tasks); + return _downloader.enqueueAll(tasks); } Future deleteAllTrackingRecords() { - return FileDownloader().database.deleteAllRecords(); + return _downloader.database.deleteAllRecords(); } Future cancel(String id) { - return FileDownloader().cancelTaskWithId(id); + return _downloader.cancelTaskWithId(id); } Future> getLiveVideoTasks() { - return FileDownloader().database.allRecordsWithStatus( - TaskStatus.complete, - group: downloadGroupLivePhoto, - ); + return _downloader.database.allRecordsWithStatus( + TaskStatus.complete, + group: downloadGroupLivePhoto, + ); } Future deleteRecordsWithIds(List ids) { - return FileDownloader().database.deleteRecordsWithIds(ids); + return _downloader.database.deleteRecordsWithIds(ids); + } + + Future> downloadAllAssets(List assets) async { + if (assets.isEmpty) { + return Future.value(const []); + } + + final length = Platform.isAndroid ? assets.length : assets.length * 2; + final tasks = List.filled(length, _dummyTask); + int taskIndex = 0; + final headers = ApiService.getRequestHeaders(); + for (final asset in assets) { + if (!asset.isRemoteOnly) { + continue; + } + + final id = asset.id; + final livePhotoVideoId = asset.livePhotoVideoId; + final isVideo = asset.isVideo; + final url = getOriginalUrlForRemoteId(id); + + if (Platform.isAndroid || livePhotoVideoId == null || isVideo) { + tasks[taskIndex++] = DownloadTask( + taskId: id, + url: url, + headers: headers, + filename: asset.name, + updates: Updates.statusAndProgress, + group: isVideo ? downloadGroupVideo : downloadGroupImage, + ); + continue; + } + + _dummyMetadata['part'] = LivePhotosPart.image; + _dummyMetadata['id'] = id; + tasks[taskIndex++] = DownloadTask( + taskId: id, + url: url, + headers: headers, + filename: asset.name, + updates: Updates.statusAndProgress, + group: downloadGroupLivePhoto, + metaData: json.encode(_dummyMetadata), + ); + + _dummyMetadata['part'] = LivePhotosPart.video; + tasks[taskIndex++] = DownloadTask( + taskId: livePhotoVideoId, + url: url, + headers: headers, + filename: asset.name + .toUpperCase() + .replaceAll(RegExp(r"\.(JPG|HEIC)$"), '.MOV'), + updates: Updates.statusAndProgress, + group: downloadGroupLivePhoto, + metaData: json.encode(_dummyMetadata), + ); + } + if (taskIndex == 0) { + return Future.value(const []); + } + return _downloader.enqueueAll(tasks.slice(0, taskIndex)); } } diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index b59df5b3dc..7b0d74e420 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -1,3 +1,5 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/repositories/download.repository.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/constants/enums.dart'; @@ -23,6 +25,7 @@ final actionServiceProvider = Provider( ref.watch(driftAlbumApiRepositoryProvider), ref.watch(remoteAlbumRepository), ref.watch(assetMediaRepositoryProvider), + ref.watch(downloadRepositoryProvider), ), ); @@ -33,6 +36,7 @@ class ActionService { final DriftAlbumApiRepository _albumApiRepository; final DriftRemoteAlbumRepository _remoteAlbumRepository; final AssetMediaRepository _assetMediaRepository; + final DownloadRepository _downloadRepository; const ActionService( this._assetApiRepository, @@ -41,6 +45,7 @@ class ActionService { this._albumApiRepository, this._remoteAlbumRepository, this._assetMediaRepository, + this._downloadRepository, ); Future shareLink(List remoteIds, BuildContext context) async { @@ -191,4 +196,8 @@ class ActionService { Future shareAssets(List assets) { return _assetMediaRepository.shareAssets(assets); } + + Future> downloadAll(List assets) { + return _downloadRepository.downloadAllAssets(assets); + } }