diff --git a/i18n/en.json b/i18n/en.json index 3674d47e17..cb467cc819 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -778,6 +778,7 @@ "clear": "Clear", "clear_all": "Clear all", "clear_all_recent_searches": "Clear all recent searches", + "clear_failed_count": "Clear failed ({count})", "clear_file_cache": "Clear File Cache", "clear_message": "Clear message", "clear_value": "Clear value", diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart index d0af52dcfd..e873a7631f 100644 --- a/mobile/lib/domain/services/remote_album.service.dart +++ b/mobile/lib/domain/services/remote_album.service.dart @@ -9,12 +9,47 @@ import 'package:immich_mobile/infrastructure/repositories/remote_album.repositor 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/repositories/drift_album_api_repository.dart'; +import 'package:immich_mobile/services/foreground_upload.service.dart'; +import 'package:logging/logging.dart'; + +/// Categorizes a heterogeneous asset selection into the candidates that can +/// be added to an album immediately (already on the server) and the local-only +/// candidates that must be uploaded first. +class AlbumAssetCandidates { + final List remoteAssetIds; + final List localAssetsToUpload; + + const AlbumAssetCandidates({required this.remoteAssetIds, required this.localAssetsToUpload}); +} class RemoteAlbumService { + static final _logger = Logger('RemoteAlbumService'); + final DriftRemoteAlbumRepository _repository; final DriftAlbumApiRepository _albumApiRepository; + final ForegroundUploadService _uploadService; - const RemoteAlbumService(this._repository, this._albumApiRepository); + const RemoteAlbumService(this._repository, this._albumApiRepository, this._uploadService); + + /// Categorizes a heterogeneous asset selection into already-on-server IDs + /// and local assets that still need to be uploaded. + static AlbumAssetCandidates categorizeCandidates(Iterable assets) { + final remoteIds = []; + final localToUpload = []; + for (final asset in assets) { + if (asset is RemoteAsset) { + remoteIds.add(asset.id); + } else if (asset is LocalAsset) { + final remoteId = asset.remoteId; + if (remoteId != null) { + remoteIds.add(remoteId); + } else { + localToUpload.add(asset); + } + } + } + return AlbumAssetCandidates(remoteAssetIds: remoteIds, localAssetsToUpload: localToUpload); + } Stream watchAlbum(String albumId) { return _repository.watchAlbum(albumId); @@ -148,6 +183,122 @@ class RemoteAlbumService { return album.added.length; } + /// !TODO The name here is not clear as we have addAssets method above, + /// which is only add remote assets to album, for the next PR, we will allow + /// adding local assets from album from the timeline as well with this flow. + /// So saving that for the next refactor + Future addAssetsToAlbum({ + required String albumId, + required UserDto uploader, + required AlbumAssetCandidates candidates, + UploadCallbacks uploadCallbacks = const UploadCallbacks(), + }) 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); + } + 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, + ) async { + int addedCount = 0; + final pendingAdds = >[]; + final localById = {for (final a in localAssets) a.id: a}; + + final wrappedCallbacks = UploadCallbacks( + onProgress: (localId, filename, bytes, totalBytes) => _runUploadCallback( + 'Upload progress callback failed for $localId', + () => userCallbacks.onProgress?.call(localId, filename, bytes, totalBytes), + ), + onICloudProgress: (localId, progress) => _runUploadCallback( + 'iCloud progress callback failed for $localId', + () => userCallbacks.onICloudProgress?.call(localId, progress), + ), + onError: (localId, errorMessage) => _runUploadCallback( + 'Upload error callback failed for $localId', + () => userCallbacks.onError?.call(localId, errorMessage), + ), + onSuccess: (localId, remoteId) { + _runUploadCallback( + 'Upload success callback failed for $localId', + () => userCallbacks.onSuccess?.call(localId, remoteId), + ); + final source = localById[localId]; + if (source == null) { + _logger.warning('Upload success for $localId but source LocalAsset missing; skipping album link'); + return; + } + pendingAdds.add( + _linkUploadedAssetToAlbum(albumId, remoteId, uploader, source) + .then((added) { + addedCount += added; + }) + .catchError((Object error, StackTrace stack) { + _logger.warning('Failed to add uploaded asset $remoteId to album $albumId', error, stack); + }), + ); + }, + ); + + await _uploadService.uploadManual(localAssets, callbacks: wrappedCallbacks); + await Future.wait(pendingAdds); + return addedCount; + } + + void _runUploadCallback(String message, void Function() callback) { + try { + callback(); + } catch (error, stack) { + _logger.warning(message, error, stack); + } + } + + /// Links a freshly-uploaded asset to an album, ensuring the local DB + /// reflects the change without waiting for the next sync. We call the API + /// (server is the source of truth), then upsert a placeholder + /// `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 { + final result = await _albumApiRepository.addAssets(albumId, [remoteId]); + if (result.added.isEmpty) { + return 0; + } + + await _repository.upsertRemoteAssetStub(remoteId: remoteId, ownerId: uploader.id, source: source); + await _repository.addAssets(albumId, result.added); + return result.added.length; + } + Future deleteAlbum(String albumId) async { await _albumApiRepository.deleteAlbum(albumId); diff --git a/mobile/lib/infrastructure/repositories/remote_album.repository.dart b/mobile/lib/infrastructure/repositories/remote_album.repository.dart index c3b972d85f..71d96bb0cb 100644 --- a/mobile/lib/infrastructure/repositories/remote_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_album.repository.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift. import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; enum SortRemoteAlbumsBy { id, updatedAt } @@ -159,7 +160,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { createdAt: Value(album.createdAt), updatedAt: Value(album.updatedAt), description: Value(album.description), - thumbnailAssetId: Value(album.thumbnailAssetId), + thumbnailAssetId: Value(album.thumbnailAssetId ?? (assetIds.isNotEmpty ? assetIds.first : null)), isActivityEnabled: Value(album.isActivityEnabled), order: Value(album.order), ); @@ -274,17 +275,59 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { } Future addAssets(String albumId, List assetIds) async { + if (assetIds.isEmpty) { + return 0; + } + final albumAssets = assetIds.map( (assetId) => RemoteAlbumAssetEntityCompanion(albumId: Value(albumId), assetId: Value(assetId)), ); - await _db.batch((batch) { - batch.insertAll(_db.remoteAlbumAssetEntity, albumAssets); + await _db.transaction(() async { + await _db.batch((batch) { + batch.insertAll(_db.remoteAlbumAssetEntity, albumAssets); + }); + + final album = _db.update(_db.remoteAlbumEntity) + ..where((row) => row.id.equals(albumId) & row.thumbnailAssetId.isNull()); + + await album.write(RemoteAlbumEntityCompanion(thumbnailAssetId: Value(assetIds.first))); }); return assetIds.length; } + /// Inserts a placeholder `remote_asset_entity` row from a freshly-uploaded + /// local asset. Skips silently if a row with the same id or + /// (owner_id, checksum) already exists — sync will overwrite with the + /// authoritative server data once the AssetUploadReadyV1 event is processed. + Future upsertRemoteAssetStub({ + required String remoteId, + required String ownerId, + required LocalAsset source, + }) async { + await _db + .into(_db.remoteAssetEntity) + .insert( + RemoteAssetEntityCompanion( + id: Value(remoteId), + ownerId: Value(ownerId), + checksum: Value(source.checksum ?? remoteId), + name: Value(source.name), + type: Value(source.type), + createdAt: Value(source.createdAt), + updatedAt: Value(source.updatedAt), + width: Value(source.width), + height: Value(source.height), + durationMs: Value(source.durationMs), + isFavorite: Value(source.isFavorite), + visibility: const Value(AssetVisibility.timeline), + isEdited: Value(source.isEdited), + ), + mode: InsertMode.insertOrIgnore, + ); + } + Future addUsers(String albumId, List userIds) { final albumUsers = userIds.map( (assetId) => RemoteAlbumUserEntityCompanion( diff --git a/mobile/lib/presentation/pages/drift_album.page.dart b/mobile/lib/presentation/pages/drift_album.page.dart index c9fed636b4..47a4625f87 100644 --- a/mobile/lib/presentation/pages/drift_album.page.dart +++ b/mobile/lib/presentation/pages/drift_album.page.dart @@ -37,6 +37,7 @@ class _DriftAlbumsPageState extends ConsumerState { final scrollView = CustomScrollView( controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), slivers: [ ImmichSliverAppBar( snap: false, diff --git a/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart b/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart index 19f813cdb5..a12ca4932b 100644 --- a/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart +++ b/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart @@ -5,7 +5,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; @RoutePage() class DriftAssetSelectionTimelinePage extends ConsumerWidget { @@ -22,17 +21,13 @@ class DriftAssetSelectionTimelinePage extends ConsumerWidget { ), ), timelineServiceProvider.overrideWith((ref) { - final user = ref.watch(currentUserProvider); - if (user == null) { - throw Exception('User must be logged in to access asset selection timeline'); - } - - final timelineService = ref.watch(timelineFactoryProvider).remoteAssets(user.id); + final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull ?? []; + final timelineService = ref.watch(timelineFactoryProvider).main(timelineUsers); ref.onDispose(timelineService.dispose); return timelineService; }), ], - child: const Timeline(), + child: const Timeline(showStorageIndicator: true), ); } } diff --git a/mobile/lib/presentation/pages/drift_create_album.page.dart b/mobile/lib/presentation/pages/drift_create_album.page.dart index f1cbdb13ff..e6ff10fd59 100644 --- a/mobile/lib/presentation/pages/drift_create_album.page.dart +++ b/mobile/lib/presentation/pages/drift_create_album.page.dart @@ -179,17 +179,14 @@ class _DriftCreateAlbumPageState extends ConsumerState { } final album = await ref - .watch(remoteAlbumProvider.notifier) - .createAlbum( + .read(remoteAlbumProvider.notifier) + .createAlbumWithAssets( title: title, description: albumDescriptionController.text.trim(), - assetIds: selectedAssets.map((asset) { - final remoteAsset = asset as RemoteAsset; - return remoteAsset.id; - }).toList(), + assets: selectedAssets, ); - if (album != null) { + if (album != null && context.mounted) { unawaited(context.replaceRoute(RemoteAlbumRoute(album: album))); } } diff --git a/mobile/lib/presentation/pages/drift_remote_album.page.dart b/mobile/lib/presentation/pages/drift_remote_album.page.dart index 09c7912bc6..ccbddb99f3 100644 --- a/mobile/lib/presentation/pages/drift_remote_album.page.dart +++ b/mobile/lib/presentation/pages/drift_remote_album.page.dart @@ -8,6 +8,7 @@ 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/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/album/pending_uploads_banner.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/remote_album/drift_album_option.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; @@ -39,7 +40,8 @@ class _RemoteAlbumPageState extends ConsumerState { } Future addAssets(BuildContext context) async { - final albumAssets = await ref.read(remoteAlbumProvider.notifier).getAssets(_album.id); + final notifier = ref.read(remoteAlbumProvider.notifier); + final albumAssets = await notifier.getAssets(_album.id); final newAssets = await context.pushRoute>( DriftAssetSelectionTimelineRoute(lockedSelectionAssets: albumAssets.toSet()), @@ -49,17 +51,9 @@ class _RemoteAlbumPageState extends ConsumerState { return; } - final added = await ref - .read(remoteAlbumProvider.notifier) - .addAssets( - _album.id, - newAssets.map((asset) { - final remoteAsset = asset as RemoteAsset; - return remoteAsset.id; - }).toList(), - ); + final added = await notifier.addAssetsToAlbum(_album.id, newAssets); - if (added > 0) { + if (added > 0 && context.mounted) { ImmichToast.show( context: context, msg: "assets_added_to_album_count".t(context: context, args: {'count': added.toString()}), @@ -186,6 +180,7 @@ class _RemoteAlbumPageState extends ConsumerState { currentRemoteAlbumScopedProvider.overrideWithValue(_album), ], child: Timeline( + topSliverWidget: PendingUploadsBanner(albumId: _album.id), appBar: RemoteAlbumSliverAppBar( icon: Icons.photo_album_outlined, kebabMenu: _AlbumKebabMenu( diff --git a/mobile/lib/presentation/widgets/album/pending_uploads_banner.widget.dart b/mobile/lib/presentation/widgets/album/pending_uploads_banner.widget.dart new file mode 100644 index 0000000000..397170ec54 --- /dev/null +++ b/mobile/lib/presentation/widgets/album/pending_uploads_banner.widget.dart @@ -0,0 +1,252 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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/images/thumbnail.widget.dart'; +import 'package:immich_mobile/providers/album/pending_album_uploads.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 +/// banner opens a bottom sheet with per-asset progress. +class PendingUploadsBanner extends ConsumerWidget { + static const double _height = 52; + + final String albumId; + + const PendingUploadsBanner({super.key, required this.albumId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pending = ref.watch(pendingAlbumUploadsProvider(albumId)); + if (pending.isEmpty) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + + final hasFailures = pending.any((p) => p.failed); + final clamped = pending.map((p) => p.progress.clamp(0.0, 1.0)).toList(growable: false); + final overallProgress = clamped.isEmpty ? 0.0 : clamped.reduce((a, b) => a + b) / clamped.length; + final isIndeterminate = overallProgress <= 0.0; + + return SliverPersistentHeader( + pinned: true, + delegate: _PendingUploadsBannerDelegate( + height: _height, + child: _PendingUploadsBannerContent( + albumId: albumId, + previewAsset: pending.first.asset, + count: pending.length, + overallProgress: overallProgress, + isIndeterminate: isIndeterminate, + hasFailures: hasFailures, + ), + ), + ); + } + + static void _openSheet(BuildContext context, String albumId) { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (_) => _PendingUploadsSheet(albumId: albumId), + ); + } +} + +class _PendingUploadsBannerDelegate extends SliverPersistentHeaderDelegate { + final double height; + final Widget child; + + const _PendingUploadsBannerDelegate({required this.height, required this.child}); + + @override + double get minExtent => height; + + @override + double get maxExtent => height; + + @override + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => child; + + @override + bool shouldRebuild(covariant _PendingUploadsBannerDelegate oldDelegate) => + height != oldDelegate.height || child != oldDelegate.child; +} + +class _PendingUploadsBannerContent extends StatelessWidget { + final String albumId; + final BaseAsset previewAsset; + final int count; + final double overallProgress; + final bool isIndeterminate; + final bool hasFailures; + + const _PendingUploadsBannerContent({ + required this.albumId, + required this.previewAsset, + required this.count, + required this.overallProgress, + required this.isIndeterminate, + required this.hasFailures, + }); + + @override + Widget build(BuildContext context) { + final percentLabel = isIndeterminate ? '' : ' · ${(overallProgress * 100).toInt()}%'; + return Material( + color: hasFailures ? context.colorScheme.errorContainer : context.colorScheme.surfaceContainerHigh, + child: InkWell( + onTap: () => PendingUploadsBanner._openSheet(context, albumId), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(4)), + child: SizedBox(width: 32, height: 32, child: Thumbnail.fromAsset(asset: previewAsset)), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + '${'uploading'.t(context: context)} $count$percentLabel', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), + ), + ), + if (hasFailures) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Icon(Icons.error_outline, color: context.colorScheme.error, size: 20), + ), + Icon(Icons.chevron_right_rounded, color: context.colorScheme.onSurfaceVariant), + ], + ), + ), + ), + SizedBox( + height: 3, + child: LinearProgressIndicator( + value: isIndeterminate ? null : overallProgress, + backgroundColor: context.colorScheme.surfaceContainerHighest, + valueColor: AlwaysStoppedAnimation( + hasFailures ? context.colorScheme.error : context.colorScheme.primary, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _PendingUploadsSheet extends ConsumerWidget { + final String albumId; + + const _PendingUploadsSheet({required this.albumId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pending = ref.watch(pendingAlbumUploadsProvider(albumId)); + + // Auto-dismiss when the queue empties. + if (pending.isEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + }); + return const SizedBox.shrink(); + } + + final failedCount = pending.where((p) => p.failed).length; + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Expanded( + child: Text( + '${'uploading'.t(context: context)} (${pending.length})', + style: context.textTheme.titleMedium, + ), + ), + if (failedCount > 0) + TextButton.icon( + onPressed: () => ref.read(pendingAlbumUploadsProvider(albumId).notifier).clearFailed(), + icon: const Icon(Icons.clear_rounded, size: 18), + label: Text('clear_failed_count'.t(context: context, args: {'count': failedCount})), + style: TextButton.styleFrom(foregroundColor: context.colorScheme.error), + ), + ], + ), + ), + SizedBox( + height: 96, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: pending.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (_, index) => _PendingUploadTile(entry: pending[index]), + ), + ), + ], + ), + ), + ); + } +} + +class _PendingUploadTile extends StatelessWidget { + final PendingAlbumUpload entry; + + const _PendingUploadTile({required this.entry}); + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: SizedBox( + width: 96, + height: 96, + child: Stack( + fit: StackFit.expand, + children: [ + Thumbnail.fromAsset(asset: entry.asset), + Positioned.fill( + child: ColoredBox( + color: entry.failed ? Colors.red.withValues(alpha: 0.6) : Colors.black54, + child: Center( + child: entry.failed + ? const Icon(Icons.error_outline, color: Colors.white, size: 28) + : SizedBox( + width: 32, + height: 32, + child: CircularProgressIndicator( + value: entry.progress > 0 ? entry.progress : null, + strokeWidth: 2.5, + backgroundColor: Colors.white24, + valueColor: const AlwaysStoppedAnimation(Colors.white), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index 250cea8229..ea6f3e739e 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -242,7 +242,11 @@ class _AssetTileWidget extends ConsumerWidget { return false; } - return lockSelectionAssets.contains(asset); + // Iterate with `==` instead of `Set.contains` because `RemoteAsset.hashCode` + // includes `localId` while `==` does not — so the same server asset can + // hash to a different bucket when its `localId` differs (e.g., album-fetched + // copy has localId=null, merged-timeline copy has it populated). + return lockSelectionAssets.any((a) => a == asset); } @override diff --git a/mobile/lib/providers/album/pending_album_uploads.provider.dart b/mobile/lib/providers/album/pending_album_uploads.provider.dart new file mode 100644 index 0000000000..db857ba3c0 --- /dev/null +++ b/mobile/lib/providers/album/pending_album_uploads.provider.dart @@ -0,0 +1,81 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; + +class PendingAlbumUpload { + final LocalAsset asset; + final double progress; + final bool failed; + + const PendingAlbumUpload({required this.asset, this.progress = 0.0, this.failed = false}); + + PendingAlbumUpload copyWith({double? progress, bool? failed}) => + PendingAlbumUpload(asset: asset, progress: progress ?? this.progress, failed: failed ?? this.failed); +} + +class AlbumPendingUploadsNotifier extends AutoDisposeFamilyNotifier, String> { + KeepAliveLink? _keepAliveLink; + + @override + List build(String albumId) { + ref.onDispose(() { + _keepAliveLink?.close(); + _keepAliveLink = null; + }); + + return const []; + } + + void enqueue(Iterable assets) { + if (assets.isEmpty) { + return; + } + + final existingIds = state.map((e) => e.asset.id).toSet(); + final additions = assets.where((a) => !existingIds.contains(a.id)).map((a) => PendingAlbumUpload(asset: a)); + state = [...state, ...additions]; + _syncKeepAlive(); + } + + void updateProgress(String localAssetId, double progress) { + state = [ + for (final entry in state) + if (entry.asset.id == localAssetId) entry.copyWith(progress: progress, failed: false) else entry, + ]; + _syncKeepAlive(); + } + + void markFailed(String localAssetId) { + state = [ + for (final entry in state) + if (entry.asset.id == localAssetId) entry.copyWith(failed: true) else entry, + ]; + _syncKeepAlive(); + } + + void markAllFailed() { + state = [for (final entry in state) entry.copyWith(failed: true)]; + _syncKeepAlive(); + } + + void remove(String localAssetId) { + state = state.where((e) => e.asset.id != localAssetId).toList(); + _syncKeepAlive(); + } + + void clearFailed() { + state = state.where((e) => !e.failed).toList(); + _syncKeepAlive(); + } + + void _syncKeepAlive() { + if (state.isEmpty) { + _keepAliveLink?.close(); + _keepAliveLink = null; + } else { + _keepAliveLink ??= ref.keepAlive(); + } + } +} + +final pendingAlbumUploadsProvider = NotifierProvider.autoDispose + .family, String>(AlbumPendingUploadsNotifier.new); diff --git a/mobile/lib/providers/infrastructure/album.provider.dart b/mobile/lib/providers/infrastructure/album.provider.dart index 1ddabc1604..379e7b3101 100644 --- a/mobile/lib/providers/infrastructure/album.provider.dart +++ b/mobile/lib/providers/infrastructure/album.provider.dart @@ -9,6 +9,7 @@ import 'package:immich_mobile/infrastructure/repositories/remote_album.repositor import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; +import 'package:immich_mobile/services/foreground_upload.service.dart'; final localAlbumRepository = Provider( (ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)), @@ -33,7 +34,11 @@ final remoteAlbumRepository = Provider( ); final remoteAlbumServiceProvider = Provider( - (ref) => RemoteAlbumService(ref.watch(remoteAlbumRepository), ref.watch(driftAlbumApiRepositoryProvider)), + (ref) => RemoteAlbumService( + ref.watch(remoteAlbumRepository), + ref.watch(driftAlbumApiRepositoryProvider), + ref.watch(foregroundUploadServiceProvider), + ), dependencies: [remoteAlbumRepository], ); diff --git a/mobile/lib/providers/infrastructure/remote_album.provider.dart b/mobile/lib/providers/infrastructure/remote_album.provider.dart index b9a0e91ce5..73a796bd31 100644 --- a/mobile/lib/providers/infrastructure/remote_album.provider.dart +++ b/mobile/lib/providers/infrastructure/remote_album.provider.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; @@ -6,8 +8,10 @@ import 'package:immich_mobile/domain/models/user.model.dart'; 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/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:logging/logging.dart'; class RemoteAlbumState { @@ -105,6 +109,46 @@ class RemoteAlbumNotifier extends Notifier { } } + /// Creates an album from a heterogeneous asset selection. Already-remote + /// assets seed the album immediately; local-only assets are uploaded in the + /// background and linked one-by-one as each upload completes. + Future createAlbumWithAssets({ + required String title, + String? description, + Iterable assets = const [], + }) async { + try { + final currentUser = ref.read(currentUserProvider); + if (currentUser == null) { + throw Exception('User not logged in'); + } + + final candidates = RemoteAlbumService.categorizeCandidates(assets); + final album = await _remoteAlbumService.createAlbum( + title: title, + owner: currentUser, + description: description, + assetIds: candidates.remoteAssetIds, + ); + + state = state.copyWith(albums: [...state.albums, album]); + + if (candidates.localAssetsToUpload.isNotEmpty) { + unawaited( + addAssetsToAlbum( + album.id, + candidates.localAssetsToUpload, + ).then((_) {}).catchError((Object _, StackTrace _) {}), + ); + } + + return album; + } catch (error, stack) { + _logger.severe('Failed to create album with assets', error, stack); + rethrow; + } + } + Future updateAlbum( String albumId, { String? name, @@ -155,8 +199,65 @@ class RemoteAlbumNotifier extends Notifier { return _remoteAlbumService.getAssets(albumId); } - Future addAssets(String albumId, List assetIds) { - return _remoteAlbumService.addAssets(albumId: albumId, assetIds: assetIds); + Future addAssets(String albumId, List assetIds) async { + final added = await _remoteAlbumService.addAssets(albumId: albumId, assetIds: assetIds); + 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 + /// progress indicators), uploaded, and linked one-by-one as each finishes. + Future addAssetsToAlbum(String albumId, Iterable assets) async { + final currentUser = ref.read(currentUserProvider); + if (currentUser == null) { + throw Exception('User not logged in'); + } + + final candidates = RemoteAlbumService.categorizeCandidates(assets); + final pendingNotifier = ref.read(pendingAlbumUploadsProvider(albumId).notifier); + pendingNotifier.enqueue(candidates.localAssetsToUpload); + + try { + final added = await _remoteAlbumService.addAssetsToAlbum( + albumId: albumId, + uploader: currentUser, + candidates: candidates, + uploadCallbacks: UploadCallbacks( + onProgress: (localAssetId, _, bytes, totalBytes) { + final progress = totalBytes > 0 ? bytes / totalBytes : 0.0; + pendingNotifier.updateProgress(localAssetId, progress); + }, + onSuccess: (localAssetId, _) => pendingNotifier.remove(localAssetId), + onError: (localAssetId, _) => pendingNotifier.markFailed(localAssetId), + ), + ); + if (added > 0) { + await _refreshAlbumInState(albumId); + } + return added; + } catch (error, stack) { + if (candidates.localAssetsToUpload.isNotEmpty) { + pendingNotifier.markAllFailed(); + } + _logger.severe('Failed to add assets to album $albumId', error, stack); + rethrow; + } + } + + /// Re-reads a single album from the local DB and replaces it in [state] so + /// that views bound to the album list (counts, thumbnails) reflect the + /// latest junction-table changes without a full `refresh()`. + Future _refreshAlbumInState(String albumId) async { + final updated = await _remoteAlbumService.get(albumId); + if (updated == null) { + return; + } + + state = state.copyWith(albums: state.albums.map((album) => album.id == albumId ? updated : album).toList()); } Future addUsers(String albumId, List userIds) { diff --git a/mobile/test/medium/repositories/remote_album_repository_test.dart b/mobile/test/medium/repositories/remote_album_repository_test.dart index 5e923ea09b..e4d803a51c 100644 --- a/mobile/test/medium/repositories/remote_album_repository_test.dart +++ b/mobile/test/medium/repositories/remote_album_repository_test.dart @@ -17,6 +17,33 @@ void main() { await ctx.dispose(); }); + group('addAssets', () { + test('sets the first added asset as thumbnail when the album has no thumbnail', () async { + final user = await ctx.newUser(); + final album = await ctx.newRemoteAlbum(ownerId: user.id); + final asset = await ctx.newRemoteAsset(ownerId: user.id); + + await sut.addAssets(album.id, [asset.id]); + + final updated = await sut.get(album.id); + expect(updated?.thumbnailAssetId, asset.id); + expect(updated?.assetCount, 1); + }); + + test('preserves an existing thumbnail when adding assets', () async { + final user = await ctx.newUser(); + final thumbnail = await ctx.newRemoteAsset(ownerId: user.id); + final album = await ctx.newRemoteAlbum(ownerId: user.id, thumbnailAssetId: thumbnail.id); + final asset = await ctx.newRemoteAsset(ownerId: user.id); + + await sut.addAssets(album.id, [asset.id]); + + final updated = await sut.get(album.id); + expect(updated?.thumbnailAssetId, thumbnail.id); + expect(updated?.assetCount, 1); + }); + }); + group('getSortedAlbumIds', () { late String userId;