diff --git a/mobile/lib/presentation/pages/drift_remote_album.page.dart b/mobile/lib/presentation/pages/drift_remote_album.page.dart index 89d3a8f037..9b78ba3997 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'; @@ -179,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..137f25c6bf --- /dev/null +++ b/mobile/lib/presentation/widgets/album/pending_uploads_banner.widget.dart @@ -0,0 +1,250 @@ +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 ($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/providers/album/pending_album_uploads.provider.dart b/mobile/lib/providers/album/pending_album_uploads.provider.dart new file mode 100644 index 0000000000..8823ae568d --- /dev/null +++ b/mobile/lib/providers/album/pending_album_uploads.provider.dart @@ -0,0 +1,52 @@ +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 FamilyNotifier, String> { + @override + List build(String albumId) => 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]; + } + + 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, + ]; + } + + void markFailed(String localAssetId) { + state = [ + for (final entry in state) + if (entry.asset.id == localAssetId) entry.copyWith(failed: true) else entry, + ]; + } + + void remove(String localAssetId) { + state = state.where((e) => e.asset.id != localAssetId).toList(); + } + + void clearFailed() { + state = state.where((e) => !e.failed).toList(); + } +} + +final pendingAlbumUploadsProvider = + NotifierProvider.family, String>( + AlbumPendingUploadsNotifier.new, + ); diff --git a/mobile/lib/providers/infrastructure/remote_album.provider.dart b/mobile/lib/providers/infrastructure/remote_album.provider.dart index 38b4a7461c..9479938354 100644 --- a/mobile/lib/providers/infrastructure/remote_album.provider.dart +++ b/mobile/lib/providers/infrastructure/remote_album.provider.dart @@ -1,5 +1,3 @@ -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'; @@ -8,7 +6,7 @@ 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/backup/asset_upload_progress.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'; @@ -109,8 +107,7 @@ class RemoteAlbumNotifier extends Notifier { /// Creates an album from a heterogeneous asset selection. Already-remote /// assets seed the album immediately; local-only assets are uploaded and - /// linked one-by-one as each upload completes. Per-asset progress is - /// reported via [assetUploadProgressProvider]. + /// linked one-by-one as each upload completes. Future createAlbumWithAssets({ required String title, String? description, @@ -123,19 +120,22 @@ class RemoteAlbumNotifier extends Notifier { } final candidates = RemoteAlbumService.categorizeCandidates(assets); - final album = await _runWithProgressBridge( - candidates.localAssetsToUpload, - (callbacks) => _remoteAlbumService.createAlbumWithAssets( - title: title, - owner: currentUser, - description: description, - candidates: candidates, - uploadCallbacks: callbacks, - ), + final album = await _remoteAlbumService.createAlbumWithAssets( + title: title, + owner: currentUser, + description: description, + candidates: candidates, ); state = state.copyWith(albums: [...state.albums, album]); + // The createAlbum API returns the album with its initial asset count, but + // any local-only assets are uploaded and linked afterward — re-read to + // pick up the post-upload junction rows. + if (candidates.localAssetsToUpload.isNotEmpty) { + await _refreshAlbumInState(album.id); + } + return album; } catch (error, stack) { _logger.severe('Failed to create album with assets', error, stack); @@ -193,14 +193,18 @@ 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 uploaded and linked one-by- - /// one as each upload completes. Per-asset progress is reported via - /// [assetUploadProgressProvider]. + /// 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) { @@ -208,60 +212,40 @@ class RemoteAlbumNotifier extends Notifier { } final candidates = RemoteAlbumService.categorizeCandidates(assets); + final pendingNotifier = ref.read(pendingAlbumUploadsProvider(albumId).notifier); + pendingNotifier.enqueue(candidates.localAssetsToUpload); + try { - return await _runWithProgressBridge( - candidates.localAssetsToUpload, - (callbacks) => _remoteAlbumService.addAssetsToAlbum( - albumId: albumId, - uploader: currentUser, - candidates: candidates, - uploadCallbacks: callbacks, + 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) { _logger.severe('Failed to add assets to album $albumId', error, stack); rethrow; } } - /// Bridges [UploadCallbacks] from the service to [assetUploadProgressProvider] - /// and the manual upload cancel token, so the UI's existing progress overlays - /// pick up the work without each caller wiring it manually. - Future _runWithProgressBridge( - List localAssets, - Future Function(UploadCallbacks callbacks) action, - ) async { - if (localAssets.isEmpty) { - return action(const UploadCallbacks()); - } - - final progressNotifier = ref.read(assetUploadProgressProvider.notifier); - final cancelToken = Completer(); - ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken; - - for (final asset in localAssets) { - progressNotifier.setProgress(asset.id, 0.0); - } - - try { - return await action( - UploadCallbacks( - onProgress: (localAssetId, _, bytes, totalBytes) { - final progress = totalBytes > 0 ? bytes / totalBytes : 0.0; - progressNotifier.setProgress(localAssetId, progress); - }, - onSuccess: (localAssetId, _) { - progressNotifier.remove(localAssetId); - }, - onError: (localAssetId, _) { - progressNotifier.setError(localAssetId); - }, - ), - ); - } finally { - ref.read(manualUploadCancelTokenProvider.notifier).state = null; - Future.delayed(const Duration(seconds: 2), progressNotifier.clear); - } + /// 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) {