Compare commits

...

2 Commits

Author SHA1 Message Date
Yaros
87dd274421 Merge branch 'main' into feat/share-downloading 2026-04-20 17:23:38 +02:00
Yaros
900febb517 feat(mobile): better downloading while sharing 2026-03-26 22:36:05 +01:00
4 changed files with 132 additions and 43 deletions

View File

@@ -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<double?> 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<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>();
const preparingDialog = _SharePreparingDialog();
final progress = ValueNotifier<double?>(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();
});
}

View File

@@ -425,11 +425,17 @@ class ActionNotifier extends Notifier<void> {
ActionSource source,
BuildContext context, {
Completer<void>? 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);

View File

@@ -1,26 +1,25 @@
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';
import 'package:immich_mobile/domain/models/asset/base_asset.model.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/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<bool> _androidSupportsTrash() async {
if (Platform.isAndroid) {
@@ -81,10 +80,29 @@ class AssetMediaRepository {
);
}
// TODO: make this more efficient
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context, {Completer<void>? cancelCompleter}) async {
Future<int> shareAssets(
List<BaseAsset> assets,
BuildContext context, {
Completer<void>? cancelCompleter,
void Function(double progress)? onAssetDownloadProgress,
}) async {
final downloadedXFiles = <XFile>[];
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) {
if (cancelCompleter != null && cancelCompleter.isCompleted) {
@@ -101,6 +119,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);
}
@@ -108,22 +128,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();
}
}

View File

@@ -232,8 +232,18 @@ class ActionService {
await _assetApiRepository.unStack(stackIds);
}
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context, {Completer<void>? cancelCompleter}) {
return _assetMediaRepository.shareAssets(assets, context, cancelCompleter: cancelCompleter);
Future<int> shareAssets(
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) {