feat: show preparing/hashing status in backup page (#22222)

* only show preparing information while hashing

* pr feedback

* use count

* use a single query for count

* use Mert's query
This commit is contained in:
Alex 2025-09-21 14:34:19 -05:00 committed by GitHub
parent 0bbeb20595
commit 7a0107fc79
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 280 additions and 97 deletions

View File

@ -1527,6 +1527,7 @@
"port": "Port", "port": "Port",
"preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_subtitle": "Manage the app's preferences",
"preferences_settings_title": "Preferences", "preferences_settings_title": "Preferences",
"preparing": "Preparing",
"preset": "Preset", "preset": "Preset",
"preview": "Preview", "preview": "Preview",
"previous": "Previous", "previous": "Previous",
@ -1592,6 +1593,7 @@
"read_changelog": "Read Changelog", "read_changelog": "Read Changelog",
"readonly_mode_disabled": "Read-only mode disabled", "readonly_mode_disabled": "Read-only mode disabled",
"readonly_mode_enabled": "Read-only mode enabled", "readonly_mode_enabled": "Read-only mode enabled",
"ready_for_upload": "Ready for upload",
"reassign": "Reassign", "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_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", "reassigned_assets_to_new_person": "Re-assigned {count, plural, one {# asset} other {# assets}} to a new person",

View File

@ -29,82 +29,56 @@ class DriftBackupRepository extends DriftDatabaseRepository {
..where(_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.excluded)); ..where(_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.excluded));
} }
Future<int> getTotalCount() async { /// Returns all backup-related counts in a single query.
final query = _db.localAlbumAssetEntity.selectOnly(distinct: true) ///
..addColumns([_db.localAlbumAssetEntity.assetId]) /// - total: number of distinct assets in selected albums, excluding those that are also in excluded albums
..join([ /// - backup: number of those assets that already exist on the server for [userId]
innerJoin( /// - remainder: number of those assets that do not yet exist on the server for [userId]
_db.localAlbumEntity, /// (includes processing)
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id), /// - processing: number of those assets that are still preparing/have a null checksum
useColumns: false, Future<({int total, int remainder, int processing})> getAllCounts(String userId) async {
), const sql = '''
]) SELECT
..where( COUNT(*) AS total_count,
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) & COUNT(*) FILTER (WHERE lae.checksum IS NULL) AS processing_count,
_db.localAlbumAssetEntity.assetId.isNotInQuery(_getExcludedSubquery()), 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<int> getRemainderCount(String userId) async { final data = row.data;
final query = _db.localAlbumAssetEntity.selectOnly(distinct: true) return (
..addColumns([_db.localAlbumAssetEntity.assetId]) total: (data['total_count'] as int?) ?? 0,
..join([ remainder: (data['remainder_count'] as int?) ?? 0,
innerJoin( processing: (data['processing_count'] as int?) ?? 0,
_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<int> 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);
} }
Future<List<LocalAsset>> getCandidates(String userId) async { Future<List<LocalAsset>> getCandidates(String userId) async {

View File

@ -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/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/backup/backup_info_card.dart'; import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
import 'dart:async';
import 'package:wakelock_plus/wakelock_plus.dart';
@RoutePage() @RoutePage()
class DriftBackupPage extends ConsumerStatefulWidget { class DriftBackupPage extends ConsumerStatefulWidget {
@ -29,6 +32,9 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WakelockPlus.enable();
final currentUser = ref.read(currentUserProvider); final currentUser = ref.read(currentUserProvider);
if (currentUser == null) { if (currentUser == null) {
return; return;
@ -44,6 +50,12 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
}); });
} }
@override
dispose() {
super.dispose();
WakelockPlus.disable();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final selectedAlbum = ref final selectedAlbum = ref
@ -260,12 +272,205 @@ class _RemainderCard extends ConsumerWidget {
final remainderCount = ref.watch(driftBackupProvider.select((p) => p.remainderCount)); final remainderCount = ref.watch(driftBackupProvider.select((p) => p.remainderCount));
final syncStatus = ref.watch(syncStatusProvider); final syncStatus = ref.watch(syncStatusProvider);
return BackupInfoCard( return Card(
title: "backup_controller_page_remainder".tr(), shape: RoundedRectangleBorder(
subtitle: "backup_controller_page_remainder_sub".tr(), borderRadius: const BorderRadius.all(Radius.circular(20)),
info: remainderCount.toString(), side: BorderSide(color: context.colorScheme.outlineVariant, width: 1),
isLoading: syncStatus.isRemoteSyncing, ),
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()), 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<int>(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,
),
),
],
),
),
),
],
); );
} }
} }

View File

@ -123,6 +123,7 @@ class DriftBackupState {
final int totalCount; final int totalCount;
final int backupCount; final int backupCount;
final int remainderCount; final int remainderCount;
final int processingCount;
final int enqueueCount; final int enqueueCount;
final int enqueueTotalCount; final int enqueueTotalCount;
@ -135,6 +136,7 @@ class DriftBackupState {
required this.totalCount, required this.totalCount,
required this.backupCount, required this.backupCount,
required this.remainderCount, required this.remainderCount,
required this.processingCount,
required this.enqueueCount, required this.enqueueCount,
required this.enqueueTotalCount, required this.enqueueTotalCount,
required this.isCanceling, required this.isCanceling,
@ -145,6 +147,7 @@ class DriftBackupState {
int? totalCount, int? totalCount,
int? backupCount, int? backupCount,
int? remainderCount, int? remainderCount,
int? processingCount,
int? enqueueCount, int? enqueueCount,
int? enqueueTotalCount, int? enqueueTotalCount,
bool? isCanceling, bool? isCanceling,
@ -154,6 +157,7 @@ class DriftBackupState {
totalCount: totalCount ?? this.totalCount, totalCount: totalCount ?? this.totalCount,
backupCount: backupCount ?? this.backupCount, backupCount: backupCount ?? this.backupCount,
remainderCount: remainderCount ?? this.remainderCount, remainderCount: remainderCount ?? this.remainderCount,
processingCount: processingCount ?? this.processingCount,
enqueueCount: enqueueCount ?? this.enqueueCount, enqueueCount: enqueueCount ?? this.enqueueCount,
enqueueTotalCount: enqueueTotalCount ?? this.enqueueTotalCount, enqueueTotalCount: enqueueTotalCount ?? this.enqueueTotalCount,
isCanceling: isCanceling ?? this.isCanceling, isCanceling: isCanceling ?? this.isCanceling,
@ -163,7 +167,7 @@ class DriftBackupState {
@override @override
String toString() { 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 @override
@ -174,6 +178,7 @@ class DriftBackupState {
return other.totalCount == totalCount && return other.totalCount == totalCount &&
other.backupCount == backupCount && other.backupCount == backupCount &&
other.remainderCount == remainderCount && other.remainderCount == remainderCount &&
other.processingCount == processingCount &&
other.enqueueCount == enqueueCount && other.enqueueCount == enqueueCount &&
other.enqueueTotalCount == enqueueTotalCount && other.enqueueTotalCount == enqueueTotalCount &&
other.isCanceling == isCanceling && other.isCanceling == isCanceling &&
@ -185,6 +190,7 @@ class DriftBackupState {
return totalCount.hashCode ^ return totalCount.hashCode ^
backupCount.hashCode ^ backupCount.hashCode ^
remainderCount.hashCode ^ remainderCount.hashCode ^
processingCount.hashCode ^
enqueueCount.hashCode ^ enqueueCount.hashCode ^
enqueueTotalCount.hashCode ^ enqueueTotalCount.hashCode ^
isCanceling.hashCode ^ isCanceling.hashCode ^
@ -203,6 +209,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
totalCount: 0, totalCount: 0,
backupCount: 0, backupCount: 0,
remainderCount: 0, remainderCount: 0,
processingCount: 0,
enqueueCount: 0, enqueueCount: 0,
enqueueTotalCount: 0, enqueueTotalCount: 0,
isCanceling: false, isCanceling: false,
@ -313,13 +320,14 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
} }
Future<void> getBackupStatus(String userId) async { Future<void> getBackupStatus(String userId) async {
final [totalCount, backupCount, remainderCount] = await Future.wait([ final counts = await _uploadService.getBackupCounts(userId);
_uploadService.getBackupTotalCount(),
_uploadService.getBackupFinishedCount(userId),
_uploadService.getBackupRemainderCount(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<void> startBackup(String userId) { Future<void> startBackup(String userId) {

View File

@ -89,16 +89,8 @@ class UploadService {
return _uploadRepository.getActiveTasks(group); return _uploadRepository.getActiveTasks(group);
} }
Future<int> getBackupTotalCount() { Future<({int total, int remainder, int processing})> getBackupCounts(String userId) {
return _backupRepository.getTotalCount(); return _backupRepository.getAllCounts(userId);
}
Future<int> getBackupRemainderCount(String userId) {
return _backupRepository.getRemainderCount(userId);
}
Future<int> getBackupFinishedCount(String userId) {
return _backupRepository.getBackupCount(userId);
} }
Future<void> manualBackup(List<LocalAsset> localAssets) async { Future<void> manualBackup(List<LocalAsset> localAssets) async {

View File

@ -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/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/oauth.provider.dart'; import 'package:immich_mobile/providers/oauth.provider.dart';
import 'package:immich_mobile/providers/server_info.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/routing/router.dart';
import 'package:immich_mobile/utils/provider_utils.dart'; import 'package:immich_mobile/utils/provider_utils.dart';
import 'package:immich_mobile/utils/url_helper.dart'; import 'package:immich_mobile/utils/url_helper.dart';
@ -193,6 +194,7 @@ class LoginForm extends HookConsumerWidget {
if (isBeta) { if (isBeta) {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission(); await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
handleSyncFlow(); handleSyncFlow();
ref.read(websocketProvider.notifier).connect();
context.replaceRoute(const TabShellRoute()); context.replaceRoute(const TabShellRoute());
return; return;
} }