feat(mobile): beta sync stats page (#19950)

* show beta sync stats

* show status next to jobs

* use drift devtools reset database impl

* dcm fixes

* fix: hash count

* styling

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Brandon Wees 2025-07-22 11:24:32 -05:00 committed by GitHub
parent 97daf42fd5
commit 2efca67217
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 742 additions and 44 deletions

View file

@ -0,0 +1,348 @@
import 'package:drift/drift.dart' as drift_db;
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart';
class BetaSyncSettings extends HookConsumerWidget {
const BetaSyncSettings({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assetService = ref.watch(assetServiceProvider);
final localAlbumService = ref.watch(localAlbumServiceProvider);
final remoteAlbumService = ref.watch(remoteAlbumServiceProvider);
final memoryService = ref.watch(driftMemoryServiceProvider);
Future<List<dynamic>> loadCounts() async {
final assetCounts = assetService.getAssetCounts();
final localAlbumCounts = localAlbumService.getCount();
final remoteAlbumCounts = remoteAlbumService.getCount();
final memoryCount = memoryService.getCount();
final getLocalHashedCount = assetService.getLocalHashedCount();
return await Future.wait([
assetCounts,
localAlbumCounts,
remoteAlbumCounts,
memoryCount,
getLocalHashedCount,
]);
}
Future<void> resetDatabase() async {
// https://github.com/simolus3/drift/commit/bd80a46264b6dd833ef4fd87fffc03f5a832ab41#diff-3f879e03b4a35779344ef16170b9353608dd9c42385f5402ec6035aac4dd8a04R76-R94
final drift = ref.read(driftProvider);
final database = drift.attachedDatabase;
await database.exclusively(() async {
// https://stackoverflow.com/a/65743498/25690041
await database.customStatement('PRAGMA writable_schema = 1;');
await database.customStatement('DELETE FROM sqlite_master;');
await database.customStatement('VACUUM;');
await database.customStatement('PRAGMA writable_schema = 0;');
await database.customStatement('PRAGMA integrity_check');
await database.customStatement('PRAGMA user_version = 0');
await database.beforeOpen(
// ignore: invalid_use_of_internal_member
database.resolvedEngine.executor,
drift_db.OpeningDetails(null, database.schemaVersion),
);
await database.customStatement(
'PRAGMA user_version = ${database.schemaVersion}',
);
// Refresh all stream queries
database.notifyUpdates({
for (final table in database.allTables)
drift_db.TableUpdate.onTable(table),
});
});
}
return FutureBuilder<List<dynamic>>(
future: loadCounts(),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const CircularProgressIndicator();
}
final assetCounts = snapshot.data![0]! as (int, int);
final localAssetCount = assetCounts.$1;
final remoteAssetCount = assetCounts.$2;
final localAlbumCount = snapshot.data![1]! as int;
final remoteAlbumCount = snapshot.data![2]! as int;
final memoryCount = snapshot.data![3]! as int;
final localHashedCount = snapshot.data![4]! as int;
return Padding(
padding: const EdgeInsets.only(top: 16, bottom: 32),
child: ListView(
children: [
_SectionHeaderText(text: "assets".t(context: context)),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 8.0,
children: [
Expanded(
child: EntitiyCountTile(
label: "local".t(context: context),
count: localAssetCount,
icon: Icons.smartphone,
),
),
Expanded(
child: EntitiyCountTile(
label: "remote".t(context: context),
count: remoteAssetCount,
icon: Icons.cloud,
),
),
],
),
),
_SectionHeaderText(text: "albums".t(context: context)),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 8.0,
children: [
Expanded(
child: EntitiyCountTile(
label: "local".t(context: context),
count: localAlbumCount,
icon: Icons.smartphone,
),
),
Expanded(
child: EntitiyCountTile(
label: "remote".t(context: context),
count: remoteAlbumCount,
icon: Icons.cloud,
),
),
],
),
),
_SectionHeaderText(text: "other".t(context: context)),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 8.0,
children: [
Expanded(
child: EntitiyCountTile(
label: "memories".t(context: context),
count: memoryCount,
icon: Icons.calendar_today,
),
),
Expanded(
child: EntitiyCountTile(
label: "hashed_assets".t(context: context),
count: localHashedCount,
icon: Icons.tag,
),
),
],
),
),
const Divider(
height: 1,
indent: 16,
endIndent: 16,
),
const SizedBox(height: 24),
_SectionHeaderText(text: "jobs".t(context: context)),
ListTile(
title: Text(
"sync_local".t(context: context),
style: const TextStyle(
fontWeight: FontWeight.w500,
),
),
subtitle: Text(
"tap_to_run_job".t(context: context),
),
leading: const Icon(Icons.sync),
trailing: _SyncStatusIcon(
status: ref.watch(syncStatusProvider).localSyncStatus,
),
onTap: () {
ref.read(backgroundSyncProvider).syncLocal(full: true);
},
),
ListTile(
title: Text(
"sync_remote".t(context: context),
style: const TextStyle(
fontWeight: FontWeight.w500,
),
),
subtitle: Text(
"tap_to_run_job".t(context: context),
),
leading: const Icon(Icons.cloud_sync),
trailing: _SyncStatusIcon(
status: ref.watch(syncStatusProvider).remoteSyncStatus,
),
onTap: () {
ref.read(backgroundSyncProvider).syncRemote();
},
),
ListTile(
title: Text(
"hash_asset".t(context: context),
style: const TextStyle(
fontWeight: FontWeight.w500,
),
),
leading: const Icon(Icons.tag),
subtitle: Text(
"tap_to_run_job".t(context: context),
),
trailing: _SyncStatusIcon(
status: ref.watch(syncStatusProvider).hashJobStatus,
),
onTap: () {
ref.read(backgroundSyncProvider).hashAssets();
},
),
const Divider(
height: 1,
indent: 16,
endIndent: 16,
),
const SizedBox(height: 24),
_SectionHeaderText(text: "actions".t(context: context)),
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 {
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,
),
),
),
],
);
},
);
},
),
],
),
);
},
);
}
}
class _SyncStatusIcon extends StatelessWidget {
final SyncStatus status;
const _SyncStatusIcon({
required this.status,
});
@override
Widget build(BuildContext context) {
return switch (status) {
SyncStatus.idle => const Icon(
Icons.pause_circle_outline_rounded,
),
SyncStatus.syncing => const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
SyncStatus.success => const Icon(
Icons.check_circle_outline,
color: Colors.green,
),
SyncStatus.error => Icon(
Icons.error_outline,
color: context.colorScheme.error,
),
};
}
}
class _SectionHeaderText extends StatelessWidget {
final String text;
const _SectionHeaderText({
required this.text,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(
text.toUpperCase(),
style: context.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w500,
color: context.colorScheme.onSurface.withAlpha(200),
),
),
);
}
}

