diff --git a/mobile/lib/infrastructure/repositories/storage.repository.dart b/mobile/lib/infrastructure/repositories/storage.repository.dart index 164fa04529..f32d86552e 100644 --- a/mobile/lib/infrastructure/repositories/storage.repository.dart +++ b/mobile/lib/infrastructure/repositories/storage.repository.dart @@ -81,6 +81,60 @@ class StorageRepository { return entity; } + /// Check if an asset is available locally or needs to be downloaded from iCloud + Future isAssetAvailableLocally(String assetId) async { + final log = Logger('StorageRepository'); + + try { + final entity = await AssetEntity.fromId(assetId); + if (entity == null) { + log.warning("Cannot get AssetEntity for asset $assetId"); + return false; + } + + return await entity.isLocallyAvailable(isOrigin: true); + } catch (error, stackTrace) { + log.warning("Error checking if asset is locally available $assetId", error, stackTrace); + return false; + } + } + + /// Load file from iCloud with progress handler (for iOS) + Future loadFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async { + final log = Logger('StorageRepository'); + + try { + final entity = await AssetEntity.fromId(assetId); + if (entity == null) { + log.warning("Cannot get AssetEntity for asset $assetId"); + return null; + } + + return await entity.loadFile(progressHandler: progressHandler); + } catch (error, stackTrace) { + log.warning("Error loading file from cloud for asset $assetId", error, stackTrace); + return null; + } + } + + /// Load live photo motion file from iCloud with progress handler (for iOS) + Future loadMotionFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async { + final log = Logger('StorageRepository'); + + try { + final entity = await AssetEntity.fromId(assetId); + if (entity == null) { + log.warning("Cannot get AssetEntity for asset $assetId"); + return null; + } + + return await entity.loadFile(withSubtype: true, progressHandler: progressHandler); + } catch (error, stackTrace) { + log.warning("Error loading motion file from cloud for asset $assetId", error, stackTrace); + return null; + } + } + Future clearCache() async { final log = Logger('StorageRepository'); diff --git a/mobile/lib/pages/backup/drift_upload_detail.page.dart b/mobile/lib/pages/backup/drift_upload_detail.page.dart index 80956b708f..1f0160dc1a 100644 --- a/mobile/lib/pages/backup/drift_upload_detail.page.dart +++ b/mobile/lib/pages/backup/drift_upload_detail.page.dart @@ -17,6 +17,7 @@ class DriftUploadDetailPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final uploadItems = ref.watch(driftBackupProvider.select((state) => state.uploadItems)); + final iCloudProgress = ref.watch(driftBackupProvider.select((state) => state.iCloudDownloadProgress)); return Scaffold( appBar: AppBar( @@ -25,7 +26,9 @@ class DriftUploadDetailPage extends ConsumerWidget { elevation: 0, scrolledUnderElevation: 1, ), - body: uploadItems.isEmpty ? _buildEmptyState(context) : _buildUploadList(uploadItems), + body: uploadItems.isEmpty && iCloudProgress.isEmpty + ? _buildEmptyState(context) + : _buildUploadList(uploadItems, iCloudProgress), ); } @@ -45,19 +48,110 @@ class DriftUploadDetailPage extends ConsumerWidget { ); } - Widget _buildUploadList(Map uploadItems) { + Widget _buildUploadList(Map uploadItems, Map iCloudProgress) { + final totalItems = uploadItems.length + iCloudProgress.length; + return ListView.separated( addAutomaticKeepAlives: true, padding: const EdgeInsets.all(16), - itemCount: uploadItems.length, + itemCount: totalItems, separatorBuilder: (context, index) => const SizedBox(height: 4), itemBuilder: (context, index) { - final item = uploadItems.values.elementAt(index); + // Show iCloud downloads first + if (index < iCloudProgress.length) { + final entry = iCloudProgress.entries.elementAt(index); + return _buildICloudDownloadCard(context, entry.key, entry.value); + } + + // Then show upload items + final uploadIndex = index - iCloudProgress.length; + final item = uploadItems.values.elementAt(uploadIndex); return _buildUploadCard(context, item); }, ); } + Widget _buildICloudDownloadCard(BuildContext context, String assetId, double progress) { + final double progressPercentage = (progress * 100).clamp(0, 100); + + return Card( + elevation: 0, + color: context.colorScheme.surfaceContainerHighest, + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(16)), + side: BorderSide(color: context.colorScheme.outline.withValues(alpha: 0.1), width: 1), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.cloud_download_rounded, size: 20, color: context.colorScheme.primary), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + Text( + "Downloading from iCloud", + style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + assetId, + style: context.textTheme.bodySmall?.copyWith( + color: context.colorScheme.onSurface.withValues(alpha: 0.6), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + _buildICloudProgressIndicator(context, progress, progressPercentage), + ], + ), + ], + ), + ), + ); + } + + Widget _buildICloudProgressIndicator(BuildContext context, double progress, double percentage) { + return Column( + children: [ + Stack( + alignment: AlignmentDirectional.center, + children: [ + SizedBox( + width: 36, + height: 36, + child: TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: progress), + duration: const Duration(milliseconds: 300), + builder: (context, value, _) => CircularProgressIndicator( + backgroundColor: context.colorScheme.outline.withValues(alpha: 0.2), + strokeWidth: 3, + value: value, + color: context.colorScheme.primary, + ), + ), + ), + Text( + percentage.toStringAsFixed(0), + style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.bold, fontSize: 10), + ), + ], + ), + Text("iCloud", style: context.textTheme.labelSmall?.copyWith(color: context.colorScheme.primary, fontSize: 10)), + ], + ); + } + Widget _buildUploadCard(BuildContext context, DriftUploadStatus item) { final isCompleted = item.progress >= 1.0; final double progressPercentage = (item.progress * 100).clamp(0, 100); diff --git a/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart b/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart index 8d374f74ff..ff9057c507 100644 --- a/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart +++ b/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart @@ -67,7 +67,9 @@ class BackupToggleButtonState extends ConsumerState with Sin final isSyncing = ref.watch(driftBackupProvider.select((state) => state.isSyncing)); - final isProcessing = uploadTasks.isNotEmpty || isSyncing; + final iCloudProgress = ref.watch(driftBackupProvider.select((state) => state.iCloudDownloadProgress)); + + final isProcessing = uploadTasks.isNotEmpty || isSyncing || iCloudProgress.isNotEmpty; return AnimatedBuilder( animation: _animationController, diff --git a/mobile/lib/providers/backup/drift_backup.provider.dart b/mobile/lib/providers/backup/drift_backup.provider.dart index 76dd96ec25..b4a6b33c60 100644 --- a/mobile/lib/providers/backup/drift_backup.provider.dart +++ b/mobile/lib/providers/backup/drift_backup.provider.dart @@ -117,6 +117,9 @@ class DriftBackupState { final Map uploadItems; final CancellationToken? cancelToken; + /// iCloud download progress for assets (assetId -> progress 0.0-1.0) + final Map iCloudDownloadProgress; + const DriftBackupState({ required this.totalCount, required this.backupCount, @@ -129,6 +132,7 @@ class DriftBackupState { this.error = BackupError.none, required this.uploadItems, this.cancelToken, + this.iCloudDownloadProgress = const {}, }); DriftBackupState copyWith({ @@ -143,6 +147,7 @@ class DriftBackupState { BackupError? error, Map? uploadItems, CancellationToken? cancelToken, + Map? iCloudDownloadProgress, }) { return DriftBackupState( totalCount: totalCount ?? this.totalCount, @@ -156,12 +161,13 @@ class DriftBackupState { error: error ?? this.error, uploadItems: uploadItems ?? this.uploadItems, cancelToken: cancelToken ?? this.cancelToken, + iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress, ); } @override String toString() { - return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, enqueueCount: $enqueueCount, enqueueTotalCount: $enqueueTotalCount, isSyncing: $isSyncing, isCanceling: $isCanceling, error: $error, uploadItems: $uploadItems, cancelToken: $cancelToken)'; + return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, enqueueCount: $enqueueCount, enqueueTotalCount: $enqueueTotalCount, isSyncing: $isSyncing, isCanceling: $isCanceling, error: $error, uploadItems: $uploadItems, cancelToken: $cancelToken, iCloudDownloadProgress: $iCloudDownloadProgress)'; } @override @@ -178,6 +184,7 @@ class DriftBackupState { other.isSyncing == isSyncing && other.isCanceling == isCanceling && other.error == error && + mapEquals(other.iCloudDownloadProgress, iCloudDownloadProgress) && mapEquals(other.uploadItems, uploadItems) && other.cancelToken == cancelToken; } @@ -194,7 +201,8 @@ class DriftBackupState { isCanceling.hashCode ^ error.hashCode ^ uploadItems.hashCode ^ - cancelToken.hashCode; + cancelToken.hashCode ^ + iCloudDownloadProgress.hashCode; } } @@ -370,12 +378,26 @@ class DriftBackupNotifier extends StateNotifier { _handleForegroundBackupProgress, _handleForegroundBackupSuccess, _handleForegroundBackupError, + onICloudProgress: _handleICloudProgress, ); } Future stopBackup() async { state.cancelToken?.cancel(); - state = state.copyWith(cancelToken: null, uploadItems: {}); + state = state.copyWith(cancelToken: null, uploadItems: {}, iCloudDownloadProgress: {}); + } + + void _handleICloudProgress(String localAssetId, double progress) { + state = state.copyWith(iCloudDownloadProgress: {...state.iCloudDownloadProgress, localAssetId: progress}); + + // Remove from progress map when download completes + if (progress >= 1.0) { + Future.delayed(const Duration(milliseconds: 500), () { + final updatedProgress = Map.from(state.iCloudDownloadProgress); + updatedProgress.remove(localAssetId); + state = state.copyWith(iCloudDownloadProgress: updatedProgress); + }); + } } void _handleForegroundBackupProgress(String localAssetId, int bytes, int totalBytes) { diff --git a/mobile/lib/repositories/upload.repository.dart b/mobile/lib/repositories/upload.repository.dart index c693adb592..a89e475318 100644 --- a/mobile/lib/repositories/upload.repository.dart +++ b/mobile/lib/repositories/upload.repository.dart @@ -222,10 +222,8 @@ class UploadRepository { if (![200, 201].contains(response.statusCode)) { final error = responseBody; final errorMessage = error['message'] ?? error['error']; - - logger.warning( - "Error(${error['statusCode']}) uploading $logContext | $originalFileName | ${error['error']}", - ); + + logger.warning("Error(${error['statusCode']}) uploading $logContext | $originalFileName | ${error['error']}"); return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage); } diff --git a/mobile/lib/services/upload.service.dart b/mobile/lib/services/upload.service.dart index de502dc018..8b9a4df647 100644 --- a/mobile/lib/services/upload.service.dart +++ b/mobile/lib/services/upload.service.dart @@ -23,6 +23,7 @@ import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/debug_print.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart' as p; +import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; final uploadServiceProvider = Provider((ref) { final service = UploadService( @@ -193,8 +194,9 @@ class UploadService { CancellationToken cancelToken, void Function(String localAssetId, int bytes, int totalBytes) onProgress, void Function(String localAssetId, String remoteAssetId) onSuccess, - void Function(String errorMessage) onError, - ) async { + void Function(String errorMessage) onError, { + void Function(String localAssetId, double progress)? onICloudProgress, + }) async { const concurrentUploads = 3; final httpClients = List.generate(concurrentUploads, (_) => Client()); @@ -230,6 +232,7 @@ class UploadService { (bytes, totalBytes) => onProgress(asset.localId!, bytes, totalBytes), onSuccess, onError, + onICloudProgress: onICloudProgress, ), ); } @@ -253,8 +256,9 @@ class UploadService { CancellationToken cancelToken, void Function(int bytes, int totalBytes) onProgress, void Function(String localAssetId, String remoteAssetId) onSuccess, - void Function(String errorMessage) onError, - ) async { + void Function(String errorMessage) onError, { + void Function(String localAssetId, double progress)? onICloudProgress, + }) async { File? file; File? livePhotoFile; @@ -264,17 +268,52 @@ class UploadService { return; } - file = await _storageRepository.getFileForAsset(asset.id); - if (file == null) { - return; + final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id); + + if (!isAvailableLocally && Platform.isIOS) { + _logger.info("Loading iCloud asset ${asset.id} - ${asset.name}"); + + // Create progress handler for iCloud download + PMProgressHandler? progressHandler; + StreamSubscription? progressSubscription; + + if (onICloudProgress != null) { + progressHandler = PMProgressHandler(); + progressSubscription = progressHandler.stream.listen((event) { + onICloudProgress(asset.localId!, event.progress); + }); + } + + try { + file = await _storageRepository.loadFileFromCloud(asset.id, progressHandler: progressHandler); + if (entity.isLivePhoto) { + livePhotoFile = await _storageRepository.loadMotionFileFromCloud( + asset.id, + progressHandler: progressHandler, + ); + } + } finally { + await progressSubscription?.cancel(); + } + } else { + // Get files locally + file = await _storageRepository.getFileForAsset(asset.id); + if (file == null) { + return; + } + + // For live photos, get the motion video file + if (entity.isLivePhoto) { + livePhotoFile = await _storageRepository.getMotionFileForAsset(asset); + if (livePhotoFile == null) { + _logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}"); + } + } } - // For live photos, get the motion video file - if (entity.isLivePhoto) { - livePhotoFile = await _storageRepository.getMotionFileForAsset(asset); - if (livePhotoFile == null) { - _logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}"); - } + if (file == null) { + _logger.warning("Failed to obtain file for asset ${asset.id} - ${asset.name}"); + return; } final originalFileName = entity.isLivePhoto ? p.setExtension(asset.name, p.extension(file.path)) : asset.name;