expose iCloud retrieval progress

This commit is contained in:
Alex
2025-10-23 10:20:16 -05:00
parent 21ced44159
commit 7f016bfcfa
6 changed files with 234 additions and 25 deletions

View File

@@ -81,6 +81,60 @@ class StorageRepository {
return entity;
}
/// Check if an asset is available locally or needs to be downloaded from iCloud
Future<bool> 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<File?> 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<File?> 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<void> clearCache() async {
final log = Logger('StorageRepository');

View File

@@ -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<String, DriftUploadStatus> uploadItems) {
Widget _buildUploadList(Map<String, DriftUploadStatus> uploadItems, Map<String, double> 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<double>(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);

View File

@@ -67,7 +67,9 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> 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,

View File

@@ -117,6 +117,9 @@ class DriftBackupState {
final Map<String, DriftUploadStatus> uploadItems;
final CancellationToken? cancelToken;
/// iCloud download progress for assets (assetId -> progress 0.0-1.0)
final Map<String, double> 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<String, DriftUploadStatus>? uploadItems,
CancellationToken? cancelToken,
Map<String, double>? 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<DriftBackupState> {
_handleForegroundBackupProgress,
_handleForegroundBackupSuccess,
_handleForegroundBackupError,
onICloudProgress: _handleICloudProgress,
);
}
Future<void> 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<String, double>.from(state.iCloudDownloadProgress);
updatedProgress.remove(localAssetId);
state = state.copyWith(iCloudDownloadProgress: updatedProgress);
});
}
}
void _handleForegroundBackupProgress(String localAssetId, int bytes, int totalBytes) {

View File

@@ -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);
}

View File

@@ -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;