View file

@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
class EntitiyCountTile extends StatelessWidget {
final int count;
final String label;
final IconData icon;
const EntitiyCountTile({
super.key,
required this.count,
required this.label,
required this.icon,
});
String zeroPadding(int number, int targetWidth) {
final numStr = number.toString();
return numStr.length < targetWidth
? "0" * (targetWidth - numStr.length)
: "";
}
int calculateMaxDigits(double availableWidth) {
const double charWidth = 11.0;
return (availableWidth / charWidth).floor().clamp(1, 20);
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainerLow,
borderRadius: const BorderRadius.all(Radius.circular(16)),
border: Border.all(
width: 0.5,
color: context.colorScheme.outline.withAlpha(25),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Icon and Label
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Icon(
icon,
color: context.primaryColor,
),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
color: context.primaryColor,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
],
),
const SizedBox(height: 12),
// Number
LayoutBuilder(
builder: (context, constraints) {
final maxDigits = calculateMaxDigits(constraints.maxWidth);
return RichText(
text: TextSpan(
style: const TextStyle(
fontSize: 18,
fontFamily: 'OverpassMono',
fontWeight: FontWeight.w600,
),
children: [
TextSpan(
text: zeroPadding(count, maxDigits),
style: TextStyle(
color: context.colorScheme.onSurfaceSecondary
.withAlpha(75),
),
),
TextSpan(
text: count.toString(),
style: TextStyle(
color: context.primaryColor,
),
),
],
),
);
},
),
],
),
);
}
}