feat: album info sync (#21103)

* wip

* album creation

* fix: album api repository no invalidating after logging out

* add linkedRemoteAlbumId column and migration

* link/unlink remote album

* logic to find and add new assets to album

* pr feedback

* add toggle option to backup option page

* refactor: provider > service

* rename

* Handle page pop manually

* UI feedback for user creation and sync linked album

* uncomment migration

* remove unused method
This commit is contained in:
Alex 2025-09-04 13:44:10 -05:00 committed by GitHub
parent 538263dc38
commit bcfb5bee1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 601 additions and 199 deletions

View file

@ -1417,6 +1417,8 @@
"open_the_search_filters": "Open the search filters", "open_the_search_filters": "Open the search filters",
"options": "Options", "options": "Options",
"or": "or", "or": "or",
"organize_into_albums": "Organize into albums",
"organize_into_albums_description": "Put existing photos into albums using current sync settings",
"organize_your_library": "Organize your library", "organize_your_library": "Organize your library",
"original": "original", "original": "original",
"other": "Other", "other": "Other",

Binary file not shown.

View file

@ -15,6 +15,7 @@ class LocalAlbum {
final int assetCount; final int assetCount;
final BackupSelection backupSelection; final BackupSelection backupSelection;
final String? linkedRemoteAlbumId;
const LocalAlbum({ const LocalAlbum({
required this.id, required this.id,
@ -23,6 +24,7 @@ class LocalAlbum {
this.assetCount = 0, this.assetCount = 0,
this.backupSelection = BackupSelection.none, this.backupSelection = BackupSelection.none,
this.isIosSharedAlbum = false, this.isIosSharedAlbum = false,
this.linkedRemoteAlbumId,
}); });
LocalAlbum copyWith({ LocalAlbum copyWith({
@ -32,6 +34,7 @@ class LocalAlbum {
int? assetCount, int? assetCount,
BackupSelection? backupSelection, BackupSelection? backupSelection,
bool? isIosSharedAlbum, bool? isIosSharedAlbum,
String? linkedRemoteAlbumId,
}) { }) {
return LocalAlbum( return LocalAlbum(
id: id ?? this.id, id: id ?? this.id,
@ -40,6 +43,7 @@ class LocalAlbum {
assetCount: assetCount ?? this.assetCount, assetCount: assetCount ?? this.assetCount,
backupSelection: backupSelection ?? this.backupSelection, backupSelection: backupSelection ?? this.backupSelection,
isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum,
linkedRemoteAlbumId: linkedRemoteAlbumId ?? this.linkedRemoteAlbumId,
); );
} }
@ -53,7 +57,8 @@ class LocalAlbum {
other.updatedAt == updatedAt && other.updatedAt == updatedAt &&
other.assetCount == assetCount && other.assetCount == assetCount &&
other.backupSelection == backupSelection && other.backupSelection == backupSelection &&
other.isIosSharedAlbum == isIosSharedAlbum; other.isIosSharedAlbum == isIosSharedAlbum &&
other.linkedRemoteAlbumId == linkedRemoteAlbumId;
} }
@override @override
@ -63,7 +68,8 @@ class LocalAlbum {
updatedAt.hashCode ^ updatedAt.hashCode ^
assetCount.hashCode ^ assetCount.hashCode ^
backupSelection.hashCode ^ backupSelection.hashCode ^
isIosSharedAlbum.hashCode; isIosSharedAlbum.hashCode ^
linkedRemoteAlbumId.hashCode;
} }
@override @override
@ -75,6 +81,7 @@ updatedAt: $updatedAt,
assetCount: $assetCount, assetCount: $assetCount,
backupSelection: $backupSelection, backupSelection: $backupSelection,
isIosSharedAlbum: $isIosSharedAlbum isIosSharedAlbum: $isIosSharedAlbum
linkedRemoteAlbumId: $linkedRemoteAlbumId,
}'''; }''';
} }
} }

View file

@ -22,4 +22,16 @@ class LocalAlbumService {
Future<int> getCount() { Future<int> getCount() {
return _repository.getCount(); return _repository.getCount();
} }
Future<void> unlinkRemoteAlbum(String id) async {
return _repository.unlinkRemoteAlbum(id);
}
Future<void> linkRemoteAlbum(String localAlbumId, String remoteAlbumId) async {
return _repository.linkRemoteAlbum(localAlbumId, remoteAlbumId);
}
Future<List<LocalAlbum>> getBackupAlbums() {
return _repository.getBackupAlbums();
}
} }

