mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
feat(mobile): better downloading while sharing
This commit is contained in:
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user