feat(mobile): better downloading while sharing

This commit is contained in:
Yaros
2026-03-26 22:36:05 +01:00
parent e2d26ebdea
commit 900febb517
4 changed files with 132 additions and 43 deletions
@@ -14,7 +14,9 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
class _SharePreparingDialog extends StatelessWidget { class _SharePreparingDialog extends StatelessWidget {
const _SharePreparingDialog(); final ValueNotifier<double?> progress;
const _SharePreparingDialog({required this.progress});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -22,8 +24,24 @@ class _SharePreparingDialog extends StatelessWidget {
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const CircularProgressIndicator(), Container(margin: const EdgeInsets.only(bottom: 12), child: const Text('share_dialog_preparing').tr()),
Container(margin: const EdgeInsets.only(top: 12), child: const Text('share_dialog_preparing').tr()), SizedBox(
width: 240,
child: ValueListenableBuilder<double?>(
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<void>(); final cancelCompleter = Completer<void>();
const preparingDialog = _SharePreparingDialog(); final progress = ValueNotifier<double?>(null);
final preparingDialog = _SharePreparingDialog(progress: progress);
await showDialog( await showDialog(
context: context, context: context,
builder: (BuildContext buildContext) { builder: (BuildContext buildContext) {
ref.read(actionProvider.notifier).shareAssets(source, context, cancelCompleter: cancelCompleter).then(( ref
ActionResult result, .read(actionProvider.notifier)
) { .shareAssets(
if (cancelCompleter.isCompleted || !context.mounted) { source,
return; 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) { if (!result.success) {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: 'scaffold_body_error_occurred'.t(context: context), msg: 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM, gravity: ToastGravity.BOTTOM,
toastType: ToastType.error, toastType: ToastType.error,
); );
} }
buildContext.pop(); buildContext.pop();
}); });
// show a loading spinner with a "Preparing" message // Show download progress with a "Preparing" message
return preparingDialog; return preparingDialog;
}, },
barrierDismissible: false, barrierDismissible: false,
@@ -77,6 +102,7 @@ class ShareActionButton extends ConsumerWidget {
if (!cancelCompleter.isCompleted) { if (!cancelCompleter.isCompleted) {
cancelCompleter.complete(); cancelCompleter.complete();
} }
progress.dispose();
}); });
} }
@@ -426,11 +426,17 @@ class ActionNotifier extends Notifier<void> {
ActionSource source, ActionSource source,
BuildContext context, { BuildContext context, {
Completer<void>? cancelCompleter, Completer<void>? cancelCompleter,
void Function(double progress)? onAssetDownloadProgress,
}) async { }) async {
final ids = _getAssets(source).toList(growable: false); final ids = _getAssets(source).toList(growable: false);
try { try {
await _service.shareAssets(ids, context, cancelCompleter: cancelCompleter); await _service.shareAssets(
ids,
context,
cancelCompleter: cancelCompleter,
onAssetDownloadProgress: onAssetDownloadProgress,
);
return ActionResult(count: ids.length, success: true); return ActionResult(count: ids.length, success: true);
} catch (error, stack) { } catch (error, stack) {
_logger.severe('Failed to share assets', error, stack); _logger.severe('Failed to share assets', error, stack);
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/response_extensions.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/utils/hash.dart'; import 'package:immich_mobile/utils/hash.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider))); final assetMediaRepositoryProvider = Provider((ref) => const AssetMediaRepository());
class AssetMediaRepository { class AssetMediaRepository {
final AssetApiRepository _assetApiRepository;
static final Logger _log = Logger("AssetMediaRepository"); static final Logger _log = Logger("AssetMediaRepository");
const AssetMediaRepository(this._assetApiRepository); const AssetMediaRepository();
Future<bool> _androidSupportsTrash() async { Future<bool> _androidSupportsTrash() async {
if (Platform.isAndroid) { if (Platform.isAndroid) {
@@ -116,10 +115,29 @@ class AssetMediaRepository {
); );
} }
// TODO: make this more efficient Future<int> shareAssets(
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context, {Completer<void>? cancelCompleter}) async { List<BaseAsset> assets,
BuildContext context, {
Completer<void>? cancelCompleter,
void Function(double progress)? onAssetDownloadProgress,
}) async {
final downloadedXFiles = <XFile>[]; final downloadedXFiles = <XFile>[];
final tempFiles = <File>[]; final tempFiles = <File>[];
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) { for (var asset in assets) {
if (cancelCompleter != null && cancelCompleter.isCompleted) { if (cancelCompleter != null && cancelCompleter.isCompleted) {
@@ -136,6 +154,8 @@ class AssetMediaRepository {
if (localId != null && !asset.isEdited) { if (localId != null && !asset.isEdited) {
File? f = await AssetEntity(id: localId, width: 1, height: 1, typeInt: 0).originFile; File? f = await AssetEntity(id: localId, width: 1, height: 1, typeInt: 0).originFile;
downloadedXFiles.add(XFile(f!.path)); downloadedXFiles.add(XFile(f!.path));
processedAssets++;
updateProgress();
if (CurrentPlatform.isIOS) { if (CurrentPlatform.isIOS) {
tempFiles.add(f); tempFiles.add(f);
} }
@@ -143,22 +163,49 @@ class AssetMediaRepository {
final remoteId = (asset is RemoteAsset) ? asset.id : asset.remoteId; final remoteId = (asset is RemoteAsset) ? asset.id : asset.remoteId;
if (remoteId == null) { if (remoteId == null) {
_log.warning("Asset has no remote ID for sharing: $asset"); _log.warning("Asset has no remote ID for sharing: $asset");
processedAssets++;
updateProgress();
continue; continue;
} }
final tempDir = await getTemporaryDirectory(); final taskId = 'share-$remoteId-${DateTime.now().microsecondsSinceEpoch}';
final name = asset.name; final sanitizedFilename = asset.name.replaceAll(RegExp(r'[\\/]'), '_');
final tempFile = await File('${tempDir.path}/$name').create(); final task = DownloadTask(
final res = await _assetApiRepository.downloadAsset(remoteId, edited: true); 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) { if (cancelCompleter != null && cancelCompleter.isCompleted) {
_log.severe("Download for $name failed", res.toLoggerString()); await _cleanupTempFiles(tempFiles);
continue; return 0;
} }
await tempFile.writeAsBytes(res.bodyBytes); if (statusUpdate.status == TaskStatus.complete) {
downloadedXFiles.add(XFile(tempFile.path)); final filePath = await task.filePath();
tempFiles.add(tempFile); 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();
} }
} }
+12 -2
View File
@@ -232,8 +232,18 @@ class ActionService {
await _assetApiRepository.unStack(stackIds); await _assetApiRepository.unStack(stackIds);
} }
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context, {Completer<void>? cancelCompleter}) { Future<int> shareAssets(
return _assetMediaRepository.shareAssets(assets, context, cancelCompleter: cancelCompleter); List<BaseAsset> assets,
BuildContext context, {
Completer<void>? cancelCompleter,
void Function(double progress)? onAssetDownloadProgress,
}) {
return _assetMediaRepository.shareAssets(
assets,
context,
cancelCompleter: cancelCompleter,
onAssetDownloadProgress: onAssetDownloadProgress,
);
} }
Future<List<bool>> downloadAll(List<RemoteAsset> assets) { Future<List<bool>> downloadAll(List<RemoteAsset> assets) {