View file

@ -26,6 +26,10 @@ class RemoteAlbumService {
return _repository.get(albumId); return _repository.get(albumId);
} }
Future<RemoteAlbum?> getByName(String albumName, String ownerId) {
return _repository.getByName(albumName, ownerId);
}
Future<List<RemoteAlbum>> sortAlbums( Future<List<RemoteAlbum>> sortAlbums(
List<RemoteAlbum> albums, List<RemoteAlbum> albums,
RemoteAlbumSortMode sortMode, { RemoteAlbumSortMode sortMode, {
@ -80,7 +84,6 @@ class RemoteAlbumService {
Future<RemoteAlbum> createAlbum({required String title, required List<String> assetIds, String? description}) async { Future<RemoteAlbum> createAlbum({required String title, required List<String> assetIds, String? description}) async {
final album = await _albumApiRepository.createDriftAlbum(title, description: description, assetIds: assetIds); final album = await _albumApiRepository.createDriftAlbum(title, description: description, assetIds: assetIds);
await _repository.create(album, assetIds); await _repository.create(album, assetIds);
return album; return album;

View file

@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
final syncLinkedAlbumServiceProvider = Provider(
(ref) => SyncLinkedAlbumService(
ref.watch(localAlbumRepository),
ref.watch(remoteAlbumRepository),
ref.watch(driftAlbumApiRepositoryProvider),
),
);
class SyncLinkedAlbumService {
final DriftLocalAlbumRepository _localAlbumRepository;
final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DriftAlbumApiRepository _albumApiRepository;
const SyncLinkedAlbumService(this._localAlbumRepository, this._remoteAlbumRepository, this._albumApiRepository);
Future<void> syncLinkedAlbums(String userId) async {
final selectedAlbums = await _localAlbumRepository.getBackupAlbums();
await Future.wait(
selectedAlbums.map((localAlbum) async {
final linkedRemoteAlbumId = localAlbum.linkedRemoteAlbumId;
if (linkedRemoteAlbumId == null) {
return;
}
final remoteAlbum = await _remoteAlbumRepository.get(linkedRemoteAlbumId);
if (remoteAlbum == null) {
return;
}
// get assets that are uploaded but not in the remote album
final assetIds = await _remoteAlbumRepository.getLinkedAssetIds(userId, localAlbum.id, linkedRemoteAlbumId);
if (assetIds.isNotEmpty) {
final album = await _albumApiRepository.addAssets(remoteAlbum.id, assetIds);
await _remoteAlbumRepository.addAssets(remoteAlbum.id, album.added);
}
}),
);
}
Future<void> manageLinkedAlbums(List<LocalAlbum> localAlbums, String ownerId) async {
for (final album in localAlbums) {
await _processLocalAlbum(album, ownerId);
}
}
/// Processes a single local album to ensure proper linking with remote albums
Future<void> _processLocalAlbum(LocalAlbum localAlbum, String ownerId) {
final hasLinkedRemoteAlbum = localAlbum.linkedRemoteAlbumId != null;
if (hasLinkedRemoteAlbum) {
return _handleLinkedAlbum(localAlbum);
} else {
return _handleUnlinkedAlbum(localAlbum, ownerId);
}
}
/// Handles albums that are already linked to a remote album
Future<void> _handleLinkedAlbum(LocalAlbum localAlbum) async {
final remoteAlbumId = localAlbum.linkedRemoteAlbumId!;
final remoteAlbum = await _remoteAlbumRepository.get(remoteAlbumId);
final remoteAlbumExists = remoteAlbum != null;
if (!remoteAlbumExists) {
return _localAlbumRepository.unlinkRemoteAlbum(localAlbum.id);
}
}
/// Handles albums that are not linked to any remote album
Future<void> _handleUnlinkedAlbum(LocalAlbum localAlbum, String ownerId) async {
final existingRemoteAlbum = await _remoteAlbumRepository.getByName(localAlbum.name, ownerId);
if (existingRemoteAlbum != null) {
return _linkToExistingRemoteAlbum(localAlbum, existingRemoteAlbum);
} else {
return _createAndLinkNewRemoteAlbum(localAlbum);
}
}
/// Links a local album to an existing remote album
Future<void> _linkToExistingRemoteAlbum(LocalAlbum localAlbum, dynamic existingRemoteAlbum) {
return _localAlbumRepository.linkRemoteAlbum(localAlbum.id, existingRemoteAlbum.id);
}
/// Creates a new remote album and links it to the local album
Future<void> _createAndLinkNewRemoteAlbum(LocalAlbum localAlbum) async {
debugPrint("Creating new remote album for local album: ${localAlbum.name}");
final newRemoteAlbum = await _albumApiRepository.createDriftAlbum(localAlbum.name, assetIds: []);
await _remoteAlbumRepository.create(newRemoteAlbum, []);
return _localAlbumRepository.linkRemoteAlbum(localAlbum.id, newRemoteAlbum.id);
}
}

View file

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:immich_mobile/domain/utils/sync_linked_album.dart';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart'; import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/utils/isolate.dart'; import 'package:immich_mobile/utils/isolate.dart';
import 'package:worker_manager/worker_manager.dart'; import 'package:worker_manager/worker_manager.dart';
@ -155,6 +156,11 @@ class BackgroundSyncManager {
_syncWebsocketTask = null; _syncWebsocketTask = null;
}); });
} }
Future<void> syncLinkedAlbum() {
final task = runInIsolateGentle(computation: syncLinkedAlbumsIsolated);
return task.future;
}
} }
Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle( Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle(

View file

@ -0,0 +1,11 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/providers/user.provider.dart';
Future<void> syncLinkedAlbumsIsolated(ProviderContainer ref) {
final user = ref.read(currentUserProvider);
if (user == null) {
return Future.value();
}
return ref.read(syncLinkedAlbumServiceProvider).syncLinkedAlbums(user.id);
}

View file

@ -1,5 +1,7 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class LocalAlbumEntity extends Table with DriftDefaultsMixin { class LocalAlbumEntity extends Table with DriftDefaultsMixin {
@ -11,9 +13,26 @@ class LocalAlbumEntity extends Table with DriftDefaultsMixin {
IntColumn get backupSelection => intEnum<BackupSelection>()(); IntColumn get backupSelection => intEnum<BackupSelection>()();
BoolColumn get isIosSharedAlbum => boolean().withDefault(const Constant(false))(); BoolColumn get isIosSharedAlbum => boolean().withDefault(const Constant(false))();
// // Linked album for putting assets to the remote album after finished uploading
TextColumn get linkedRemoteAlbumId =>
text().references(RemoteAlbumEntity, #id, onDelete: KeyAction.setNull).nullable()();
// Used for mark & sweep // Used for mark & sweep
BoolColumn get marker_ => boolean().nullable()(); BoolColumn get marker_ => boolean().nullable()();
@override @override
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
} }
extension LocalAlbumEntityDataHelper on LocalAlbumEntityData {
LocalAlbum toDto({int assetCount = 0}) {
return LocalAlbum(
id: id,
name: name,
updatedAt: updatedAt,
assetCount: assetCount,
backupSelection: backupSelection,
linkedRemoteAlbumId: linkedRemoteAlbumId,
);
}
}

View file

@ -20,7 +20,7 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
} }
extension LocalAssetEntityDataDomainEx on LocalAssetEntityData { extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
LocalAsset toDto() => LocalAsset( LocalAsset toDto() => LocalAsset(
id: id, id: id,
name: name, name: name,

View file

@ -4,9 +4,10 @@ import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import "package:immich_mobile/utils/database.utils.dart";
final backupRepositoryProvider = Provider<DriftBackupRepository>( final backupRepositoryProvider = Provider<DriftBackupRepository>(
(ref) => DriftBackupRepository(ref.watch(driftProvider)), (ref) => DriftBackupRepository(ref.watch(driftProvider)),

View file

@ -68,7 +68,7 @@ class Drift extends $Drift implements IDatabaseRepository {
: super(executor ?? driftDatabase(name: 'immich', native: const DriftNativeOptions(shareAcrossIsolates: true))); : super(executor ?? driftDatabase(name: 'immich', native: const DriftNativeOptions(shareAcrossIsolates: true)));
@override @override
int get schemaVersion => 8; int get schemaVersion => 9;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@ -123,6 +123,9 @@ class Drift extends $Drift implements IDatabaseRepository {
from7To8: (m, v8) async { from7To8: (m, v8) async {
await m.create(v8.storeEntity); await m.create(v8.storeEntity);
}, },
from8To9: (m, v9) async {
await m.addColumn(v9.localAlbumEntity, v9.localAlbumEntity.linkedRemoteAlbumId);
},
), ),
); );

View file

@ -1,11 +1,12 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/utils/database.utils.dart';
import 'package:platform/platform.dart'; import 'package:platform/platform.dart';
enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum, name, assetCount, newestAsset } enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum, name, assetCount, newestAsset }
@ -49,6 +50,13 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
return query.map((row) => row.readTable(_db.localAlbumEntity).toDto(assetCount: row.read(assetCount) ?? 0)).get(); return query.map((row) => row.readTable(_db.localAlbumEntity).toDto(assetCount: row.read(assetCount) ?? 0)).get();
} }
Future<List<LocalAlbum>> getBackupAlbums() async {
final query = _db.localAlbumEntity.select()
..where((row) => row.backupSelection.equalsValue(BackupSelection.selected));
return query.map((row) => row.toDto()).get();
}
Future<void> delete(String albumId) => transaction(() async { Future<void> delete(String albumId) => transaction(() async {
// Remove all assets that are only in this particular album // Remove all assets that are only in this particular album
// We cannot remove all assets in the album because they might be in other albums in iOS // We cannot remove all assets in the album because they might be in other albums in iOS
@ -335,4 +343,16 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
Future<int> getCount() { Future<int> getCount() {
return _db.managers.localAlbumEntity.count(); return _db.managers.localAlbumEntity.count();
} }
Future unlinkRemoteAlbum(String id) async {
return _db.localAlbumEntity.update()
..where((row) => row.id.equals(id))
..write(const LocalAlbumEntityCompanion(linkedRemoteAlbumId: Value(null)));
}
Future linkRemoteAlbum(String localAlbumId, String remoteAlbumId) async {
return _db.localAlbumEntity.update()
..where((row) => row.id.equals(localAlbumId))
..write(LocalAlbumEntityCompanion(linkedRemoteAlbumId: Value(remoteAlbumId)));
}
} }

View file

@ -113,6 +113,15 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
.getSingleOrNull(); .getSingleOrNull();
} }
Future<RemoteAlbum?> getByName(String albumName, String ownerId) {
final query = _db.remoteAlbumEntity.select()
..where((row) => row.name.equals(albumName) & row.ownerId.equals(ownerId))
..orderBy([(row) => OrderingTerm.desc(row.createdAt)])
..limit(1);
return query.map((row) => row.toDto(ownerName: '', isShared: false)).getSingleOrNull();
}
Future<void> create(RemoteAlbum album, List<String> assetIds) async { Future<void> create(RemoteAlbum album, List<String> assetIds) async {
await _db.transaction(() async { await _db.transaction(() async {
final entity = RemoteAlbumEntityCompanion( final entity = RemoteAlbumEntityCompanion(
@ -321,6 +330,42 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
Future<int> getCount() { Future<int> getCount() {
return _db.managers.remoteAlbumEntity.count(); return _db.managers.remoteAlbumEntity.count();
} }
Future<List<String>> getLinkedAssetIds(String userId, String localAlbumId, String remoteAlbumId) async {
// Find remote asset ids that:
// 1. Belong to the provided local album (via local_album_asset_entity)
// 2. Have been uploaded (i.e. a matching remote asset exists for the same checksum & owner)
// 3. Are NOT already in the remote album (remote_album_asset_entity)
final query = _db.remoteAssetEntity.selectOnly()
..addColumns([_db.remoteAssetEntity.id])
..join([
innerJoin(
_db.localAssetEntity,
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
useColumns: false,
),
innerJoin(
_db.localAlbumAssetEntity,
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
useColumns: false,
),
// Left join remote album assets to exclude those already in the remote album
leftOuterJoin(
_db.remoteAlbumAssetEntity,
_db.remoteAlbumAssetEntity.assetId.equalsExp(_db.remoteAssetEntity.id) &
_db.remoteAlbumAssetEntity.albumId.equals(remoteAlbumId),
useColumns: false,
),
])
..where(
_db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteAssetEntity.deletedAt.isNull() &
_db.localAlbumAssetEntity.albumId.equals(localAlbumId) &
_db.remoteAlbumAssetEntity.assetId.isNull(), // only those not yet linked
);
return query.map((row) => row.read(_db.remoteAssetEntity.id)!).get();
}
} }
extension on RemoteAlbumEntityData { extension on RemoteAlbumEntityData {

View file

@ -7,6 +7,7 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
@ -26,10 +27,10 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
String _searchQuery = ''; String _searchQuery = '';
bool _isSearchMode = false; bool _isSearchMode = false;
int _initialTotalAssetCount = 0; int _initialTotalAssetCount = 0;
bool _hasPopped = false;
late ValueNotifier<bool> _enableSyncUploadAlbum; late ValueNotifier<bool> _enableSyncUploadAlbum;
late TextEditingController _searchController; late TextEditingController _searchController;
late FocusNode _searchFocusNode; late FocusNode _searchFocusNode;
Future? _handleLinkedAlbumFuture;
@override @override
void initState() { void initState() {
@ -44,6 +45,36 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
_initialTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount)); _initialTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
} }
Future<void> _handlePagePopped() async {
final user = ref.read(currentUserProvider);
if (user == null) {
return;
}
final enableSyncUploadAlbum = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
final selectedAlbums = ref
.read(backupAlbumProvider)
.where((a) => a.backupSelection == BackupSelection.selected)
.toList();
if (enableSyncUploadAlbum && selectedAlbums.isNotEmpty) {
setState(() {
_handleLinkedAlbumFuture = ref.read(syncLinkedAlbumServiceProvider).manageLinkedAlbums(selectedAlbums, user.id);
});
await _handleLinkedAlbumFuture;
}
// Restart backup if total count changed and backup is enabled
final currentTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
final totalChanged = currentTotalAssetCount != _initialTotalAssetCount;
final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
if (totalChanged && isBackupEnabled) {
await ref.read(driftBackupProvider.notifier).cancel();
await ref.read(driftBackupProvider.notifier).startBackup(user.id);
}
}
@override @override
void dispose() { void dispose() {
_enableSyncUploadAlbum.dispose(); _enableSyncUploadAlbum.dispose();
@ -65,42 +96,12 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
final selectedBackupAlbums = albums.where((album) => album.backupSelection == BackupSelection.selected).toList(); final selectedBackupAlbums = albums.where((album) => album.backupSelection == BackupSelection.selected).toList();
final excludedBackupAlbums = albums.where((album) => album.backupSelection == BackupSelection.excluded).toList(); final excludedBackupAlbums = albums.where((album) => album.backupSelection == BackupSelection.excluded).toList();
// handleSyncAlbumToggle(bool isEnable) async {
// if (isEnable) {
// await ref.read(albumProvider.notifier).refreshRemoteAlbums();
// for (final album in selectedBackupAlbums) {
// await ref.read(albumProvider.notifier).createSyncAlbum(album.name);
// }
// }
// }
return PopScope( return PopScope(
onPopInvokedWithResult: (didPop, result) async { canPop: false,
// There is an issue with Flutter where the pop event onPopInvokedWithResult: (didPop, _) async {
// can be triggered multiple times, so we guard it with _hasPopped if (!didPop) {
if (didPop && !_hasPopped) { await _handlePagePopped();
_hasPopped = true; Navigator.of(context).pop();
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
return;
}
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
final currentTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
if (currentTotalAssetCount != _initialTotalAssetCount) {
final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
if (!isBackupEnabled) {
return;
}
final backupNotifier = ref.read(driftBackupProvider.notifier);
backupNotifier.cancel().then((_) {
backupNotifier.startBackup(currentUser.id);
});
}
} }
}, },
child: Scaffold( child: Scaffold(
@ -139,103 +140,123 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
], ],
elevation: 0, elevation: 0,
), ),
body: CustomScrollView( body: Stack(
physics: const ClampingScrollPhysics(), children: [
slivers: [ CustomScrollView(
SliverToBoxAdapter( physics: const ClampingScrollPhysics(),
child: Column( slivers: [
crossAxisAlignment: CrossAxisAlignment.start, SliverToBoxAdapter(
children: [ child: Column(
Padding( crossAxisAlignment: CrossAxisAlignment.start,
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), children: [
child: Text( Padding(
"backup_album_selection_page_selection_info", padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
style: context.textTheme.titleSmall, child: Text(
).t(context: context), "backup_album_selection_page_selection_info",
), style: context.textTheme.titleSmall,
).t(context: context),
),
// Selected Album Chips // Selected Album Chips
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Wrap( child: Wrap(
children: [ children: [
_SelectedAlbumNameChips(selectedBackupAlbums: selectedBackupAlbums), _SelectedAlbumNameChips(selectedBackupAlbums: selectedBackupAlbums),
_ExcludedAlbumNameChips(excludedBackupAlbums: excludedBackupAlbums), _ExcludedAlbumNameChips(excludedBackupAlbums: excludedBackupAlbums),
], ],
), ),
), ),
ListTile(
// SettingsSwitchListTile( title: Text(
// valueNotifier: _enableSyncUploadAlbum, "albums_on_device_count".t(context: context, args: {'count': albumCount.toString()}),
// title: "sync_albums".t(context: context), style: context.textTheme.titleSmall,
// subtitle: "sync_upload_album_setting_subtitle".t(context: context), ),
// contentPadding: const EdgeInsets.symmetric(horizontal: 16), subtitle: Padding(
// titleStyle: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold), padding: const EdgeInsets.symmetric(vertical: 8.0),
// subtitleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.primary), child: Text(
// onChanged: handleSyncAlbumToggle, "backup_album_selection_page_albums_tap",
// ), style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
ListTile( ).t(context: context),
title: Text( ),
"albums_on_device_count".t(context: context, args: {'count': albumCount.toString()}), trailing: IconButton(
style: context.textTheme.titleSmall, splashRadius: 16,
), icon: Icon(Icons.info, size: 20, color: context.primaryColor),
subtitle: Padding( onPressed: () {
padding: const EdgeInsets.symmetric(vertical: 8.0), showDialog(
child: Text( context: context,
"backup_album_selection_page_albums_tap", builder: (BuildContext context) {
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor), return AlertDialog(
).t(context: context), shape: const RoundedRectangleBorder(
), borderRadius: BorderRadius.all(Radius.circular(10)),
trailing: IconButton( ),
splashRadius: 16, elevation: 5,
icon: Icon(Icons.info, size: 20, color: context.primaryColor), title: Text(
onPressed: () { 'backup_album_selection_page_selection_info',
// show the dialog style: TextStyle(
showDialog( fontSize: 16,
context: context, fontWeight: FontWeight.bold,
builder: (BuildContext context) { color: context.primaryColor,
return AlertDialog( ),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))), ).t(context: context),
elevation: 5, content: SingleChildScrollView(
title: Text( child: ListBody(
'backup_album_selection_page_selection_info', children: [
style: TextStyle( const Text(
fontSize: 16, 'backup_album_selection_page_assets_scatter',
fontWeight: FontWeight.bold, style: TextStyle(fontSize: 14),
color: context.primaryColor, ).t(context: context),
), ],
).t(context: context), ),
content: SingleChildScrollView( ),
child: ListBody( );
children: [ },
const Text(
'backup_album_selection_page_assets_scatter',
style: TextStyle(fontSize: 14),
).t(context: context),
],
),
),
); );
}, },
); ),
}, ),
),
),
if (Platform.isAndroid) if (Platform.isAndroid)
_SelectAllButton(filteredAlbums: filteredAlbums, selectedBackupAlbums: selectedBackupAlbums), _SelectAllButton(filteredAlbums: filteredAlbums, selectedBackupAlbums: selectedBackupAlbums),
], ],
),
),
SliverLayoutBuilder(
builder: (context, constraints) {
if (constraints.crossAxisExtent > 600) {
return _AlbumSelectionGrid(filteredAlbums: filteredAlbums, searchQuery: _searchQuery);
} else {
return _AlbumSelectionList(filteredAlbums: filteredAlbums, searchQuery: _searchQuery);
}
},
),
],
),
if (_handleLinkedAlbumFuture != null)
FutureBuilder(
future: _handleLinkedAlbumFuture,
builder: (context, snapshot) {
return SizedBox(
height: double.infinity,
width: double.infinity,
child: Container(
color: context.scaffoldBackgroundColor.withValues(alpha: 0.8),
child: Center(
child: Column(
spacing: 16,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: [
const CircularProgressIndicator(strokeWidth: 4),
Text("Creating linked albums...", style: context.textTheme.labelLarge),
],
),
),
),
);
},
), ),
),
SliverLayoutBuilder(
builder: (context, constraints) {
if (constraints.crossAxisExtent > 600) {
return _AlbumSelectionGrid(filteredAlbums: filteredAlbums, searchQuery: _searchQuery);
} else {
return _AlbumSelectionList(filteredAlbums: filteredAlbums, searchQuery: _searchQuery);
}
},
),
], ],
), ),
), ),

