diff --git a/i18n/en.json b/i18n/en.json index b817ab375f..23e1071a2d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1527,6 +1527,7 @@ "port": "Port", "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Preferences", + "preparing": "Preparing", "preset": "Preset", "preview": "Preview", "previous": "Previous", @@ -1592,6 +1593,7 @@ "read_changelog": "Read Changelog", "readonly_mode_disabled": "Read-only mode disabled", "readonly_mode_enabled": "Read-only mode enabled", + "ready_for_upload": "Ready for upload", "reassign": "Reassign", "reassigned_assets_to_existing_person": "Re-assigned {count, plural, one {# asset} other {# assets}} to {name, select, null {an existing person} other {{name}}}", "reassigned_assets_to_new_person": "Re-assigned {count, plural, one {# asset} other {# assets}} to a new person", diff --git a/mobile/lib/infrastructure/repositories/backup.repository.dart b/mobile/lib/infrastructure/repositories/backup.repository.dart index 1e9f69147c..cc6fd7dfe3 100644 --- a/mobile/lib/infrastructure/repositories/backup.repository.dart +++ b/mobile/lib/infrastructure/repositories/backup.repository.dart @@ -29,82 +29,56 @@ class DriftBackupRepository extends DriftDatabaseRepository { ..where(_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.excluded)); } - Future getTotalCount() async { - final query = _db.localAlbumAssetEntity.selectOnly(distinct: true) - ..addColumns([_db.localAlbumAssetEntity.assetId]) - ..join([ - innerJoin( - _db.localAlbumEntity, - _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id), - useColumns: false, - ), - ]) - ..where( - _db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) & - _db.localAlbumAssetEntity.assetId.isNotInQuery(_getExcludedSubquery()), - ); + /// Returns all backup-related counts in a single query. + /// + /// - total: number of distinct assets in selected albums, excluding those that are also in excluded albums + /// - backup: number of those assets that already exist on the server for [userId] + /// - remainder: number of those assets that do not yet exist on the server for [userId] + /// (includes processing) + /// - processing: number of those assets that are still preparing/have a null checksum + Future<({int total, int remainder, int processing})> getAllCounts(String userId) async { + const sql = ''' + SELECT + COUNT(*) AS total_count, + COUNT(*) FILTER (WHERE lae.checksum IS NULL) AS processing_count, + COUNT(*) FILTER (WHERE rae.id IS NULL) AS remainder_count + FROM local_asset_entity lae + LEFT JOIN main.remote_asset_entity rae + ON lae.checksum = rae.checksum AND rae.owner_id = ?1 + WHERE EXISTS ( + SELECT 1 + FROM local_album_asset_entity laa + INNER JOIN main.local_album_entity la on laa.album_id = la.id + WHERE laa.asset_id = lae.id + AND la.backup_selection = ?2 + ) + AND NOT EXISTS ( + SELECT 1 + FROM local_album_asset_entity laa + INNER JOIN main.local_album_entity la on laa.album_id = la.id + WHERE laa.asset_id = lae.id + AND la.backup_selection = ?3 + ); + '''; - return query.get().then((rows) => rows.length); - } + final row = await _db + .customSelect( + sql, + variables: [ + Variable.withString(userId), + Variable.withInt(BackupSelection.selected.index), + Variable.withInt(BackupSelection.excluded.index), + ], + readsFrom: {_db.localAlbumAssetEntity, _db.localAlbumEntity, _db.localAssetEntity, _db.remoteAssetEntity}, + ) + .getSingle(); - Future getRemainderCount(String userId) async { - final query = _db.localAlbumAssetEntity.selectOnly(distinct: true) - ..addColumns([_db.localAlbumAssetEntity.assetId]) - ..join([ - innerJoin( - _db.localAlbumEntity, - _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id), - useColumns: false, - ), - innerJoin( - _db.localAssetEntity, - _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id), - useColumns: false, - ), - leftOuterJoin( - _db.remoteAssetEntity, - _db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum) & - _db.remoteAssetEntity.ownerId.equals(userId), - useColumns: false, - ), - ]) - ..where( - _db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) & - _db.remoteAssetEntity.id.isNull() & - _db.localAlbumAssetEntity.assetId.isNotInQuery(_getExcludedSubquery()), - ); - - return query.get().then((rows) => rows.length); - } - - Future getBackupCount(String userId) async { - final query = _db.localAlbumAssetEntity.selectOnly(distinct: true) - ..addColumns([_db.localAlbumAssetEntity.assetId]) - ..join([ - innerJoin( - _db.localAlbumEntity, - _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id), - useColumns: false, - ), - innerJoin( - _db.localAssetEntity, - _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id), - useColumns: false, - ), - innerJoin( - _db.remoteAssetEntity, - _db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum), - useColumns: false, - ), - ]) - ..where( - _db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) & - _db.remoteAssetEntity.id.isNotNull() & - _db.remoteAssetEntity.ownerId.equals(userId) & - _db.localAlbumAssetEntity.assetId.isNotInQuery(_getExcludedSubquery()), - ); - - return query.get().then((rows) => rows.length); + final data = row.data; + return ( + total: (data['total_count'] as int?) ?? 0, + remainder: (data['remainder_count'] as int?) ?? 0, + processing: (data['processing_count'] as int?) ?? 0, + ); } Future> getCandidates(String userId) async { diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index 8578506ced..30782726e2 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -16,6 +16,9 @@ import 'package:immich_mobile/providers/sync_status.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/backup/backup_info_card.dart'; +import 'dart:async'; + +import 'package:wakelock_plus/wakelock_plus.dart'; @RoutePage() class DriftBackupPage extends ConsumerStatefulWidget { @@ -29,6 +32,9 @@ class _DriftBackupPageState extends ConsumerState { @override void initState() { super.initState(); + + WakelockPlus.enable(); + final currentUser = ref.read(currentUserProvider); if (currentUser == null) { return; @@ -44,6 +50,12 @@ class _DriftBackupPageState extends ConsumerState { }); } + @override + dispose() { + super.dispose(); + WakelockPlus.disable(); + } + @override Widget build(BuildContext context) { final selectedAlbum = ref @@ -260,12 +272,205 @@ class _RemainderCard extends ConsumerWidget { final remainderCount = ref.watch(driftBackupProvider.select((p) => p.remainderCount)); final syncStatus = ref.watch(syncStatusProvider); - return BackupInfoCard( - title: "backup_controller_page_remainder".tr(), - subtitle: "backup_controller_page_remainder_sub".tr(), - info: remainderCount.toString(), - isLoading: syncStatus.isRemoteSyncing, - onTap: () => context.pushRoute(const DriftBackupAssetDetailRoute()), + return Card( + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(20)), + side: BorderSide(color: context.colorScheme.outlineVariant, width: 1), + ), + elevation: 0, + borderOnForeground: false, + child: Column( + children: [ + ListTile( + minVerticalPadding: 18, + isThreeLine: true, + title: Text("backup_controller_page_remainder".t(context: context), style: context.textTheme.titleMedium), + subtitle: Padding( + padding: const EdgeInsets.only(top: 4.0, right: 18.0), + child: Text( + "backup_controller_page_remainder_sub".t(context: context), + style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + ), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Stack( + children: [ + Text( + remainderCount.toString(), + style: context.textTheme.titleLarge?.copyWith( + color: context.colorScheme.onSurface.withAlpha(syncStatus.isRemoteSyncing ? 50 : 255), + ), + ), + if (syncStatus.isRemoteSyncing) + Positioned.fill( + child: Align( + alignment: Alignment.center, + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: context.colorScheme.onSurface.withAlpha(150), + ), + ), + ), + ), + ], + ), + Text( + "backup_info_card_assets", + style: context.textTheme.labelLarge?.copyWith( + color: context.colorScheme.onSurface.withAlpha(syncStatus.isRemoteSyncing ? 50 : 255), + ), + ).tr(), + ], + ), + ), + const Divider(height: 0), + const _PreparingStatus(), + const Divider(height: 0), + + ListTile( + enableFeedback: true, + visualDensity: VisualDensity.compact, + contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 0.0), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only(bottomLeft: Radius.circular(20), bottomRight: Radius.circular(20)), + ), + onTap: () => context.pushRoute(const DriftBackupAssetDetailRoute()), + title: Text( + "view_details".t(context: context), + style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurface.withAlpha(200)), + ), + trailing: Icon(Icons.arrow_forward_ios, size: 16, color: context.colorScheme.onSurfaceVariant), + ), + ], + ), + ); + } +} + +class _PreparingStatus extends ConsumerStatefulWidget { + const _PreparingStatus(); + + @override + _PreparingStatusState createState() => _PreparingStatusState(); +} + +class _PreparingStatusState extends ConsumerState { + Timer? _pollingTimer; + + @override + void dispose() { + _pollingTimer?.cancel(); + super.dispose(); + } + + void _startPollingIfNeeded() { + if (_pollingTimer != null) return; + + _pollingTimer = Timer.periodic(const Duration(seconds: 3), (timer) async { + final currentUser = ref.read(currentUserProvider); + if (currentUser != null && mounted) { + await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id); + + // Stop polling if processing count reaches 0 + final updatedProcessingCount = ref.read(driftBackupProvider.select((p) => p.processingCount)); + if (updatedProcessingCount == 0) { + timer.cancel(); + _pollingTimer = null; + } + } else { + timer.cancel(); + _pollingTimer = null; + } + }); + } + + @override + Widget build(BuildContext context) { + final syncStatus = ref.watch(syncStatusProvider); + final remainderCount = ref.watch(driftBackupProvider.select((p) => p.remainderCount)); + final processingCount = ref.watch(driftBackupProvider.select((p) => p.processingCount)); + final readyForUploadCount = remainderCount - processingCount; + + ref.listen(driftBackupProvider.select((p) => p.processingCount), (previous, next) { + if (next > 0 && _pollingTimer == null) { + _startPollingIfNeeded(); + } else if (next == 0 && _pollingTimer != null) { + _pollingTimer?.cancel(); + _pollingTimer = null; + } + }); + + if (!syncStatus.isHashing) { + return const SizedBox.shrink(); + } + + return Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 1.0), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + decoration: BoxDecoration( + color: context.colorScheme.surfaceContainerHigh.withValues(alpha: 0.5), + shape: BoxShape.rectangle, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + "preparing".t(context: context), + style: context.textTheme.labelLarge?.copyWith( + color: context.colorScheme.onSurface.withAlpha(200), + ), + ), + const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 1.5)), + ], + ), + const SizedBox(height: 2), + Text( + processingCount.toString(), + style: context.textTheme.titleMedium?.copyWith( + color: context.colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + decoration: BoxDecoration(color: context.colorScheme.primary.withValues(alpha: 0.1)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + "ready_for_upload".t(context: context), + style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurface.withAlpha(200)), + ), + const SizedBox(height: 2), + Text( + readyForUploadCount.toString(), + style: context.textTheme.titleMedium?.copyWith( + color: context.primaryColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], ); } } diff --git a/mobile/lib/providers/backup/drift_backup.provider.dart b/mobile/lib/providers/backup/drift_backup.provider.dart index 4069cef13f..1ce14e5204 100644 --- a/mobile/lib/providers/backup/drift_backup.provider.dart +++ b/mobile/lib/providers/backup/drift_backup.provider.dart @@ -123,6 +123,7 @@ class DriftBackupState { final int totalCount; final int backupCount; final int remainderCount; + final int processingCount; final int enqueueCount; final int enqueueTotalCount; @@ -135,6 +136,7 @@ class DriftBackupState { required this.totalCount, required this.backupCount, required this.remainderCount, + required this.processingCount, required this.enqueueCount, required this.enqueueTotalCount, required this.isCanceling, @@ -145,6 +147,7 @@ class DriftBackupState { int? totalCount, int? backupCount, int? remainderCount, + int? processingCount, int? enqueueCount, int? enqueueTotalCount, bool? isCanceling, @@ -154,6 +157,7 @@ class DriftBackupState { totalCount: totalCount ?? this.totalCount, backupCount: backupCount ?? this.backupCount, remainderCount: remainderCount ?? this.remainderCount, + processingCount: processingCount ?? this.processingCount, enqueueCount: enqueueCount ?? this.enqueueCount, enqueueTotalCount: enqueueTotalCount ?? this.enqueueTotalCount, isCanceling: isCanceling ?? this.isCanceling, @@ -163,7 +167,7 @@ class DriftBackupState { @override String toString() { - return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, enqueueCount: $enqueueCount, enqueueTotalCount: $enqueueTotalCount, isCanceling: $isCanceling, uploadItems: $uploadItems)'; + return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, enqueueCount: $enqueueCount, enqueueTotalCount: $enqueueTotalCount, isCanceling: $isCanceling, uploadItems: $uploadItems)'; } @override @@ -174,6 +178,7 @@ class DriftBackupState { return other.totalCount == totalCount && other.backupCount == backupCount && other.remainderCount == remainderCount && + other.processingCount == processingCount && other.enqueueCount == enqueueCount && other.enqueueTotalCount == enqueueTotalCount && other.isCanceling == isCanceling && @@ -185,6 +190,7 @@ class DriftBackupState { return totalCount.hashCode ^ backupCount.hashCode ^ remainderCount.hashCode ^ + processingCount.hashCode ^ enqueueCount.hashCode ^ enqueueTotalCount.hashCode ^ isCanceling.hashCode ^ @@ -203,6 +209,7 @@ class DriftBackupNotifier extends StateNotifier { totalCount: 0, backupCount: 0, remainderCount: 0, + processingCount: 0, enqueueCount: 0, enqueueTotalCount: 0, isCanceling: false, @@ -313,13 +320,14 @@ class DriftBackupNotifier extends StateNotifier { } Future getBackupStatus(String userId) async { - final [totalCount, backupCount, remainderCount] = await Future.wait([ - _uploadService.getBackupTotalCount(), - _uploadService.getBackupFinishedCount(userId), - _uploadService.getBackupRemainderCount(userId), - ]); + final counts = await _uploadService.getBackupCounts(userId); - state = state.copyWith(totalCount: totalCount, backupCount: backupCount, remainderCount: remainderCount); + state = state.copyWith( + totalCount: counts.total, + backupCount: counts.total - counts.remainder, + remainderCount: counts.remainder, + processingCount: counts.processing, + ); } Future startBackup(String userId) { diff --git a/mobile/lib/services/upload.service.dart b/mobile/lib/services/upload.service.dart index 9e9c81076b..b1130dab80 100644 --- a/mobile/lib/services/upload.service.dart +++ b/mobile/lib/services/upload.service.dart @@ -89,16 +89,8 @@ class UploadService { return _uploadRepository.getActiveTasks(group); } - Future getBackupTotalCount() { - return _backupRepository.getTotalCount(); - } - - Future getBackupRemainderCount(String userId) { - return _backupRepository.getRemainderCount(userId); - } - - Future getBackupFinishedCount(String userId) { - return _backupRepository.getBackupCount(userId); + Future<({int total, int remainder, int processing})> getBackupCounts(String userId) { + return _backupRepository.getAllCounts(userId); } Future manualBackup(List localAssets) async { diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 7b5d31544a..f100b58649 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -19,6 +19,7 @@ import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/oauth.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/provider_utils.dart'; import 'package:immich_mobile/utils/url_helper.dart'; @@ -193,6 +194,7 @@ class LoginForm extends HookConsumerWidget { if (isBeta) { await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission(); handleSyncFlow(); + ref.read(websocketProvider.notifier).connect(); context.replaceRoute(const TabShellRoute()); return; }