mirror of
https://github.com/immich-app/immich.git
synced 2025-12-29 01:11:52 +03:00
expose iCloud retrieval progress
This commit is contained in:
@@ -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');
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user