View file

@ -105,6 +105,8 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
]).then((_) async { ]).then((_) async {
final isEnableBackup = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup); final isEnableBackup = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
final isAlbumLinkedSyncEnable = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
if (isEnableBackup) { if (isEnableBackup) {
final currentUser = _ref.read(currentUserProvider); final currentUser = _ref.read(currentUserProvider);
if (currentUser == null) { if (currentUser == null) {
@ -113,6 +115,10 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
await _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id); await _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id);
} }
if (isAlbumLinkedSyncEnable) {
await backgroundManager.syncLinkedAlbum();
}
}); });
} catch (e, stackTrace) { } catch (e, stackTrace) {
Logger("AppLifeCycleNotifier").severe("Error during background sync", e, stackTrace); Logger("AppLifeCycleNotifier").severe("Error during background sync", e, stackTrace);

View file

@ -12,7 +12,6 @@ import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
// import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
@ -323,7 +322,11 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
} }
try { try {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketBatch(_batchedAssetUploadReady.toList())); unawaited(
_ref.read(backgroundSyncProvider).syncWebsocketBatch(_batchedAssetUploadReady.toList()).then((_) {
return _ref.read(backgroundSyncProvider).syncLinkedAlbum();
}),
);
} catch (error) { } catch (error) {
_log.severe("Error processing batched AssetUploadReadyV1 events: $error"); _log.severe("Error processing batched AssetUploadReadyV1 events: $error");
} }

