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",
"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",

View File

@ -29,82 +29,56 @@ class DriftBackupRepository extends DriftDatabaseRepository {
..where(_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.excluded));
}
Future<int> 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<int> 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<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);
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<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/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<DriftBackupPage> {
@override
void initState() {
super.initState();
WakelockPlus.enable();
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
return;
@ -44,6 +50,12 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
});
}
@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<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 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<DriftBackupState> {
totalCount: 0,
backupCount: 0,
remainderCount: 0,
processingCount: 0,
enqueueCount: 0,
enqueueTotalCount: 0,
isCanceling: false,
@ -313,13 +320,14 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
Future<void> 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<void> startBackup(String userId) {

View File

@ -89,16 +89,8 @@ class UploadService {
return _uploadRepository.getActiveTasks(group);
}
Future<int> getBackupTotalCount() {
return _backupRepository.getTotalCount();
}
Future<int> getBackupRemainderCount(String userId) {
return _backupRepository.getRemainderCount(userId);
}
Future<int> getBackupFinishedCount(String userId) {
return _backupRepository.getBackupCount(userId);
Future<({int total, int remainder, int processing})> getBackupCounts(String userId) {
return _backupRepository.getAllCounts(userId);
}
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/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;
}