From 900febb5174984c4934465716e833ec4265c20b8 Mon Sep 17 00:00:00 2001 From: Yaros Date: Thu, 26 Mar 2026 22:36:05 +0100 Subject: [PATCH] feat(mobile): better downloading while sharing --- .../share_action_button.widget.dart | 70 +++++++++++----- .../infrastructure/action.provider.dart | 8 +- .../repositories/asset_media.repository.dart | 83 +++++++++++++++---- mobile/lib/services/action.service.dart | 14 +++- 4 files changed, 132 insertions(+), 43 deletions(-) diff --git a/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart index 6fbd6f7dfa..7bc5dacb16 100644 --- a/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart @@ -14,7 +14,9 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; class _SharePreparingDialog extends StatelessWidget { - const _SharePreparingDialog(); + final ValueNotifier progress; + + const _SharePreparingDialog({required this.progress}); @override Widget build(BuildContext context) { @@ -22,8 +24,24 @@ class _SharePreparingDialog extends StatelessWidget { content: Column( mainAxisSize: MainAxisSize.min, children: [ - const CircularProgressIndicator(), - Container(margin: const EdgeInsets.only(top: 12), child: const Text('share_dialog_preparing').tr()), + Container(margin: const EdgeInsets.only(bottom: 12), child: const Text('share_dialog_preparing').tr()), + SizedBox( + width: 240, + child: ValueListenableBuilder( + valueListenable: progress, + builder: (context, value, _) { + final percent = value == null ? null : (value * 100).clamp(0, 100); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + LinearProgressIndicator(value: value, minHeight: 8.0), + if (percent != null) + Container(margin: const EdgeInsets.only(top: 8), child: Text('${percent.toStringAsFixed(0)}%')), + ], + ); + }, + ), + ), ], ), ); @@ -43,32 +61,39 @@ class ShareActionButton extends ConsumerWidget { } final cancelCompleter = Completer(); - const preparingDialog = _SharePreparingDialog(); + final progress = ValueNotifier(null); + final preparingDialog = _SharePreparingDialog(progress: progress); await showDialog( context: context, builder: (BuildContext buildContext) { - ref.read(actionProvider.notifier).shareAssets(source, context, cancelCompleter: cancelCompleter).then(( - ActionResult result, - ) { - if (cancelCompleter.isCompleted || !context.mounted) { - return; - } + ref + .read(actionProvider.notifier) + .shareAssets( + source, + context, + cancelCompleter: cancelCompleter, + onAssetDownloadProgress: (value) => progress.value = value, + ) + .then((ActionResult result) { + if (cancelCompleter.isCompleted || !context.mounted) { + return; + } - ref.read(multiSelectProvider.notifier).reset(); + ref.read(multiSelectProvider.notifier).reset(); - if (!result.success) { - ImmichToast.show( - context: context, - msg: 'scaffold_body_error_occurred'.t(context: context), - gravity: ToastGravity.BOTTOM, - toastType: ToastType.error, - ); - } + if (!result.success) { + ImmichToast.show( + context: context, + msg: 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: ToastType.error, + ); + } - buildContext.pop(); - }); + buildContext.pop(); + }); - // show a loading spinner with a "Preparing" message + // Show download progress with a "Preparing" message return preparingDialog; }, barrierDismissible: false, @@ -77,6 +102,7 @@ class ShareActionButton extends ConsumerWidget { if (!cancelCompleter.isCompleted) { cancelCompleter.complete(); } + progress.dispose(); }); } diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index bad0d986d0..4286d2bb74 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -426,11 +426,17 @@ class ActionNotifier extends Notifier { ActionSource source, BuildContext context, { Completer? cancelCompleter, + void Function(double progress)? onAssetDownloadProgress, }) async { final ids = _getAssets(source).toList(growable: false); try { - await _service.shareAssets(ids, context, cancelCompleter: cancelCompleter); + await _service.shareAssets( + ids, + context, + cancelCompleter: cancelCompleter, + onAssetDownloadProgress: onAssetDownloadProgress, + ); return ActionResult(count: ids.length, success: true); } catch (error, stack) { _logger.severe('Failed to share assets', error, stack); diff --git a/mobile/lib/repositories/asset_media.repository.dart b/mobile/lib/repositories/asset_media.repository.dart index fecfe6df4d..fda00da6ee 100644 --- a/mobile/lib/repositories/asset_media.repository.dart +++ b/mobile/lib/repositories/asset_media.repository.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:background_downloader/background_downloader.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -11,21 +12,19 @@ import 'package:immich_mobile/entities/asset.entity.dart' as asset_entity; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; -import 'package:immich_mobile/extensions/response_extensions.dart'; -import 'package:immich_mobile/repositories/asset_api.repository.dart'; +import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/hash.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:logging/logging.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:share_plus/share_plus.dart'; -final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider))); +final assetMediaRepositoryProvider = Provider((ref) => const AssetMediaRepository()); class AssetMediaRepository { - final AssetApiRepository _assetApiRepository; static final Logger _log = Logger("AssetMediaRepository"); - const AssetMediaRepository(this._assetApiRepository); + const AssetMediaRepository(); Future _androidSupportsTrash() async { if (Platform.isAndroid) { @@ -116,10 +115,29 @@ class AssetMediaRepository { ); } - // TODO: make this more efficient - Future shareAssets(List assets, BuildContext context, {Completer? cancelCompleter}) async { + Future shareAssets( + List assets, + BuildContext context, { + Completer? cancelCompleter, + void Function(double progress)? onAssetDownloadProgress, + }) async { final downloadedXFiles = []; final tempFiles = []; + final totalAssets = assets.length; + var processedAssets = 0; + + void updateProgress([double currentAssetProgress = 0.0]) { + if (totalAssets <= 0) { + onAssetDownloadProgress?.call(1.0); + return; + } + + final normalizedAssetProgress = currentAssetProgress.clamp(0.0, 1.0); + final overallProgress = ((processedAssets + normalizedAssetProgress) / totalAssets).clamp(0.0, 1.0); + onAssetDownloadProgress?.call(overallProgress); + } + + updateProgress(); for (var asset in assets) { if (cancelCompleter != null && cancelCompleter.isCompleted) { @@ -136,6 +154,8 @@ class AssetMediaRepository { if (localId != null && !asset.isEdited) { File? f = await AssetEntity(id: localId, width: 1, height: 1, typeInt: 0).originFile; downloadedXFiles.add(XFile(f!.path)); + processedAssets++; + updateProgress(); if (CurrentPlatform.isIOS) { tempFiles.add(f); } @@ -143,22 +163,49 @@ class AssetMediaRepository { final remoteId = (asset is RemoteAsset) ? asset.id : asset.remoteId; if (remoteId == null) { _log.warning("Asset has no remote ID for sharing: $asset"); + processedAssets++; + updateProgress(); continue; } - final tempDir = await getTemporaryDirectory(); - final name = asset.name; - final tempFile = await File('${tempDir.path}/$name').create(); - final res = await _assetApiRepository.downloadAsset(remoteId, edited: true); + final taskId = 'share-$remoteId-${DateTime.now().microsecondsSinceEpoch}'; + final sanitizedFilename = asset.name.replaceAll(RegExp(r'[\\/]'), '_'); + final task = DownloadTask( + taskId: taskId, + url: getOriginalUrlForRemoteId(remoteId, edited: asset.isEdited), + headers: ApiService.getRequestHeaders(), + filename: sanitizedFilename, + baseDirectory: BaseDirectory.temporary, + updates: Updates.statusAndProgress, + ); + final statusUpdate = await FileDownloader().download( + task, + onProgress: (value) { + if (cancelCompleter != null && cancelCompleter.isCompleted) { + unawaited(FileDownloader().cancelTaskWithId(taskId)); + return; + } + updateProgress(value); + }, + ); - if (res.statusCode != 200) { - _log.severe("Download for $name failed", res.toLoggerString()); - continue; + if (cancelCompleter != null && cancelCompleter.isCompleted) { + await _cleanupTempFiles(tempFiles); + return 0; } - await tempFile.writeAsBytes(res.bodyBytes); - downloadedXFiles.add(XFile(tempFile.path)); - tempFiles.add(tempFile); + if (statusUpdate.status == TaskStatus.complete) { + final filePath = await task.filePath(); + final file = File(filePath); + tempFiles.add(file); + downloadedXFiles.add(XFile(filePath)); + processedAssets++; + updateProgress(); + continue; + } + _log.severe("Download for ${asset.name} failed with status ${statusUpdate.status}", statusUpdate.exception); + processedAssets++; + updateProgress(); } } diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index c435bf9d79..bb2e6e08d2 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -232,8 +232,18 @@ class ActionService { await _assetApiRepository.unStack(stackIds); } - Future shareAssets(List assets, BuildContext context, {Completer? cancelCompleter}) { - return _assetMediaRepository.shareAssets(assets, context, cancelCompleter: cancelCompleter); + Future shareAssets( + List assets, + BuildContext context, { + Completer? cancelCompleter, + void Function(double progress)? onAssetDownloadProgress, + }) { + return _assetMediaRepository.shareAssets( + assets, + context, + cancelCompleter: cancelCompleter, + onAssetDownloadProgress: onAssetDownloadProgress, + ); } Future> downloadAll(List assets) {