View file

@ -1,31 +0,0 @@
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
extension LocalAlbumEntityDataHelper on LocalAlbumEntityData {
LocalAlbum toDto({int assetCount = 0}) {
return LocalAlbum(
id: id,
name: name,
updatedAt: updatedAt,
assetCount: assetCount,
backupSelection: backupSelection,
);
}
}
extension LocalAssetEntityDataHelper on LocalAssetEntityData {
LocalAsset toDto() {
return LocalAsset(
id: id,
name: name,
checksum: checksum,
type: type,
createdAt: createdAt,
updatedAt: updatedAt,
durationInSeconds: durationInSeconds,
isFavorite: isFavorite,
);
}
}

View file

@ -23,8 +23,10 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@ -268,11 +270,17 @@ Future<List<void>> runNewSync(WidgetRef ref, {bool full = false}) {
ref.read(backupProvider.notifier).cancelBackup(); ref.read(backupProvider.notifier).cancelBackup();
final backgroundManager = ref.read(backgroundSyncProvider); final backgroundManager = ref.read(backgroundSyncProvider);
final isAlbumLinkedSyncEnable = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
return Future.wait([ return Future.wait([
backgroundManager.syncLocal(full: full).then((_) { backgroundManager.syncLocal(full: full).then((_) {
Logger("runNewSync").fine("Hashing assets after syncLocal"); Logger("runNewSync").fine("Hashing assets after syncLocal");
return backgroundManager.hashAssets(); return backgroundManager.hashAssets();
}), }),
backgroundManager.syncRemote(), backgroundManager.syncRemote().then((_) {
if (isAlbumLinkedSyncEnable) {
return backgroundManager.syncLinkedAlbum();
}
}),
]); ]);
} }

