328 lines
11 KiB
Dart
328 lines
11 KiB
Dart
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';
|
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|
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:openapi/api.dart' show Optional;
|
|
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';
|
|
import 'package:logging/logging.dart';
|
|
|
|
class RemoteAlbumState {
|
|
final List<RemoteAlbum> albums;
|
|
|
|
const RemoteAlbumState({required this.albums});
|
|
|
|
RemoteAlbumState copyWith({List<RemoteAlbum>? albums}) {
|
|
return RemoteAlbumState(albums: albums ?? this.albums);
|
|
}
|
|
|
|
@override
|
|
String toString() => 'RemoteAlbumState(albums: ${albums.length})';
|
|
|
|
@override
|
|
bool operator ==(covariant RemoteAlbumState other) {
|
|
if (identical(this, other)) {
|
|
return true;
|
|
}
|
|
final listEquals = const DeepCollectionEquality().equals;
|
|
|
|
return listEquals(other.albums, albums);
|
|
}
|
|
|
|
@override
|
|
int get hashCode => albums.hashCode;
|
|
}
|
|
|
|
class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
|
late RemoteAlbumService _remoteAlbumService;
|
|
final _logger = Logger('RemoteAlbumNotifier');
|
|
|
|
@override
|
|
RemoteAlbumState build() {
|
|
_remoteAlbumService = ref.read(remoteAlbumServiceProvider);
|
|
return const RemoteAlbumState(albums: []);
|
|
}
|
|
|
|
Future<List<RemoteAlbum>> _getAll() async {
|
|
try {
|
|
final albums = await _remoteAlbumService.getAll();
|
|
state = state.copyWith(albums: albums);
|
|
return albums;
|
|
} catch (error, stack) {
|
|
_logger.severe('Failed to fetch albums', error, stack);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<void> refresh() async {
|
|
await _getAll();
|
|
}
|
|
|
|
List<RemoteAlbum> searchAlbums(
|
|
List<RemoteAlbum> albums,
|
|
String query,
|
|
String? userId, [
|
|
QuickFilterMode filterMode = QuickFilterMode.all,
|
|
]) {
|
|
return _remoteAlbumService.searchAlbums(albums, query, userId, filterMode);
|
|
}
|
|
|
|
Future<List<RemoteAlbum>> sortAlbums(
|
|
List<RemoteAlbum> albums,
|
|
AlbumSortMode sortMode, {
|
|
bool isReverse = false,
|
|
}) async {
|
|
return await _remoteAlbumService.sortAlbums(albums, sortMode, isReverse: isReverse);
|
|
}
|
|
|
|
Future<RemoteAlbum?> createAlbum({
|
|
required String title,
|
|
String? description,
|
|
List<String> assetIds = const [],
|
|
}) async {
|
|
try {
|
|
final currentUser = ref.read(currentUserProvider);
|
|
if (currentUser == null) {
|
|
throw Exception('User not logged in');
|
|
}
|
|
|
|
final album = await _remoteAlbumService.createAlbum(
|
|
title: title,
|
|
owner: currentUser,
|
|
description: description,
|
|
assetIds: assetIds,
|
|
);
|
|
|
|
state = state.copyWith(albums: [...state.albums, album]);
|
|
|
|
return album;
|
|
} catch (error, stack) {
|
|
_logger.severe('Failed to create album', error, stack);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// 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<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.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<void>((_) {}).catchError((Object _, StackTrace _) {}),
|
|
);
|
|
}
|
|
|
|
return album;
|
|
} catch (error, stack) {
|
|
_logger.severe('Failed to create album with assets', error, stack);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<RemoteAlbum?> updateAlbum(
|
|
String albumId, {
|
|
String? name,
|
|
Optional<String?> description = const Optional.absent(),
|
|
String? thumbnailAssetId,
|
|
bool? isActivityEnabled,
|
|
AlbumAssetOrder? order,
|
|
}) async {
|
|
try {
|
|
final updatedAlbum = await _remoteAlbumService.updateAlbum(
|
|
albumId,
|
|
name: name,
|
|
description: description,
|
|
thumbnailAssetId: thumbnailAssetId,
|
|
isActivityEnabled: isActivityEnabled,
|
|
order: order,
|
|
);
|
|
|
|
final updatedAlbums = state.albums.map((album) {
|
|
return album.id == albumId ? updatedAlbum : album;
|
|
}).toList();
|
|
|
|
state = state.copyWith(albums: updatedAlbums);
|
|
|
|
return updatedAlbum;
|
|
} catch (error, stack) {
|
|
_logger.severe('Failed to update album', error, stack);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<RemoteAlbum?> toggleAlbumOrder(String albumId) async {
|
|
final currentAlbum = state.albums.firstWhere((album) => album.id == albumId);
|
|
|
|
final newOrder = currentAlbum.order == AlbumAssetOrder.asc ? AlbumAssetOrder.desc : AlbumAssetOrder.asc;
|
|
|
|
return updateAlbum(albumId, order: newOrder);
|
|
}
|
|
|
|
Future<void> deleteAlbum(String albumId) async {
|
|
await _remoteAlbumService.deleteAlbum(albumId);
|
|
|
|
final updatedAlbums = state.albums.where((album) => album.id != albumId).toList();
|
|
state = state.copyWith(albums: updatedAlbums);
|
|
}
|
|
|
|
Future<List<RemoteAsset>> getAssets(String albumId) {
|
|
return _remoteAlbumService.getAssets(albumId);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/// 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<int> 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
|
|
/// 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);
|
|
|
|
Completer<void>? cancelToken;
|
|
if (candidates.localAssetsToUpload.isNotEmpty) {
|
|
cancelToken = Completer<void>();
|
|
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;
|
|
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;
|
|
} finally {
|
|
if (cancelToken != null) {
|
|
if (cancelToken.isCompleted) {
|
|
pendingNotifier.clear();
|
|
}
|
|
if (ref.read(manualUploadCancelTokenProvider) == cancelToken) {
|
|
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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) {
|
|
return _remoteAlbumService.addUsers(albumId: albumId, userIds: userIds);
|
|
}
|
|
|
|
Future<void> removeUser(String albumId, String userId) {
|
|
return _remoteAlbumService.removeUser(albumId, userId: userId);
|
|
}
|
|
|
|
Future<void> leaveAlbum(String albumId, {required String userId}) async {
|
|
await _remoteAlbumService.removeUser(albumId, userId: userId);
|
|
|
|
final updatedAlbums = state.albums.where((album) => album.id != albumId).toList();
|
|
state = state.copyWith(albums: updatedAlbums);
|
|
}
|
|
|
|
Future<void> setActivityStatus(String albumId, bool enabled) {
|
|
return _remoteAlbumService.setActivityStatus(albumId, enabled);
|
|
}
|
|
}
|
|
|
|
final remoteAlbumDateRangeProvider = FutureProvider.family<(DateTime, DateTime), String>((ref, albumId) async {
|
|
final service = ref.watch(remoteAlbumServiceProvider);
|
|
return service.getDateRange(albumId);
|
|
});
|
|
|
|
final remoteAlbumSharedUsersProvider = FutureProvider.autoDispose.family<List<UserDto>, String>((ref, albumId) async {
|
|
final link = ref.keepAlive();
|
|
ref.onDispose(() => link.close());
|
|
final service = ref.watch(remoteAlbumServiceProvider);
|
|
return service.getSharedUsers(albumId);
|
|
});
|