From f87ae08cd1818215ed0572b1df1452a66b8c0311 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 9 Jun 2025 14:35:33 -0500 Subject: [PATCH] wip --- .../domain/interfaces/backup.interface.dart | 14 + .../repositories/backup.repository.dart | 114 ++++++ .../backup/exp_backup_controller.page.dart | 343 +++++++++--------- .../backup/backup_album.provider.dart | 1 - .../providers/backup/exp_backup.provider.dart | 73 ++++ mobile/lib/routing/router.dart | 4 + mobile/lib/services/exp_backup.service.dart | 31 ++ 7 files changed, 403 insertions(+), 177 deletions(-) create mode 100644 mobile/lib/domain/interfaces/backup.interface.dart create mode 100644 mobile/lib/infrastructure/repositories/backup.repository.dart create mode 100644 mobile/lib/providers/backup/exp_backup.provider.dart create mode 100644 mobile/lib/services/exp_backup.service.dart diff --git a/mobile/lib/domain/interfaces/backup.interface.dart b/mobile/lib/domain/interfaces/backup.interface.dart new file mode 100644 index 0000000000..61ab41d16e --- /dev/null +++ b/mobile/lib/domain/interfaces/backup.interface.dart @@ -0,0 +1,14 @@ +import 'package:immich_mobile/domain/interfaces/db.interface.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/local_album.model.dart'; + +abstract interface class IBackupRepository implements IDatabaseRepository { + Future> getAssets(String albumId); + + Future> getAssetIds(String albumId); + + /// Returns the total number of assets that are selected for backup. + Future getTotalCount(BackupSelection selection); + + Future getBackupCount(); +} diff --git a/mobile/lib/infrastructure/repositories/backup.repository.dart b/mobile/lib/infrastructure/repositories/backup.repository.dart new file mode 100644 index 0000000000..f23293ef6c --- /dev/null +++ b/mobile/lib/infrastructure/repositories/backup.repository.dart @@ -0,0 +1,114 @@ +import 'package:drift/drift.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/interfaces/backup.interface.dart'; +import 'package:immich_mobile/domain/interfaces/local_album.interface.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/local_album.model.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.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:platform/platform.dart'; + +final backupRepositoryProvider = Provider( + (ref) => DriftBackupRepository(ref.watch(driftProvider)), +); + +class DriftBackupRepository extends DriftDatabaseRepository + implements IBackupRepository { + final Drift _db; + final Platform _platform; + const DriftBackupRepository(this._db, {Platform? platform}) + : _platform = platform ?? const LocalPlatform(), + super(_db); + + @override + Future> getAssets(String albumId) { + final query = _db.localAlbumAssetEntity.select().join( + [ + innerJoin( + _db.localAssetEntity, + _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id), + ), + ], + ) + ..where(_db.localAlbumAssetEntity.albumId.equals(albumId)) + ..orderBy([OrderingTerm.asc(_db.localAssetEntity.id)]); + return query + .map((row) => row.readTable(_db.localAssetEntity).toDto()) + .get(); + } + + @override + Future> getAssetIds(String albumId) { + final query = _db.localAlbumAssetEntity.selectOnly() + ..addColumns([_db.localAlbumAssetEntity.assetId]) + ..where(_db.localAlbumAssetEntity.albumId.equals(albumId)); + return query + .map((row) => row.read(_db.localAlbumAssetEntity.assetId)!) + .get(); + } + + @override + Future getTotalCount(BackupSelection selection) { + final query = _db.localAlbumAssetEntity.selectOnly(distinct: true) + ..addColumns([_db.localAlbumAssetEntity.assetId]) + ..join([ + innerJoin( + _db.localAlbumEntity, + _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id), + ), + ]) + ..where( + _db.localAlbumEntity.backupSelection.equals(selection.index), + ); + + return query.get().then((rows) => rows.length); + } + + @override + Future getBackupCount() { + final query = _db.localAlbumEntity.select().join( + [ + innerJoin( + _db.localAlbumAssetEntity, + _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id), + ), + ], + )..where( + _db.localAlbumEntity.backupSelection.equals( + BackupSelection.selected.index, + ), + ); + + return query.get().then((rows) => rows.length); + } +} + +extension on LocalAlbumEntityData { + LocalAlbum toDto({int assetCount = 0}) { + return LocalAlbum( + id: id, + name: name, + updatedAt: updatedAt, + assetCount: assetCount, + backupSelection: backupSelection, + ); + } +} + +extension 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/pages/backup/exp_backup_controller.page.dart b/mobile/lib/pages/backup/exp_backup_controller.page.dart index 3848ea3cd1..db6ca16522 100644 --- a/mobile/lib/pages/backup/exp_backup_controller.page.dart +++ b/mobile/lib/pages/backup/exp_backup_controller.page.dart @@ -10,15 +10,14 @@ import 'package:immich_mobile/domain/models/local_album.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; -import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart'; +import 'package:immich_mobile/providers/backup/exp_backup.provider.dart'; import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/services/upload.service.dart'; import 'package:immich_mobile/widgets/backup/backup_info_card.dart'; import 'package:immich_mobile/widgets/backup/current_backup_asset_info_box.dart'; import 'package:immich_mobile/widgets/backup/exp_upload_option_toggle.dart'; @@ -43,6 +42,14 @@ class ExpBackupPage extends HookConsumerWidget { ? false : true; + useEffect( + () { + ref.read(expBackupProvider.notifier).getBackupStatus(); + return null; + }, + [], + ); + useEffect( () { // Update the background settings information just to make sure we @@ -88,131 +95,6 @@ class ExpBackupPage extends HookConsumerWidget { [backupState.backupProgress], ); - Widget buildSelectedAlbumName() { - String text = "backup_controller_page_backup_selected".tr(); - final albums = ref - .watch(backupAlbumProvider) - .where( - (album) => album.backupSelection == BackupSelection.selected, - ) - .toList(); - - if (albums.isNotEmpty) { - for (var album in albums) { - if (album.name == "Recent" || album.name == "Recents") { - text += "${album.name} (${'all'.tr()}), "; - } else { - text += "${album.name}, "; - } - } - - return Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Text( - text.trim().substring(0, text.length - 2), - style: context.textTheme.labelLarge?.copyWith( - color: context.primaryColor, - ), - ), - ); - } else { - return Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Text( - "backup_controller_page_none_selected".tr(), - style: context.textTheme.labelLarge?.copyWith( - color: context.primaryColor, - ), - ), - ); - } - } - - Widget buildExcludedAlbumName() { - String text = "backup_controller_page_excluded".tr(); - final albums = ref - .watch(backupAlbumProvider) - .where( - (album) => album.backupSelection == BackupSelection.excluded, - ) - .toList(); - - if (albums.isNotEmpty) { - for (var album in albums) { - text += "${album.name}, "; - } - - return Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Text( - text.trim().substring(0, text.length - 2), - style: context.textTheme.labelLarge?.copyWith( - color: Colors.red[300], - ), - ), - ); - } else { - return const SizedBox(); - } - } - - buildFolderSelectionTile() { - return Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - side: BorderSide( - color: context.colorScheme.outlineVariant, - width: 1, - ), - ), - elevation: 0, - borderOnForeground: false, - child: ListTile( - minVerticalPadding: 18, - title: Text( - "backup_controller_page_albums", - style: context.textTheme.titleMedium, - ).tr(), - subtitle: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "backup_controller_page_to_backup", - style: context.textTheme.bodyMedium?.copyWith( - color: context.colorScheme.onSurfaceSecondary, - ), - ).tr(), - buildSelectedAlbumName(), - buildExcludedAlbumName(), - ], - ), - ), - trailing: ElevatedButton( - onPressed: () async { - await context.pushRoute(const ExpBackupAlbumSelectionRoute()); - // waited until returning from selection - await ref - .read(backupProvider.notifier) - .backupAlbumSelectionDone(); - // waited until backup albums are stored in DB - ref.read(albumProvider.notifier).refreshDeviceAlbums(); - }, - child: const Text( - "select", - style: TextStyle( - fontWeight: FontWeight.bold, - ), - ).tr(), - ), - ), - ), - ); - } - void startBackup() { ref.watch(errorBackupListProvider.notifier).empty(); if (ref.watch(backupProvider).backupProgress != @@ -329,61 +211,17 @@ class ExpBackupPage extends HookConsumerWidget { onToggle: () => context.replaceRoute(const BackupControllerRoute()), ), - buildFolderSelectionTile(), - BackupInfoCard( - title: "total".tr(), - subtitle: "backup_controller_page_total_sub".tr(), - info: ref.watch(backupProvider).availableAlbums.isEmpty - ? "..." - : "${backupState.allUniqueAssets.length}", - ), - BackupInfoCard( - title: "backup_controller_page_backup".tr(), - subtitle: "backup_controller_page_backup_sub".tr(), - info: ref.watch(backupProvider).availableAlbums.isEmpty - ? "..." - : "${backupState.selectedAlbumsBackupAssetsIds.length}", - ), - BackupInfoCard( - title: "backup_controller_page_remainder".tr(), - subtitle: "backup_controller_page_remainder_sub".tr(), - info: ref.watch(backupProvider).availableAlbums.isEmpty - ? "..." - : "${max(0, backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length)}", - ), + const SizedBox(height: 8), + const BackupAlbumSelectionCard(), + const TotalCard(), + const RemainderCard(), const Divider(), const CurrentUploadingAssetInfoBox(), if (!hasExclusiveAccess) buildBackgroundBackupInfo(), buildBackupButton(), - ElevatedButton( - onPressed: () { - ref.watch(uploadServiceProvider).getRecords(); - }, - child: const Text( - "get record", - ), - ), - ElevatedButton( - onPressed: () { - ref - .watch(uploadServiceProvider) - .deleteAllUploadTasks(); - }, - child: const Text( - "clear records", - ), - ), - ElevatedButton( - onPressed: () { - ref.watch(uploadServiceProvider).cancelAllUpload(); - }, - child: const Text( - "cancel all uploads", - ), - ), ] : [ - buildFolderSelectionTile(), + const BackupAlbumSelectionCard(), if (!didGetBackupInfo.value) buildLoadingIndicator(), ], ), @@ -393,3 +231,156 @@ class ExpBackupPage extends HookConsumerWidget { ); } } + +class BackupAlbumSelectionCard extends ConsumerWidget { + const BackupAlbumSelectionCard({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + Widget buildSelectedAlbumName() { + String text = "backup_controller_page_backup_selected".tr(); + final albums = ref + .watch(backupAlbumProvider) + .where( + (album) => album.backupSelection == BackupSelection.selected, + ) + .toList(); + + if (albums.isNotEmpty) { + for (var album in albums) { + if (album.name == "Recent" || album.name == "Recents") { + text += "${album.name} (${'all'.tr()}), "; + } else { + text += "${album.name}, "; + } + } + + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + text.trim().substring(0, text.length - 2), + style: context.textTheme.labelLarge?.copyWith( + color: context.primaryColor, + ), + ), + ); + } else { + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + "backup_controller_page_none_selected".tr(), + style: context.textTheme.labelLarge?.copyWith( + color: context.primaryColor, + ), + ), + ); + } + } + + Widget buildExcludedAlbumName() { + String text = "backup_controller_page_excluded".tr(); + final albums = ref + .watch(backupAlbumProvider) + .where( + (album) => album.backupSelection == BackupSelection.excluded, + ) + .toList(); + + if (albums.isNotEmpty) { + for (var album in albums) { + text += "${album.name}, "; + } + + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + text.trim().substring(0, text.length - 2), + style: context.textTheme.labelLarge?.copyWith( + color: Colors.red[300], + ), + ), + ); + } else { + return const SizedBox(); + } + } + + return Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide( + color: context.colorScheme.outlineVariant, + width: 1, + ), + ), + elevation: 0, + borderOnForeground: false, + child: ListTile( + minVerticalPadding: 18, + title: Text( + "backup_controller_page_albums", + style: context.textTheme.titleMedium, + ).tr(), + subtitle: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "backup_controller_page_to_backup", + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), + ).tr(), + buildSelectedAlbumName(), + buildExcludedAlbumName(), + ], + ), + ), + trailing: ElevatedButton( + onPressed: () async { + await context.pushRoute(const ExpBackupAlbumSelectionRoute()); + ref.read(expBackupProvider.notifier).getBackupStatus(); + }, + child: const Text( + "select", + style: TextStyle( + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + ), + ); + } +} + +class TotalCard extends ConsumerWidget { + const TotalCard({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final totalCount = ref.watch(expBackupProvider.select((p) => p.totalCount)); + + return BackupInfoCard( + title: "total".tr(), + subtitle: "backup_controller_page_total_sub".tr(), + info: totalCount.toString(), + ); + } +} + +class RemainderCard extends ConsumerWidget { + const RemainderCard({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final backupState = ref.watch(backupProvider); + return BackupInfoCard( + title: "backup_controller_page_remainder".tr(), + subtitle: "backup_controller_page_remainder_sub".tr(), + info: backupState.availableAlbums.isEmpty + ? "..." + : "${max(0, backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length)}", + ); + } +} diff --git a/mobile/lib/providers/backup/backup_album.provider.dart b/mobile/lib/providers/backup/backup_album.provider.dart index 7a6d3929a5..52ad885735 100644 --- a/mobile/lib/providers/backup/backup_album.provider.dart +++ b/mobile/lib/providers/backup/backup_album.provider.dart @@ -19,7 +19,6 @@ class BackupAlbumNotifier extends StateNotifier> { Future getAll() async { state = await _localAlbumService.getAll(); - print("Backup albums loaded: ${state.length}"); } Future selectAlbum(LocalAlbum album) async { diff --git a/mobile/lib/providers/backup/exp_backup.provider.dart b/mobile/lib/providers/backup/exp_backup.provider.dart new file mode 100644 index 0000000000..080317d0d5 --- /dev/null +++ b/mobile/lib/providers/backup/exp_backup.provider.dart @@ -0,0 +1,73 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:immich_mobile/services/exp_backup.service.dart'; + +class ExpBackupState { + final int totalCount; + ExpBackupState({ + required this.totalCount, + }); + + ExpBackupState copyWith({ + int? totalCount, + }) { + return ExpBackupState( + totalCount: totalCount ?? this.totalCount, + ); + } + + Map toMap() { + return { + 'totalCount': totalCount, + }; + } + + factory ExpBackupState.fromMap(Map map) { + return ExpBackupState( + totalCount: map['totalCount'] as int, + ); + } + + String toJson() => json.encode(toMap()); + + factory ExpBackupState.fromJson(String source) => + ExpBackupState.fromMap(json.decode(source) as Map); + + @override + String toString() => 'ExpBackupState(totalCount: $totalCount)'; + + @override + bool operator ==(covariant ExpBackupState other) { + if (identical(this, other)) return true; + + return other.totalCount == totalCount; + } + + @override + int get hashCode => totalCount.hashCode; +} + +final expBackupProvider = + StateNotifierProvider((ref) { + return ExpBackupNotifier(ref.watch(expBackupServiceProvider)); +}); + +class ExpBackupNotifier extends StateNotifier { + ExpBackupNotifier(this._backupService) + : super( + ExpBackupState( + totalCount: 0, + ), + ); + + final ExpBackupService _backupService; + + Future getBackupStatus() async { + final totalCount = await _backupService.getTotalCount(); + + state = state.copyWith(totalCount: totalCount); + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 7774d58d41..46de68529c 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -336,5 +336,9 @@ class AppRouter extends RootStackRouter { page: ExpBackupRoute.page, guards: [_authGuard, _duplicateGuard], ), + AutoRoute( + page: ExpBackupAlbumSelectionRoute.page, + guards: [_authGuard, _duplicateGuard], + ), ]; } diff --git a/mobile/lib/services/exp_backup.service.dart b/mobile/lib/services/exp_backup.service.dart new file mode 100644 index 0000000000..c551badb96 --- /dev/null +++ b/mobile/lib/services/exp_backup.service.dart @@ -0,0 +1,31 @@ +import 'package:immich_mobile/domain/interfaces/backup.interface.dart'; +import 'package:immich_mobile/domain/interfaces/local_album.interface.dart'; +import 'package:immich_mobile/domain/models/local_album.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +final expBackupServiceProvider = Provider( + (ref) => ExpBackupService( + ref.watch(backupRepositoryProvider), + ), +); + +class ExpBackupService { + ExpBackupService(this._backupRepository); + + final IBackupRepository _backupRepository; + + Future getTotalCount() async { + final [selectedCount, excludedCount] = await Future.wait([ + _backupRepository.getTotalCount(BackupSelection.selected), + _backupRepository.getTotalCount(BackupSelection.excluded), + ]); + + return selectedCount - excludedCount; + } + + Future getBackupCount() { + return _backupRepository.getBackupCount(); + } +}