View file

@ -1,19 +1,153 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
class DriftBackupSettings extends StatelessWidget { class DriftBackupSettings extends ConsumerWidget {
const DriftBackupSettings({super.key}); const DriftBackupSettings({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return const SettingsSubPageScaffold(
settings: [
_UseWifiForUploadVideosButton(),
_UseWifiForUploadPhotosButton(),
Divider(indent: 16, endIndent: 16),
_AlbumSyncActionButton(),
],
);
}
}
class _AlbumSyncActionButton extends ConsumerStatefulWidget {
const _AlbumSyncActionButton();
@override
ConsumerState<_AlbumSyncActionButton> createState() => _AlbumSyncActionButtonState();
}
class _AlbumSyncActionButtonState extends ConsumerState<_AlbumSyncActionButton> {
bool isAlbumSyncInProgress = false;
Future<void> _manualSyncAlbums() async {
setState(() {
isAlbumSyncInProgress = true;
});
try {
await ref.read(backgroundSyncProvider).syncLinkedAlbum();
await ref.read(backgroundSyncProvider).syncRemote();
} catch (_) {
} finally {
Future.delayed(const Duration(seconds: 1), () {
setState(() {
isAlbumSyncInProgress = false;
});
});
}
}
Future<void> _manageLinkedAlbums() async {
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
return;
}
final localAlbums = ref.read(backupAlbumProvider);
final selectedBackupAlbums = localAlbums
.where((album) => album.backupSelection == BackupSelection.selected)
.toList();
await ref.read(syncLinkedAlbumServiceProvider).manageLinkedAlbums(selectedBackupAlbums, currentUser.id);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return const SettingsSubPageScaffold(settings: [_UseWifiForUploadVideosButton(), _UseWifiForUploadPhotosButton()]); return ListView(
shrinkWrap: true,
children: [
StreamBuilder(
stream: Store.watch(StoreKey.syncAlbums),
initialData: Store.tryGet(StoreKey.syncAlbums) ?? false,
builder: (context, snapshot) {
final albumSyncEnable = snapshot.data ?? false;
return Column(
children: [
ListTile(
title: Text(
"sync_albums".t(context: context),
style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor),
),
subtitle: Text(
"sync_upload_album_setting_subtitle".t(context: context),
style: context.textTheme.labelLarge,
),
trailing: Switch(
value: albumSyncEnable,
onChanged: (bool newValue) async {
await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.syncAlbums, newValue);
if (newValue == true) {
await _manageLinkedAlbums();
}
},
),
),
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: albumSyncEnable ? 1.0 : 0.0,
child: albumSyncEnable
? ListTile(
onTap: _manualSyncAlbums,
contentPadding: const EdgeInsets.only(left: 32, right: 16),
title: Text(
"organize_into_albums".t(context: context),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.normal,
),
),
subtitle: Text(
"organize_into_albums_description".t(context: context),
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
trailing: isAlbumSyncInProgress
? const SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
)
: IconButton(
onPressed: _manualSyncAlbums,
icon: const Icon(Icons.sync_rounded),
color: context.colorScheme.onSurface.withValues(alpha: 0.7),
iconSize: 20,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
),
)
: const SizedBox.shrink(),
),
),
],
);
},
),
],
);
} }
} }

