diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart index e873a7631f..35a8f899a8 100644 --- a/mobile/lib/domain/services/remote_album.service.dart +++ b/mobile/lib/domain/services/remote_album.service.dart @@ -192,43 +192,30 @@ class RemoteAlbumService { required UserDto uploader, required AlbumAssetCandidates candidates, UploadCallbacks uploadCallbacks = const UploadCallbacks(), + Completer? cancelToken, }) async { int addedCount = 0; if (candidates.remoteAssetIds.isNotEmpty) { addedCount += await addAssets(albumId: albumId, assetIds: candidates.remoteAssetIds); } if (candidates.localAssetsToUpload.isNotEmpty) { - addedCount += await _uploadAndAddLocals(albumId, uploader, candidates.localAssetsToUpload, uploadCallbacks); + addedCount += await _uploadAndAddLocals( + albumId, + uploader, + candidates.localAssetsToUpload, + uploadCallbacks, + cancelToken, + ); } return addedCount; } - /// Creates an album, seeding it with already-remote asset IDs, then uploads - /// local-only assets and links each one as it finishes. - Future createAlbumWithAssets({ - required String title, - required UserDto owner, - String? description, - AlbumAssetCandidates candidates = const AlbumAssetCandidates(remoteAssetIds: [], localAssetsToUpload: []), - UploadCallbacks uploadCallbacks = const UploadCallbacks(), - }) async { - final album = await createAlbum( - title: title, - owner: owner, - description: description, - assetIds: candidates.remoteAssetIds, - ); - if (candidates.localAssetsToUpload.isNotEmpty) { - await _uploadAndAddLocals(album.id, owner, candidates.localAssetsToUpload, uploadCallbacks); - } - return album; - } - Future _uploadAndAddLocals( String albumId, UserDto uploader, List localAssets, UploadCallbacks userCallbacks, + Completer? cancelToken, ) async { int addedCount = 0; final pendingAdds = >[]; @@ -258,7 +245,7 @@ class RemoteAlbumService { return; } pendingAdds.add( - _linkUploadedAssetToAlbum(albumId, remoteId, uploader, source) + linkUploadedAssetToAlbum(albumId, remoteId, uploader, source) .then((added) { addedCount += added; }) @@ -269,7 +256,7 @@ class RemoteAlbumService { }, ); - await _uploadService.uploadManual(localAssets, callbacks: wrappedCallbacks); + await _uploadService.uploadManual(localAssets, callbacks: wrappedCallbacks, cancelToken: cancelToken); await Future.wait(pendingAdds); return addedCount; } @@ -288,7 +275,7 @@ class RemoteAlbumService { /// `remote_asset_entity` row from the local source so the FK-protected /// junction insert succeeds. Sync overwrites the placeholder later with /// the authoritative server data. - Future _linkUploadedAssetToAlbum(String albumId, String remoteId, UserDto uploader, LocalAsset source) async { + Future linkUploadedAssetToAlbum(String albumId, String remoteId, UserDto uploader, LocalAsset source) async { final result = await _albumApiRepository.addAssets(albumId, [remoteId]); if (result.added.isEmpty) { return 0; diff --git a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart index ecfe4a60fe..1ab3f2039d 100644 --- a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart @@ -6,6 +6,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -142,13 +143,18 @@ class _AddActionButtonState extends ConsumerState { return; } - final addedCount = await ref.read(remoteAlbumProvider.notifier).addAssets(album.id, [latest.remoteId!]); + final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.viewer, album); if (!context.mounted) { return; } - if (addedCount == 0) { + if (!result.success) { + ImmichToast.show(context: context, msg: 'scaffold_body_error_occurred'.tr(), toastType: ToastType.error); + return; + } + + if (result.count == 0) { ImmichToast.show( context: context, msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name}), @@ -159,7 +165,7 @@ class _AddActionButtonState extends ConsumerState { msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}), ); - // Invalidate using the asset's remote ID to refresh the "Appears in" list + // Refresh the "Appears in" list on the asset's info panel. ref.invalidate(albumsContainingAssetProvider(latest.remoteId!)); } diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart index 981fe2b1f8..5a174bfc5e 100644 --- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart +++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart @@ -7,7 +7,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.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/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; @@ -746,12 +745,10 @@ class AddToAlbumHeader extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { Future onCreateAlbum() async { + final selectedAssets = ref.read(multiSelectProvider).selectedAssets; final newAlbum = await ref .read(remoteAlbumProvider.notifier) - .createAlbum( - title: "Untitled Album", - assetIds: ref.read(multiSelectProvider).selectedAssets.map((e) => (e as RemoteAsset).id).toList(), - ); + .createAlbumWithAssets(title: "Untitled Album", assets: selectedAssets); if (newAlbum == null) { ImmichToast.show(context: context, toastType: ToastType.error, msg: 'errors.failed_to_create_album'.tr()); diff --git a/mobile/lib/presentation/widgets/album/pending_uploads_banner.widget.dart b/mobile/lib/presentation/widgets/album/pending_uploads_banner.widget.dart index 397170ec54..2701316e75 100644 --- a/mobile/lib/presentation/widgets/album/pending_uploads_banner.widget.dart +++ b/mobile/lib/presentation/widgets/album/pending_uploads_banner.widget.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/album/pending_album_uploads.provider.dart'; +import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; /// Pinned banner sliver that surfaces in-flight album uploads directly under /// the album app bar. Renders nothing while the queue is empty. Tapping the @@ -165,6 +166,8 @@ class _PendingUploadsSheet extends ConsumerWidget { } final failedCount = pending.where((p) => p.failed).length; + final inFlightCount = pending.length - failedCount; + final canAbort = inFlightCount > 0 && ref.watch(manualUploadCancelTokenProvider) != null; return SafeArea( child: Padding( @@ -183,7 +186,21 @@ class _PendingUploadsSheet extends ConsumerWidget { style: context.textTheme.titleMedium, ), ), - if (failedCount > 0) + if (canAbort) + TextButton.icon( + onPressed: () { + final cancelToken = ref.read(manualUploadCancelTokenProvider); + if (cancelToken != null && !cancelToken.isCompleted) { + cancelToken.complete(); + } + ref.read(manualUploadCancelTokenProvider.notifier).state = null; + ref.read(pendingAlbumUploadsProvider(albumId).notifier).clear(); + }, + icon: const Icon(Icons.stop_circle_outlined, size: 18), + label: Text('cancel'.t(context: context)), + style: TextButton.styleFrom(foregroundColor: context.colorScheme.error), + ) + else if (failedCount > 0) TextButton.icon( onPressed: () => ref.read(pendingAlbumUploadsProvider(albumId).notifier).clearFailed(), icon: const Icon(Icons.clear_rounded, size: 18), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart index 0bafacfe54..c3a569407a 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart @@ -3,9 +3,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart'; @@ -25,7 +23,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; @@ -63,37 +61,23 @@ class _GeneralBottomSheetState extends ConsumerState { userMetadataPreferencesProvider.select((value) => value.valueOrNull?.tagsEnabled ?? false), ); - Future addAssetsToAlbum(RemoteAlbum album) async { - final selectedAssets = multiselect.selectedAssets; - if (selectedAssets.isEmpty) { + Future addToAlbum(RemoteAlbum album) async { + final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album); + + if (!context.mounted) { return; } - final remoteAssets = selectedAssets.whereType(); - final addedCount = await ref - .read(remoteAlbumProvider.notifier) - .addAssets(album.id, remoteAssets.map((e) => e.id).toList()); - - if (selectedAssets.length != remoteAssets.length) { - ImmichToast.show( - context: context, - msg: 'add_to_album_bottom_sheet_some_local_assets'.t(context: context), - ); + if (!result.success) { + ImmichToast.show(context: context, msg: 'scaffold_body_error_occurred'.tr(), toastType: ToastType.error); + return; } - - if (addedCount != remoteAssets.length) { - ImmichToast.show( - context: context, - msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {"album": album.name}), - ); - } else { - ImmichToast.show( - context: context, - msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {"album": album.name}), - ); - } - - ref.read(multiSelectProvider.notifier).reset(); + ImmichToast.show( + context: context, + msg: result.count == 0 + ? 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name}) + : 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}), + ); } Future onKeyboardExpand() { @@ -131,12 +115,10 @@ class _GeneralBottomSheetState extends ConsumerState { const DeleteLocalActionButton(source: ActionSource.timeline), if (multiselect.onlyLocal) const UploadActionButton(source: ActionSource.timeline), ], - slivers: multiselect.hasRemote - ? [ - const AddToAlbumHeader(), - AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand), - ] - : [], + slivers: [ + const AddToAlbumHeader(), + AlbumSelector(onAlbumSelected: addToAlbum, onKeyboardExpanded: onKeyboardExpand), + ], ); } } diff --git a/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart index b1e87dfaea..ac8c77af03 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart @@ -1,25 +1,78 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; -class LocalAlbumBottomSheet extends ConsumerWidget { +class LocalAlbumBottomSheet extends ConsumerStatefulWidget { const LocalAlbumBottomSheet({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - return const BaseBottomSheet( + ConsumerState createState() => _LocalAlbumBottomSheetState(); +} + +class _LocalAlbumBottomSheetState extends ConsumerState { + late final DraggableScrollableController sheetController; + + @override + void initState() { + super.initState(); + sheetController = DraggableScrollableController(); + } + + @override + void dispose() { + sheetController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Future addToAlbum(RemoteAlbum album) async { + final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album); + + if (!context.mounted) { + return; + } + + if (!result.success) { + ImmichToast.show(context: context, msg: 'scaffold_body_error_occurred'.tr(), toastType: ToastType.error); + return; + } + + ImmichToast.show( + context: context, + msg: result.count == 0 + ? 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name}) + : 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}), + ); + } + + Future onKeyboardExpand() { + return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut); + } + + return BaseBottomSheet( + controller: sheetController, initialChildSize: 0.25, - maxChildSize: 0.4, + maxChildSize: 0.85, shouldCloseOnMinExtent: false, - actions: [ + actions: const [ ShareActionButton(source: ActionSource.timeline), DeleteLocalActionButton(source: ActionSource.timeline), UploadActionButton(source: ActionSource.timeline), ], + slivers: [ + const AddToAlbumHeader(), + AlbumSelector(onAlbumSelected: addToAlbum, onKeyboardExpanded: onKeyboardExpand), + ], ); } } diff --git a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart index 6848a07bb8..6b914ed077 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; @@ -21,7 +20,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_b import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -56,29 +55,28 @@ class _RemoteAlbumBottomSheetState extends ConsumerState final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); final ownsAlbum = ref.watch(currentUserProvider)?.id == widget.album.ownerId; - Future addAssetsToAlbum(RemoteAlbum album) async { - final selectedAssets = multiselect.selectedAssets; - if (selectedAssets.isEmpty) { + Future addToAlbum(RemoteAlbum album) async { + final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album); + + if (!context.mounted) { return; } - final addedCount = await ref - .read(remoteAlbumProvider.notifier) - .addAssets(album.id, selectedAssets.map((e) => (e as RemoteAsset).id).toList()); - - if (addedCount != selectedAssets.length) { + if (!result.success) { ImmichToast.show( context: context, - msg: 'add_to_album_bottom_sheet_already_exists'.t(context: context, args: {"album": album.name}), - ); - } else { - ImmichToast.show( - context: context, - msg: 'add_to_album_bottom_sheet_added'.t(context: context, args: {"album": album.name}), + msg: 'scaffold_body_error_occurred'.t(context: context), + toastType: ToastType.error, ); + return; } - ref.read(multiSelectProvider.notifier).reset(); + ImmichToast.show( + context: context, + msg: result.count == 0 + ? 'add_to_album_bottom_sheet_already_exists'.t(context: context, args: {"album": album.name}) + : 'add_to_album_bottom_sheet_added'.t(context: context, args: {"album": album.name}), + ); } Future onKeyboardExpand() { @@ -118,10 +116,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState SetAlbumCoverActionButton(source: ActionSource.timeline, albumId: widget.album.id), ], slivers: ownsAlbum - ? [ - const AddToAlbumHeader(), - AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand), - ] + ? [const AddToAlbumHeader(), AlbumSelector(onAlbumSelected: addToAlbum, onKeyboardExpanded: onKeyboardExpand)] : null, ); } diff --git a/mobile/lib/providers/album/pending_album_uploads.provider.dart b/mobile/lib/providers/album/pending_album_uploads.provider.dart index db857ba3c0..7b1188891b 100644 --- a/mobile/lib/providers/album/pending_album_uploads.provider.dart +++ b/mobile/lib/providers/album/pending_album_uploads.provider.dart @@ -67,6 +67,11 @@ class AlbumPendingUploadsNotifier extends AutoDisposeFamilyNotifier { } } + Future addToAlbum(ActionSource source, RemoteAlbum album) async { + final selected = _getAssets(source).toList(growable: false); + if (selected.isEmpty) { + return const ActionResult(count: 0, success: true); + } + + final candidates = RemoteAlbumService.categorizeCandidates(selected); + final remoteIds = candidates.remoteAssetIds; + final localAssets = candidates.localAssetsToUpload; + final albumNotifier = ref.read(remoteAlbumProvider.notifier); + + int addedRemote = 0; + if (remoteIds.isNotEmpty) { + try { + addedRemote = await albumNotifier.addAssets(album.id, remoteIds); + } catch (error, stack) { + _logger.severe('Failed to add assets to album ${album.id}', error, stack); + return ActionResult(count: 0, success: false, error: error.toString()); + } + } + + // Keep the selection available for retry if the remote add fails. Once the + // album mutation succeeds, clear timeline selection so upload overlays can render. + if (source == ActionSource.timeline) { + ref.read(multiSelectProvider.notifier).reset(); + } + + if (localAssets.isEmpty) { + return ActionResult(count: addedRemote, success: true); + } + + final uploadResult = await upload( + source, + assets: localAssets, + onAssetUploaded: (asset, remoteId) async { + await albumNotifier.linkUploadedAssetToAlbum(album.id, asset, remoteId); + }, + ); + + return ActionResult( + count: addedRemote + uploadResult.count, + success: uploadResult.success, + error: uploadResult.error, + ); + } + Future removeFromAlbum(ActionSource source, String albumId) async { final ids = _getRemoteIdsForSource(source); try { @@ -495,8 +544,16 @@ class ActionNotifier extends Notifier { } } - Future upload(ActionSource source, {List? assets}) async { + Future upload( + ActionSource source, { + List? assets, + FutureOr Function(LocalAsset asset, String remoteId)? onAssetUploaded, + }) async { final assetsToUpload = assets ?? _getAssets(source).whereType().toList(); + final assetById = {for (final a in assetsToUpload) a.id: a}; + final uploadedAssetIds = {}; + final failedAssetIds = {}; + final postUploadTasks = >[]; final progressNotifier = ref.read(assetUploadProgressProvider.notifier); final cancelToken = Completer(); @@ -518,16 +575,43 @@ class ActionNotifier extends Notifier { }, onSuccess: (localAssetId, remoteAssetId) { progressNotifier.remove(localAssetId); + uploadedAssetIds.add(localAssetId); + final asset = assetById[localAssetId]; + final callback = onAssetUploaded; + if (asset != null && callback != null) { + postUploadTasks.add( + Future.sync(() => callback(asset, remoteAssetId)).catchError((Object error, StackTrace stack) { + failedAssetIds.add(localAssetId); + progressNotifier.setError(localAssetId); + _logger.warning('Post-upload callback failed for $localAssetId', error, stack); + }), + ); + } }, onError: (localAssetId, errorMessage) { + failedAssetIds.add(localAssetId); progressNotifier.setError(localAssetId); }, ), ); - return ActionResult(count: assetsToUpload.length, success: true); + + await Future.wait(postUploadTasks); + final successCount = uploadedAssetIds.difference(failedAssetIds).length; + final isSuccess = successCount == assetsToUpload.length && failedAssetIds.isEmpty; + + return ActionResult( + count: successCount, + success: isSuccess, + error: isSuccess ? null : 'Failed to upload ${assetsToUpload.length - successCount} assets', + ); } catch (error, stack) { _logger.severe('Failed manually upload assets', error, stack); - return ActionResult(count: assetsToUpload.length, success: false, error: error.toString()); + + return ActionResult( + count: uploadedAssetIds.difference(failedAssetIds).length, + success: false, + error: error.toString(), + ); } finally { ref.read(manualUploadCancelTokenProvider.notifier).state = null; Future.delayed(const Duration(seconds: 2), () { diff --git a/mobile/lib/providers/infrastructure/remote_album.provider.dart b/mobile/lib/providers/infrastructure/remote_album.provider.dart index 73a796bd31..a4bbbae818 100644 --- a/mobile/lib/providers/infrastructure/remote_album.provider.dart +++ b/mobile/lib/providers/infrastructure/remote_album.provider.dart @@ -9,6 +9,7 @@ import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:immich_mobile/providers/album/pending_album_uploads.provider.dart'; +import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; @@ -207,6 +208,22 @@ class RemoteAlbumNotifier extends Notifier { return added; } + /// Links a freshly-uploaded local asset to an album using its new remote ID, + /// upserting a placeholder remote asset row so the local DB join survives + /// until the next sync catches up. + Future linkUploadedAssetToAlbum(String albumId, LocalAsset source, String remoteId) async { + final currentUser = ref.read(currentUserProvider); + if (currentUser == null) { + throw Exception('User not logged in'); + } + + final added = await _remoteAlbumService.linkUploadedAssetToAlbum(albumId, remoteId, currentUser, source); + if (added > 0) { + await _refreshAlbumInState(albumId); + } + return added; + } + /// Adds a heterogeneous asset selection to an album. Already-remote assets /// are linked immediately; local-only assets are queued in /// [pendingAlbumUploadsProvider] (so the album page can show them with @@ -221,11 +238,18 @@ class RemoteAlbumNotifier extends Notifier { final pendingNotifier = ref.read(pendingAlbumUploadsProvider(albumId).notifier); pendingNotifier.enqueue(candidates.localAssetsToUpload); + Completer? cancelToken; + if (candidates.localAssetsToUpload.isNotEmpty) { + cancelToken = Completer(); + ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken; + } + try { final added = await _remoteAlbumService.addAssetsToAlbum( albumId: albumId, uploader: currentUser, candidates: candidates, + cancelToken: cancelToken, uploadCallbacks: UploadCallbacks( onProgress: (localAssetId, _, bytes, totalBytes) { final progress = totalBytes > 0 ? bytes / totalBytes : 0.0; @@ -245,6 +269,15 @@ class RemoteAlbumNotifier extends Notifier { } _logger.severe('Failed to add assets to album $albumId', error, stack); rethrow; + } finally { + if (cancelToken != null) { + if (cancelToken.isCompleted) { + pendingNotifier.clear(); + } + if (ref.read(manualUploadCancelTokenProvider) == cancelToken) { + ref.read(manualUploadCancelTokenProvider.notifier).state = null; + } + } } }