Compare commits

...

4 Commits

Author SHA1 Message Date
Alex
a3674e2dc4 Upload status 2026-04-27 23:37:59 -05:00
Alex
596042c3c4 refactor 2026-04-27 22:10:55 -05:00
Alex
f24c83465f feat: manually upload local assets to album 2026-04-27 21:37:29 -05:00
Alex
2210730267 feat: manually upload local assets to album 2026-04-27 19:58:04 -05:00
11 changed files with 583 additions and 31 deletions

View File

@@ -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<String> remoteAssetIds;
final List<LocalAsset> 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<BaseAsset> assets) {
final remoteIds = <String>[];
final localToUpload = <LocalAsset>[];
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<RemoteAlbum?> watchAlbum(String albumId) {
return _repository.watchAlbum(albumId);
@@ -148,6 +183,101 @@ 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<int> 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<RemoteAlbum> 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<int> _uploadAndAddLocals(
String albumId,
UserDto uploader,
List<LocalAsset> localAssets,
UploadCallbacks userCallbacks,
) async {
int addedCount = 0;
final pendingAdds = <Future<void>>[];
final localById = {for (final a in localAssets) a.id: a};
final wrappedCallbacks = UploadCallbacks(
onProgress: userCallbacks.onProgress,
onICloudProgress: userCallbacks.onICloudProgress,
onError: userCallbacks.onError,
onSuccess: (localId, remoteId) {
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<void>((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;
}
/// 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<int> _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<void> deleteAlbum(String albumId) async {
await _albumApiRepository.deleteAlbum(albumId);

View File

@@ -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 }
@@ -285,6 +286,37 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
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<void> 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 ?? ''),
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<void> addUsers(String albumId, List<String> userIds) {
final albumUsers = userIds.map(
(assetId) => RemoteAlbumUserEntityCompanion(

View File

@@ -37,6 +37,7 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
final scrollView = CustomScrollView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
ImmichSliverAppBar(
snap: false,

View File

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

View File

@@ -179,17 +179,14 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
}
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)));
}
}

View File

@@ -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<RemoteAlbumPage> {
}
Future<void> 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<Set<BaseAsset>>(
DriftAssetSelectionTimelineRoute(lockedSelectionAssets: albumAssets.toSet()),
@@ -49,17 +51,9 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
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<RemoteAlbumPage> {
currentRemoteAlbumScopedProvider.overrideWithValue(_album),
],
child: Timeline(
topSliverWidget: PendingUploadsBanner(albumId: _album.id),
appBar: RemoteAlbumSliverAppBar(
icon: Icons.photo_album_outlined,
kebabMenu: _AlbumKebabMenu(

View File

@@ -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<Color>(
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<Color>(Colors.white),
),
),
),
),
),
],
),
),
);
}
}

View File

@@ -234,7 +234,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

View File

@@ -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<List<PendingAlbumUpload>, String> {
@override
List<PendingAlbumUpload> build(String albumId) => const [];
void enqueue(Iterable<LocalAsset> 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<AlbumPendingUploadsNotifier, List<PendingAlbumUpload>, String>(
AlbumPendingUploadsNotifier.new,
);

View File

@@ -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<DriftLocalAlbumRepository>(
(ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)),
@@ -33,7 +34,11 @@ final remoteAlbumRepository = Provider<DriftRemoteAlbumRepository>(
);
final remoteAlbumServiceProvider = Provider<RemoteAlbumService>(
(ref) => RemoteAlbumService(ref.watch(remoteAlbumRepository), ref.watch(driftAlbumApiRepositoryProvider)),
(ref) => RemoteAlbumService(
ref.watch(remoteAlbumRepository),
ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(foregroundUploadServiceProvider),
),
dependencies: [remoteAlbumRepository],
);

View File

@@ -6,8 +6,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 {
@@ -103,6 +105,44 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
}
}
/// 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.
Future<RemoteAlbum?> createAlbumWithAssets({
required String title,
String? description,
Iterable<BaseAsset> 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.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);
rethrow;
}
}
Future<RemoteAlbum?> updateAlbum(
String albumId, {
String? name,
@@ -153,8 +193,59 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
return _remoteAlbumService.getAssets(albumId);
}
Future<int> addAssets(String albumId, List<String> assetIds) {
return _remoteAlbumService.addAssets(albumId: albumId, assetIds: assetIds);
Future<int> addAssets(String albumId, List<String> 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<int> addAssetsToAlbum(String albumId, Iterable<BaseAsset> 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) {
_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<void> _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<void> addUsers(String albumId, List<String> userIds) {