View file

@ -109,6 +109,37 @@ class BetaSyncSettings extends HookConsumerWidget {
await ref.read(storageRepositoryProvider).clearCache(); await ref.read(storageRepositoryProvider).clearCache();
} }
Future<void> resetSqliteDb(BuildContext context, Future<void> Function() resetDatabase) {
return showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text("reset_sqlite".t(context: context)),
content: Text("reset_sqlite_confirmation".t(context: context)),
actions: [
TextButton(
onPressed: () => context.pop(),
child: Text("cancel".t(context: context)),
),
TextButton(
onPressed: () async {
await resetDatabase();
context.pop();
context.scaffoldMessenger.showSnackBar(
SnackBar(content: Text("reset_sqlite_success".t(context: context))),
);
},
child: Text(
"confirm".t(context: context),
style: TextStyle(color: context.colorScheme.error),
),
),
],
);
},
);
}
return FutureBuilder<List<dynamic>>( return FutureBuilder<List<dynamic>>(
future: loadCounts(), future: loadCounts(),
builder: (context, snapshot) { builder: (context, snapshot) {
@ -116,6 +147,33 @@ class BetaSyncSettings extends HookConsumerWidget {
return const CircularProgressIndicator(); return const CircularProgressIndicator();
} }
if (snapshot.hasError) {
return ListView(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Center(
child: Text(
"Error occur, reset the local database by tapping the button below",
style: context.textTheme.bodyLarge,
),
),
),
ListTile(
title: Text(
"reset_sqlite".t(context: context),
style: TextStyle(color: context.colorScheme.error, fontWeight: FontWeight.w500),
),
leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error),
onTap: () async {
await resetSqliteDb(context, resetDatabase);
},
),
],
);
}
final assetCounts = snapshot.data![0]! as (int, int); final assetCounts = snapshot.data![0]! as (int, int);
final localAssetCount = assetCounts.$1; final localAssetCount = assetCounts.$1;
final remoteAssetCount = assetCounts.$2; final remoteAssetCount = assetCounts.$2;
@ -270,34 +328,7 @@ class BetaSyncSettings extends HookConsumerWidget {
), ),
leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error), leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error),
onTap: () async { onTap: () async {
showDialog( await resetSqliteDb(context, resetDatabase);
context: context,
builder: (context) {
return AlertDialog(
title: Text("reset_sqlite".t(context: context)),
content: Text("reset_sqlite_confirmation".t(context: context)),
actions: [
TextButton(
onPressed: () => context.pop(),
child: Text("cancel".t(context: context)),
),
TextButton(
onPressed: () async {
await resetDatabase();
context.pop();
context.scaffoldMessenger.showSnackBar(
SnackBar(content: Text("reset_sqlite_success".t(context: context))),
);
},
child: Text(
"confirm".t(context: context),
style: TextStyle(color: context.colorScheme.error),
),
),
],
);
},
);
}, },
), ),
], ],

Binary file not shown.

Binary file not shown.