From eff9b46b23c95f0c3690debf9d6b9523e085a260 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 12 Jan 2026 16:25:51 -0600 Subject: [PATCH] feat: manual upload progress --- i18n/en.json | 1 - .../upload_action_button.widget.dart | 77 +++++++++++++++++-- .../widgets/images/thumbnail_tile.widget.dart | 49 ++++++++++++ .../asset_upload_progress.provider.dart | 33 ++++++++ .../infrastructure/action.provider.dart | 39 ++++++++-- 5 files changed, 186 insertions(+), 13 deletions(-) create mode 100644 mobile/lib/providers/backup/asset_upload_progress.provider.dart diff --git a/i18n/en.json b/i18n/en.json index 521eac10b1..e7969dd9d8 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2195,7 +2195,6 @@ "updated_at": "Updated", "updated_password": "Updated password", "upload": "Upload", - "upload_action_prompt": "{count} queued for upload", "upload_concurrency": "Upload concurrency", "upload_details": "Upload Details", "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", diff --git a/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart index 98ef831f9c..71905839a9 100644 --- a/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart @@ -1,9 +1,14 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.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/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.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'; @@ -20,19 +25,38 @@ class UploadActionButton extends ConsumerWidget { return; } - final result = await ref.read(actionProvider.notifier).upload(source); + final isTimeline = source == ActionSource.timeline; + List? assets; - final successMessage = 'upload_action_prompt'.t(context: context, args: {'count': result.count.toString()}); + if (source == ActionSource.timeline) { + assets = ref.read(multiSelectProvider).selectedAssets.whereType().toList(); + if (assets.isEmpty) { + return; + } + ref.read(multiSelectProvider.notifier).reset(); + } else { + unawaited( + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => const _UploadProgressDialog(), + ), + ); + } - if (context.mounted) { + final result = await ref.read(actionProvider.notifier).upload(source, assets: assets); + + if (!isTimeline && context.mounted) { + Navigator.of(context, rootNavigator: true).pop(); + } + + if (context.mounted && !result.success) { ImmichToast.show( context: context, - msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), + msg: 'scaffold_body_error_occurred'.t(context: context), gravity: ToastGravity.BOTTOM, - toastType: result.success ? ToastType.success : ToastType.error, + toastType: ToastType.error, ); - - ref.read(multiSelectProvider.notifier).reset(); } } @@ -47,3 +71,42 @@ class UploadActionButton extends ConsumerWidget { ); } } + +class _UploadProgressDialog extends ConsumerWidget { + const _UploadProgressDialog(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final progressMap = ref.watch(assetUploadProgressProvider); + + // Calculate overall progress from all assets + final values = progressMap.values.where((v) => v >= 0).toList(); + final progress = values.isEmpty ? 0.0 : values.reduce((a, b) => a + b) / values.length; + final hasError = progressMap.values.any((v) => v < 0); + final percentage = (progress * 100).toInt(); + + return AlertDialog( + title: Text('uploading'.t(context: context)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (hasError) + const Icon(Icons.error_outline, color: Colors.red, size: 48) + else + CircularProgressIndicator(value: progress > 0 ? progress : null), + const SizedBox(height: 16), + Text(hasError ? 'Error' : '$percentage%'), + ], + ), + actions: [ + TextButton( + onPressed: () { + ref.read(manualUploadCancelTokenProvider)?.cancel(); + Navigator.of(context).pop(); + }, + child: Text('cancel'.t(context: context)), + ), + ], + ); + } +} diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index c7628cb472..b33cf26304 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; +import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; @@ -45,6 +46,10 @@ class ThumbnailTile extends ConsumerWidget { final bool storageIndicator = ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator))) && showStorageIndicator; + final uploadProgress = asset is LocalAsset + ? ref.watch(assetUploadProgressProvider.select((map) => map[asset.id])) + : null; + return Stack( children: [ Container(color: lockSelection ? context.colorScheme.surfaceContainerHighest : assetContainerColor), @@ -104,6 +109,7 @@ class ThumbnailTile extends ConsumerWidget { child: _TileOverlayIcon(Icons.favorite_rounded), ), ), + if (uploadProgress != null) _UploadProgressOverlay(progress: uploadProgress), ], ), ), @@ -229,3 +235,46 @@ class _AssetTypeIcons extends StatelessWidget { ); } } + +class _UploadProgressOverlay extends StatelessWidget { + final double progress; + + const _UploadProgressOverlay({required this.progress}); + + @override + Widget build(BuildContext context) { + final isError = progress < 0; + final percentage = isError ? 0 : (progress * 100).toInt(); + + return Positioned.fill( + child: Container( + color: isError ? Colors.red.withValues(alpha: 0.6) : Colors.black54, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (isError) + const Icon(Icons.error_outline, color: Colors.white, size: 36) + else + SizedBox( + width: 36, + height: 36, + child: CircularProgressIndicator( + value: progress, + strokeWidth: 3, + backgroundColor: Colors.white24, + valueColor: const AlwaysStoppedAnimation(Colors.white), + ), + ), + const SizedBox(height: 4), + Text( + isError ? 'Error' : '$percentage%', + style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/providers/backup/asset_upload_progress.provider.dart b/mobile/lib/providers/backup/asset_upload_progress.provider.dart new file mode 100644 index 0000000000..e8aba430da --- /dev/null +++ b/mobile/lib/providers/backup/asset_upload_progress.provider.dart @@ -0,0 +1,33 @@ +import 'package:cancellation_token_http/http.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +/// Tracks per-asset upload progress. +/// Key: local asset ID, Value: upload progress 0.0 to 1.0, or -1.0 for error +class AssetUploadProgressNotifier extends Notifier> { + static const double errorValue = -1.0; + + @override + Map build() => {}; + + void setProgress(String localAssetId, double progress) { + state = {...state, localAssetId: progress}; + } + + void setError(String localAssetId) { + state = {...state, localAssetId: errorValue}; + } + + void remove(String localAssetId) { + state = Map.from(state)..remove(localAssetId); + } + + void clear() { + state = {}; + } +} + +final assetUploadProgressProvider = NotifierProvider>( + AssetUploadProgressNotifier.new, +); + +final manualUploadCancelTokenProvider = StateProvider((ref) => null); diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 7d4f6b1d87..5c7427a277 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -14,6 +14,7 @@ import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asse import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/services/action.service.dart'; import 'package:immich_mobile/services/download.service.dart'; import 'package:immich_mobile/services/timeline.service.dart'; @@ -412,14 +413,42 @@ class ActionNotifier extends Notifier { } } - Future upload(ActionSource source) async { - final assets = _getAssets(source).whereType().toList(); + Future upload(ActionSource source, {List? assets}) async { + final assetsToUpload = assets ?? _getAssets(source).whereType().toList(); + + final progressNotifier = ref.read(assetUploadProgressProvider.notifier); + final cancelToken = CancellationToken(); + ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken; + + // Initialize progress for all assets + for (final asset in assetsToUpload) { + progressNotifier.setProgress(asset.id, 0.0); + } + try { - await _uploadService.manualBackup(assets, CancellationToken()); - return ActionResult(count: assets.length, success: true); + await _uploadService.manualBackup( + assetsToUpload, + cancelToken, + onProgress: (localAssetId, filename, bytes, totalBytes) { + final progress = totalBytes > 0 ? bytes / totalBytes : 0.0; + progressNotifier.setProgress(localAssetId, progress); + }, + onSuccess: (localAssetId, remoteAssetId) { + progressNotifier.remove(localAssetId); + }, + onError: (localAssetId, errorMessage) { + progressNotifier.setError(localAssetId); + }, + ); + return ActionResult(count: assetsToUpload.length, success: true); } catch (error, stack) { _logger.severe('Failed manually upload assets', error, stack); - return ActionResult(count: assets.length, success: false, error: error.toString()); + return ActionResult(count: assetsToUpload.length, success: false, error: error.toString()); + } finally { + ref.read(manualUploadCancelTokenProvider.notifier).state = null; + Future.delayed(const Duration(seconds: 2), () { + progressNotifier.clear(); + }); } } }