diff --git a/i18n/en.json b/i18n/en.json index 9e7b6fae6..f8a51eb5f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1417,6 +1417,8 @@ "open_the_search_filters": "Open the search filters", "options": "Options", "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", "original": "original", "other": "Other", diff --git a/mobile/drift_schemas/main/drift_schema_v9.json b/mobile/drift_schemas/main/drift_schema_v9.json new file mode 100644 index 000000000..5b08a752e Binary files /dev/null and b/mobile/drift_schemas/main/drift_schema_v9.json differ diff --git a/mobile/lib/domain/models/album/local_album.model.dart b/mobile/lib/domain/models/album/local_album.model.dart index b0b08937a..ea06118aa 100644 --- a/mobile/lib/domain/models/album/local_album.model.dart +++ b/mobile/lib/domain/models/album/local_album.model.dart @@ -15,6 +15,7 @@ class LocalAlbum { final int assetCount; final BackupSelection backupSelection; + final String? linkedRemoteAlbumId; const LocalAlbum({ required this.id, @@ -23,6 +24,7 @@ class LocalAlbum { this.assetCount = 0, this.backupSelection = BackupSelection.none, this.isIosSharedAlbum = false, + this.linkedRemoteAlbumId, }); LocalAlbum copyWith({ @@ -32,6 +34,7 @@ class LocalAlbum { int? assetCount, BackupSelection? backupSelection, bool? isIosSharedAlbum, + String? linkedRemoteAlbumId, }) { return LocalAlbum( id: id ?? this.id, @@ -40,6 +43,7 @@ class LocalAlbum { assetCount: assetCount ?? this.assetCount, backupSelection: backupSelection ?? this.backupSelection, isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, + linkedRemoteAlbumId: linkedRemoteAlbumId ?? this.linkedRemoteAlbumId, ); } @@ -53,7 +57,8 @@ class LocalAlbum { other.updatedAt == updatedAt && other.assetCount == assetCount && other.backupSelection == backupSelection && - other.isIosSharedAlbum == isIosSharedAlbum; + other.isIosSharedAlbum == isIosSharedAlbum && + other.linkedRemoteAlbumId == linkedRemoteAlbumId; } @override @@ -63,7 +68,8 @@ class LocalAlbum { updatedAt.hashCode ^ assetCount.hashCode ^ backupSelection.hashCode ^ - isIosSharedAlbum.hashCode; + isIosSharedAlbum.hashCode ^ + linkedRemoteAlbumId.hashCode; } @override @@ -75,6 +81,7 @@ updatedAt: $updatedAt, assetCount: $assetCount, backupSelection: $backupSelection, isIosSharedAlbum: $isIosSharedAlbum +linkedRemoteAlbumId: $linkedRemoteAlbumId, }'''; } } diff --git a/mobile/lib/domain/services/local_album.service.dart b/mobile/lib/domain/services/local_album.service.dart index 6c1479fdc..e3d888f06 100644 --- a/mobile/lib/domain/services/local_album.service.dart +++ b/mobile/lib/domain/services/local_album.service.dart @@ -22,4 +22,16 @@ class LocalAlbumService { Future getCount() { return _repository.getCount(); } + + Future unlinkRemoteAlbum(String id) async { + return _repository.unlinkRemoteAlbum(id); + } + + Future linkRemoteAlbum(String localAlbumId, String remoteAlbumId) async { + return _repository.linkRemoteAlbum(localAlbumId, remoteAlbumId); + } + + Future> getBackupAlbums() { + return _repository.getBackupAlbums(); + } } diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart index 4d85119b7..cc28dfafd 100644 --- a/mobile/lib/domain/services/remote_album.service.dart +++ b/mobile/lib/domain/services/remote_album.service.dart @@ -26,6 +26,10 @@ class RemoteAlbumService { return _repository.get(albumId); } + Future getByName(String albumName, String ownerId) { + return _repository.getByName(albumName, ownerId); + } + Future> sortAlbums( List albums, RemoteAlbumSortMode sortMode, { @@ -80,7 +84,6 @@ class RemoteAlbumService { Future createAlbum({required String title, required List assetIds, String? description}) async { final album = await _albumApiRepository.createDriftAlbum(title, description: description, assetIds: assetIds); - await _repository.create(album, assetIds); return album; diff --git a/mobile/lib/domain/services/sync_linked_album.service.dart b/mobile/lib/domain/services/sync_linked_album.service.dart new file mode 100644 index 000000000..37e52e6c1 --- /dev/null +++ b/mobile/lib/domain/services/sync_linked_album.service.dart @@ -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 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 manageLinkedAlbums(List 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 _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 _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 _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 _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 _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); + } +} diff --git a/mobile/lib/domain/utils/background_sync.dart b/mobile/lib/domain/utils/background_sync.dart index d8042c707..1cb6820ab 100644 --- a/mobile/lib/domain/utils/background_sync.dart +++ b/mobile/lib/domain/utils/background_sync.dart @@ -1,5 +1,6 @@ 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/utils/isolate.dart'; import 'package:worker_manager/worker_manager.dart'; @@ -155,6 +156,11 @@ class BackgroundSyncManager { _syncWebsocketTask = null; }); } + + Future syncLinkedAlbum() { + final task = runInIsolateGentle(computation: syncLinkedAlbumsIsolated); + return task.future; + } } Cancelable _handleWsAssetUploadReadyV1Batch(List batchData) => runInIsolateGentle( diff --git a/mobile/lib/domain/utils/sync_linked_album.dart b/mobile/lib/domain/utils/sync_linked_album.dart new file mode 100644 index 000000000..9df69799a --- /dev/null +++ b/mobile/lib/domain/utils/sync_linked_album.dart @@ -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 syncLinkedAlbumsIsolated(ProviderContainer ref) { + final user = ref.read(currentUserProvider); + if (user == null) { + return Future.value(); + } + return ref.read(syncLinkedAlbumServiceProvider).syncLinkedAlbums(user.id); +} diff --git a/mobile/lib/infrastructure/entities/local_album.entity.dart b/mobile/lib/infrastructure/entities/local_album.entity.dart index c796a1295..707d3326a 100644 --- a/mobile/lib/infrastructure/entities/local_album.entity.dart +++ b/mobile/lib/infrastructure/entities/local_album.entity.dart @@ -1,5 +1,7 @@ import 'package:drift/drift.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'; class LocalAlbumEntity extends Table with DriftDefaultsMixin { @@ -11,9 +13,26 @@ class LocalAlbumEntity extends Table with DriftDefaultsMixin { IntColumn get backupSelection => intEnum()(); 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 BoolColumn get marker_ => boolean().nullable()(); @override Set 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, + ); + } +} diff --git a/mobile/lib/infrastructure/entities/local_album.entity.drift.dart b/mobile/lib/infrastructure/entities/local_album.entity.drift.dart index 5be349c8e..070384429 100644 Binary files a/mobile/lib/infrastructure/entities/local_album.entity.drift.dart and b/mobile/lib/infrastructure/entities/local_album.entity.drift.dart differ diff --git a/mobile/lib/infrastructure/entities/local_asset.entity.dart b/mobile/lib/infrastructure/entities/local_asset.entity.dart index 3130e41db..337a6d728 100644 --- a/mobile/lib/infrastructure/entities/local_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/local_asset.entity.dart @@ -20,7 +20,7 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { Set get primaryKey => {id}; } -extension LocalAssetEntityDataDomainEx on LocalAssetEntityData { +extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData { LocalAsset toDto() => LocalAsset( id: id, name: name, diff --git a/mobile/lib/infrastructure/repositories/backup.repository.dart b/mobile/lib/infrastructure/repositories/backup.repository.dart index d98067d5f..057c7a7bf 100644 --- a/mobile/lib/infrastructure/repositories/backup.repository.dart +++ b/mobile/lib/infrastructure/repositories/backup.repository.dart @@ -4,9 +4,10 @@ import 'package:drift/drift.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/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/providers/infrastructure/db.provider.dart'; -import "package:immich_mobile/utils/database.utils.dart"; final backupRepositoryProvider = Provider( (ref) => DriftBackupRepository(ref.watch(driftProvider)), diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index 386de2269..f8de114f8 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -68,7 +68,7 @@ class Drift extends $Drift implements IDatabaseRepository { : super(executor ?? driftDatabase(name: 'immich', native: const DriftNativeOptions(shareAcrossIsolates: true))); @override - int get schemaVersion => 8; + int get schemaVersion => 9; @override MigrationStrategy get migration => MigrationStrategy( @@ -123,6 +123,9 @@ class Drift extends $Drift implements IDatabaseRepository { from7To8: (m, v8) async { await m.create(v8.storeEntity); }, + from8To9: (m, v9) async { + await m.addColumn(v9.localAlbumEntity, v9.localAlbumEntity.linkedRemoteAlbumId); + }, ), ); diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart index 456296e2d..035f7b0c0 100644 Binary files a/mobile/lib/infrastructure/repositories/db.repository.drift.dart and b/mobile/lib/infrastructure/repositories/db.repository.drift.dart differ diff --git a/mobile/lib/infrastructure/repositories/db.repository.steps.dart b/mobile/lib/infrastructure/repositories/db.repository.steps.dart index 68c54174b..2325c2bcb 100644 Binary files a/mobile/lib/infrastructure/repositories/db.repository.steps.dart and b/mobile/lib/infrastructure/repositories/db.repository.steps.dart differ diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart index 0c2976888..923d6e0a6 100644 --- a/mobile/lib/infrastructure/repositories/local_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart @@ -1,11 +1,12 @@ import 'package:drift/drift.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/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_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/repositories/db.repository.dart'; -import 'package:immich_mobile/utils/database.utils.dart'; import 'package:platform/platform.dart'; 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(); } + Future> getBackupAlbums() async { + final query = _db.localAlbumEntity.select() + ..where((row) => row.backupSelection.equalsValue(BackupSelection.selected)); + + return query.map((row) => row.toDto()).get(); + } + Future delete(String albumId) => transaction(() async { // 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 @@ -335,4 +343,16 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository { Future getCount() { 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))); + } } diff --git a/mobile/lib/infrastructure/repositories/remote_album.repository.dart b/mobile/lib/infrastructure/repositories/remote_album.repository.dart index 44a288787..41f167b3e 100644 --- a/mobile/lib/infrastructure/repositories/remote_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_album.repository.dart @@ -113,6 +113,15 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { .getSingleOrNull(); } + Future 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 create(RemoteAlbum album, List assetIds) async { await _db.transaction(() async { final entity = RemoteAlbumEntityCompanion( @@ -321,6 +330,42 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { Future getCount() { return _db.managers.remoteAlbumEntity.count(); } + + Future> 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 { diff --git a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart index 865845525..e734dc300 100644 --- a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart +++ b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart @@ -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/translate_extensions.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/drift_backup.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -26,10 +27,10 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState _enableSyncUploadAlbum; late TextEditingController _searchController; late FocusNode _searchFocusNode; + Future? _handleLinkedAlbumFuture; @override void initState() { @@ -44,6 +45,36 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState p.totalCount)); } + Future _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 void dispose() { _enableSyncUploadAlbum.dispose(); @@ -65,42 +96,12 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState album.backupSelection == BackupSelection.selected).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( - onPopInvokedWithResult: (didPop, result) async { - // There is an issue with Flutter where the pop event - // can be triggered multiple times, so we guard it with _hasPopped - if (didPop && !_hasPopped) { - _hasPopped = true; - - 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); - }); - } + canPop: false, + onPopInvokedWithResult: (didPop, _) async { + if (!didPop) { + await _handlePagePopped(); + Navigator.of(context).pop(); } }, child: Scaffold( @@ -139,103 +140,123 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState 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); - } - }, - ), ], ), ), diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index ff5dda79c..d7cb7dbaa 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -105,6 +105,8 @@ class AppLifeCycleNotifier extends StateNotifier { ]).then((_) async { final isEnableBackup = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup); + final isAlbumLinkedSyncEnable = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums); + if (isEnableBackup) { final currentUser = _ref.read(currentUserProvider); if (currentUser == null) { @@ -113,6 +115,10 @@ class AppLifeCycleNotifier extends StateNotifier { await _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id); } + + if (isAlbumLinkedSyncEnable) { + await backgroundManager.syncLinkedAlbum(); + } }); } catch (e, stackTrace) { Logger("AppLifeCycleNotifier").severe("Error during background sync", e, stackTrace); diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index fdc21592b..3b0d5daab 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -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/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/db.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; @@ -323,7 +322,11 @@ class WebsocketNotifier extends StateNotifier { } try { - unawaited(_ref.read(backgroundSyncProvider).syncWebsocketBatch(_batchedAssetUploadReady.toList())); + unawaited( + _ref.read(backgroundSyncProvider).syncWebsocketBatch(_batchedAssetUploadReady.toList()).then((_) { + return _ref.read(backgroundSyncProvider).syncLinkedAlbum(); + }), + ); } catch (error) { _log.severe("Error processing batched AssetUploadReadyV1 events: $error"); } diff --git a/mobile/lib/utils/database.utils.dart b/mobile/lib/utils/database.utils.dart deleted file mode 100644 index 446b92db1..000000000 --- a/mobile/lib/utils/database.utils.dart +++ /dev/null @@ -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, - ); - } -} diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 9816986b9..0a786fed0 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -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/user.entity.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/backup/backup.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; @@ -268,11 +270,17 @@ Future> runNewSync(WidgetRef ref, {bool full = false}) { ref.read(backupProvider.notifier).cancelBackup(); final backgroundManager = ref.read(backgroundSyncProvider); + final isAlbumLinkedSyncEnable = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums); + return Future.wait([ backgroundManager.syncLocal(full: full).then((_) { Logger("runNewSync").fine("Hashing assets after syncLocal"); return backgroundManager.hashAssets(); }), - backgroundManager.syncRemote(), + backgroundManager.syncRemote().then((_) { + if (isAlbumLinkedSyncEnable) { + return backgroundManager.syncLinkedAlbum(); + } + }), ]); } diff --git a/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart b/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart index 553eb939c..ac9866d4d 100644 --- a/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart +++ b/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart @@ -1,19 +1,153 @@ 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/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.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/widgets/settings/settings_sub_page_scaffold.dart'; -class DriftBackupSettings extends StatelessWidget { +class DriftBackupSettings extends ConsumerWidget { 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 _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 _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 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(), + ), + ), + ], + ); + }, + ), + ], + ); } } diff --git a/mobile/lib/widgets/settings/beta_sync_settings/beta_sync_settings.dart b/mobile/lib/widgets/settings/beta_sync_settings/beta_sync_settings.dart index 8916fdd92..e5c65a9c6 100644 --- a/mobile/lib/widgets/settings/beta_sync_settings/beta_sync_settings.dart +++ b/mobile/lib/widgets/settings/beta_sync_settings/beta_sync_settings.dart @@ -109,6 +109,37 @@ class BetaSyncSettings extends HookConsumerWidget { await ref.read(storageRepositoryProvider).clearCache(); } + Future resetSqliteDb(BuildContext context, Future 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>( future: loadCounts(), builder: (context, snapshot) { @@ -116,6 +147,33 @@ class BetaSyncSettings extends HookConsumerWidget { 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 localAssetCount = assetCounts.$1; final remoteAssetCount = assetCounts.$2; @@ -270,34 +328,7 @@ class BetaSyncSettings extends HookConsumerWidget { ), leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error), onTap: () async { - 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), - ), - ), - ], - ); - }, - ); + await resetSqliteDb(context, resetDatabase); }, ), ], diff --git a/mobile/test/drift/main/generated/schema.dart b/mobile/test/drift/main/generated/schema.dart index 746206e45..413b4408c 100644 Binary files a/mobile/test/drift/main/generated/schema.dart and b/mobile/test/drift/main/generated/schema.dart differ diff --git a/mobile/test/drift/main/generated/schema_v9.dart b/mobile/test/drift/main/generated/schema_v9.dart new file mode 100644 index 000000000..c2ccb5873 Binary files /dev/null and b/mobile/test/drift/main/generated/schema_v9.dart differ