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

@ -573,6 +573,8 @@
"backup_options_page_title": "Backup options",
"backup_setting_subtitle": "Manage background and foreground upload settings",
"backward": "Backward",
"beta_sync": "Beta Sync Status",
"beta_sync_subtitle": "Manage the new sync system",
"biometric_auth_enabled": "Biometric authentication enabled",
"biometric_locked_out": "You are locked out of biometric authentication",
"biometric_no_options": "No biometric options available",
@ -1051,6 +1053,9 @@
"haptic_feedback_switch": "Enable haptic feedback",
"haptic_feedback_title": "Haptic Feedback",
"has_quota": "Has quota",
"hash_asset": "Hash asset",
"hashed_assets": "Hashed assets",
"hashing": "Hashing",
"header_settings_add_header_tip": "Add Header",
"header_settings_field_validator_msg": "Value cannot be empty",
"header_settings_header_name_input": "Header name",
@ -1083,6 +1088,7 @@
"host": "Host",
"hour": "Hour",
"id": "ID",
"idle": "Idle",
"ignore_icloud_photos": "Ignore iCloud photos",
"ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server",
"image": "Image",
@ -1165,7 +1171,9 @@
"list": "List",
"loading": "Loading",
"loading_search_results_failed": "Loading search results failed",
"local": "Local",
"local_asset_cast_failed": "Unable to cast an asset that is not uploaded to the server",
"local_assets": "Local Assets",
"local_network": "Local network",
"local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network",
"location_permission": "Location permission",
@ -1359,6 +1367,7 @@
"original": "original",
"other": "Other",
"other_devices": "Other devices",
"other_entities": "Other entities",
"other_variables": "Other variables",
"owned": "Owned",
"owner": "Owner",
@ -1519,6 +1528,8 @@
"refreshing_faces": "Refreshing faces",
"refreshing_metadata": "Refreshing metadata",
"regenerating_thumbnails": "Regenerating thumbnails",
"remote": "Remote",
"remote_assets": "Remote Assets",
"remove": "Remove",
"remove_assets_album_confirmation": "Are you sure you want to remove {count, plural, one {# asset} other {# assets}} from the album?",
"remove_assets_shared_link_confirmation": "Are you sure you want to remove {count, plural, one {# asset} other {# assets}} from this shared link?",
@ -1556,6 +1567,9 @@
"reset_password": "Reset password",
"reset_people_visibility": "Reset people visibility",
"reset_pin_code": "Reset PIN code",
"reset_sqlite": "Reset SQLite Database",
"reset_sqlite_confirmation": "Are you sure you want to reset the SQLite database? You will need to log out and log in again to resync the data",
"reset_sqlite_success": "Successfully reset the SQLite database",
"reset_to_default": "Reset to default",
"resolve_duplicates": "Resolve duplicates",
"resolved_all_duplicates": "Resolved all duplicates",
@ -1569,6 +1583,7 @@
"role": "Role",
"role_editor": "Editor",
"role_viewer": "Viewer",
"running": "Running",
"save": "Save",
"save_to_gallery": "Save to gallery",
"saved_api_key": "Saved API Key",
@ -1822,6 +1837,7 @@
"storage_quota": "Storage Quota",
"storage_usage": "{used} of {available} used",
"submit": "Submit",
"success": "Success",
"suggestions": "Suggestions",
"sunrise_on_the_beach": "Sunrise on the beach",
"support": "Support",
@ -1831,6 +1847,8 @@
"sync": "Sync",
"sync_albums": "Sync albums",
"sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums",
"sync_local": "Sync Local",
"sync_remote": "Sync Remote",
"sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich",
"tag": "Tag",
"tag_assets": "Tag assets",
@ -1841,6 +1859,7 @@
"tag_updated": "Updated tag: {tag}",
"tagged_assets": "Tagged {count, plural, one {# asset} other {# assets}}",
"tags": "Tags",
"tap_to_run_job": "Tap to run job",
"template": "Template",
"theme": "Theme",
"theme_selection": "Theme selection",

View File

@ -76,4 +76,15 @@ class AssetService {
Future<List<(String, String)>> getPlaces() {
return _remoteAssetRepository.getPlaces();
}
Future<(int local, int remote)> getAssetCounts() async {
return (
await _localAssetRepository.getCount(),
await _remoteAssetRepository.getCount()
);
}
Future<int> getLocalHashedCount() {
return _localAssetRepository.getHashedCount();
}
}

View File

@ -18,4 +18,8 @@ class LocalAlbumService {
Future<void> update(LocalAlbum album) {
return _repository.upsert(album);
}
Future<int> getCount() {
return _repository.getCount();
}
}

View File

@ -12,4 +12,8 @@ class DriftMemoryService {
Future<List<DriftMemory>> getMemoryLane(String ownerId) {
return _repository.getAll(ownerId);
}
Future<int> getCount() {
return _repository.getCount();
}
}

View File

@ -147,4 +147,8 @@ class RemoteAlbumService {
return _repository.addUsers(albumId, userIds);
}
Future<int> getCount() {
return _repository.getCount();
}
}

View File

@ -12,6 +12,14 @@ class BackgroundSyncManager {
final SyncCallback? onRemoteSyncComplete;
final SyncErrorCallback? onRemoteSyncError;
final SyncCallback? onLocalSyncStart;
final SyncCallback? onLocalSyncComplete;
final SyncErrorCallback? onLocalSyncError;
final SyncCallback? onHashingStart;
final SyncCallback? onHashingComplete;
final SyncErrorCallback? onHashingError;
Cancelable<void>? _syncTask;
Cancelable<void>? _syncWebsocketTask;
Cancelable<void>? _deviceAlbumSyncTask;
@ -21,6 +29,12 @@ class BackgroundSyncManager {
this.onRemoteSyncStart,
this.onRemoteSyncComplete,
this.onRemoteSyncError,
this.onLocalSyncStart,
this.onLocalSyncComplete,
this.onLocalSyncError,
this.onHashingStart,
this.onHashingComplete,
this.onHashingError,
});
Future<void> cancel() {
@ -47,6 +61,8 @@ class BackgroundSyncManager {
return _deviceAlbumSyncTask!.future;
}
onLocalSyncStart?.call();
// We use a ternary operator to avoid [_deviceAlbumSyncTask] from being
// captured by the closure passed to [runInIsolateGentle].
_deviceAlbumSyncTask = full
@ -61,6 +77,10 @@ class BackgroundSyncManager {
return _deviceAlbumSyncTask!.whenComplete(() {
_deviceAlbumSyncTask = null;
onLocalSyncComplete?.call();
}).catchError((error) {
onLocalSyncError?.call(error.toString());
_deviceAlbumSyncTask = null;
});
}
@ -70,10 +90,17 @@ class BackgroundSyncManager {
return _hashTask!.future;
}
onHashingStart?.call();
_hashTask = runInIsolateGentle(
computation: (ref) => ref.read(hashServiceProvider).hashAssets(),
);
return _hashTask!.whenComplete(() {
onHashingComplete?.call();
_hashTask = null;
}).catchError((error) {
onHashingError?.call(error.toString());
_hashTask = null;
});
}

View File

@ -398,4 +398,8 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
return results.isNotEmpty ? results.first : null;
}
Future<int> getCount() {
return _db.managers.localAlbumEntity.count();
}
}

View File

@ -63,4 +63,14 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
return query.map((row) => row.toDto()).getSingleOrNull();
}
Future<int> getCount() {
return _db.managers.localAssetEntity.count();
}
Future<int> getHashedCount() {
return _db.managers.localAssetEntity
.filter((e) => e.checksum.isNull().not())
.count();
}
}

View File

@ -58,6 +58,10 @@ class DriftMemoryRepository extends DriftDatabaseRepository {
return memoriesMap.values.toList();
}
Future<int> getCount() {
return _db.managers.memoryEntity.count();
}
}
extension on MemoryEntityData {

View File

@ -268,6 +268,10 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
return album;
}).watchSingleOrNull();
}
Future<int> getCount() {
return _db.managers.remoteAlbumEntity.count();
}
}
extension on RemoteAlbumEntityData {

View File

@ -238,4 +238,8 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
});
});
}
Future<int> getCount() {
return _db.managers.remoteAssetEntity.count();
}
}

View File

@ -13,6 +13,8 @@ import 'package:immich_mobile/widgets/settings/language_settings.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/networking_settings.dart';
import 'package:immich_mobile/widgets/settings/notification_setting.dart';
import 'package:immich_mobile/widgets/settings/preference_settings/preference_setting.dart';
import 'package:immich_mobile/entities/store.entity.dart' as app_store;
import 'package:immich_mobile/widgets/settings/settings_card.dart';
enum SettingSection {
advanced(
@ -97,47 +99,11 @@ class _MobileLayout extends StatelessWidget {
Widget build(BuildContext context) {
final List<Widget> settings = SettingSection.values
.map(
(setting) => Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
),
child: Card(
elevation: 0,
clipBehavior: Clip.antiAlias,
color: context.colorScheme.surfaceContainer,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
margin: const EdgeInsets.symmetric(vertical: 4.0),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16.0,
),
leading: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(16)),
color: context.isDarkTheme
? Colors.black26
: Colors.white.withAlpha(100),
),
padding: const EdgeInsets.all(16.0),
child: Icon(setting.icon, color: context.primaryColor),
),
title: Text(
setting.title,
style: context.textTheme.titleMedium!.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
).tr(),
subtitle: Text(
setting.subtitle,
style: context.textTheme.labelLarge,
).tr(),
onTap: () =>
context.pushRoute(SettingsSubRoute(section: setting)),
),
),
(setting) => SettingsCard(
title: setting.title.tr(),
subtitle: setting.subtitle.tr(),
icon: setting.icon,
settingRoute: SettingsSubRoute(section: setting),
),
)
.toList();
@ -146,6 +112,13 @@ class _MobileLayout extends StatelessWidget {
padding: const EdgeInsets.only(top: 10.0, bottom: 56),
children: [
const BetaTimelineListTile(),
if (app_store.Store.isBetaTimelineEnabled)
SettingsCard(
icon: Icons.sync_outlined,
title: 'beta_sync'.tr(),
subtitle: 'beta_sync_subtitle'.tr(),
settingRoute: const BetaSyncSettingsRoute(),
),
...settings,
],
);

View File

@ -0,0 +1,29 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/widgets/settings/beta_sync_settings/beta_sync_settings.dart';
@RoutePage()
class BetaSyncSettingsPage extends StatelessWidget {
const BetaSyncSettingsPage({
super.key,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
elevation: 0,
title: const Text("beta_sync").t(context: context),
leading: IconButton(
onPressed: () => context.maybePop(true),
splashRadius: 24,
icon: const Icon(
Icons.arrow_back_ios_rounded,
),
),
),
body: const BetaSyncSettings(),
);
}
}

View File

@ -8,6 +8,12 @@ final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
onRemoteSyncStart: syncStatusNotifier.startRemoteSync,
onRemoteSyncComplete: syncStatusNotifier.completeRemoteSync,
onRemoteSyncError: syncStatusNotifier.errorRemoteSync,
onLocalSyncStart: syncStatusNotifier.startLocalSync,
onLocalSyncComplete: syncStatusNotifier.completeLocalSync,
onLocalSyncError: syncStatusNotifier.errorLocalSync,
onHashingStart: syncStatusNotifier.startHashJob,
onHashingComplete: syncStatusNotifier.completeHashJob,
onHashingError: syncStatusNotifier.errorHashJob,
);
ref.onDispose(manager.cancel);
return manager;

View File

@ -1,43 +1,71 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
enum SyncStatus {
idle,
syncing,
success,
error,
error;
localized() {
return switch (this) {
SyncStatus.idle => "idle".tr(),
SyncStatus.syncing => "running".tr(),
SyncStatus.success => "success".tr(),
SyncStatus.error => "error".tr()
};
}
}
class SyncStatusState {
final SyncStatus remoteSyncStatus;
final SyncStatus localSyncStatus;
final SyncStatus hashJobStatus;
final String? errorMessage;
const SyncStatusState({
this.remoteSyncStatus = SyncStatus.idle,
this.localSyncStatus = SyncStatus.idle,
this.hashJobStatus = SyncStatus.idle,
this.errorMessage,
});
SyncStatusState copyWith({
SyncStatus? remoteSyncStatus,
SyncStatus? localSyncStatus,
SyncStatus? hashJobStatus,
String? errorMessage,
}) {
return SyncStatusState(
remoteSyncStatus: remoteSyncStatus ?? this.remoteSyncStatus,
localSyncStatus: localSyncStatus ?? this.localSyncStatus,
hashJobStatus: hashJobStatus ?? this.hashJobStatus,
errorMessage: errorMessage ?? this.errorMessage,
);
}
bool get isRemoteSyncing => remoteSyncStatus == SyncStatus.syncing;
bool get isLocalSyncing => localSyncStatus == SyncStatus.syncing;
bool get isHashing => hashJobStatus == SyncStatus.syncing;
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is SyncStatusState &&
other.remoteSyncStatus == remoteSyncStatus &&
other.localSyncStatus == localSyncStatus &&
other.hashJobStatus == hashJobStatus &&
other.errorMessage == errorMessage;
}
@override
int get hashCode => Object.hash(remoteSyncStatus, errorMessage);
int get hashCode => Object.hash(
remoteSyncStatus,
localSyncStatus,
hashJobStatus,
errorMessage,
);
}
class SyncStatusNotifier extends Notifier<SyncStatusState> {
@ -46,9 +74,15 @@ class SyncStatusNotifier extends Notifier<SyncStatusState> {
return const SyncStatusState(
errorMessage: null,
remoteSyncStatus: SyncStatus.idle,
localSyncStatus: SyncStatus.idle,
hashJobStatus: SyncStatus.idle,
);
}
///
/// Remote Sync
///
void setRemoteSyncStatus(SyncStatus status, [String? errorMessage]) {
state = state.copyWith(
remoteSyncStatus: status,
@ -60,6 +94,37 @@ class SyncStatusNotifier extends Notifier<SyncStatusState> {
void completeRemoteSync() => setRemoteSyncStatus(SyncStatus.success);
void errorRemoteSync(String error) =>
setRemoteSyncStatus(SyncStatus.error, error);
///
/// Local Sync
///
void setLocalSyncStatus(SyncStatus status, [String? errorMessage]) {
state = state.copyWith(
localSyncStatus: status,
errorMessage: status == SyncStatus.error ? errorMessage : null,
);
}
void startLocalSync() => setLocalSyncStatus(SyncStatus.syncing);
void completeLocalSync() => setLocalSyncStatus(SyncStatus.success);
void errorLocalSync(String error) =>
setLocalSyncStatus(SyncStatus.error, error);
///
/// Hash Job
///
void setHashJobStatus(SyncStatus status, [String? errorMessage]) {
state = state.copyWith(
hashJobStatus: status,
errorMessage: status == SyncStatus.error ? errorMessage : null,
);
}
void startHashJob() => setHashJobStatus(SyncStatus.syncing);
void completeHashJob() => setHashJobStatus(SyncStatus.success);
void errorHashJob(String error) => setHashJobStatus(SyncStatus.error, error);
}
final syncStatusProvider =

View File

@ -73,6 +73,7 @@ import 'package:immich_mobile/pages/search/map/map_location_picker.page.dart';
import 'package:immich_mobile/pages/search/person_result.page.dart';
import 'package:immich_mobile/pages/search/recently_taken.page.dart';
import 'package:immich_mobile/pages/search/search.page.dart';
import 'package:immich_mobile/pages/settings/beta_sync_settings.page.dart';
import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart';
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
@ -481,7 +482,6 @@ class AppRouter extends RootStackRouter {
page: DriftUserSelectionRoute.page,
guards: [_authGuard, _duplicateGuard],
),
AutoRoute(
page: ChangeExperienceRoute.page,
guards: [_authGuard, _duplicateGuard],
@ -495,6 +495,10 @@ class AppRouter extends RootStackRouter {
page: DriftUploadDetailRoute.page,
guards: [_authGuard, _duplicateGuard],
),
AutoRoute(
page: BetaSyncSettingsRoute.page,
guards: [_authGuard, _duplicateGuard],
),
// required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'),

View File

@ -503,6 +503,22 @@ class BackupOptionsRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [BetaSyncSettingsPage]
class BetaSyncSettingsRoute extends PageRouteInfo<void> {
const BetaSyncSettingsRoute({List<PageRouteInfo>? children})
: super(BetaSyncSettingsRoute.name, initialChildren: children);
static const String name = 'BetaSyncSettingsRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const BetaSyncSettingsPage();
},
);
}
/// generated route for
/// [ChangeExperiencePage]
class ChangeExperienceRoute extends PageRouteInfo<ChangeExperienceRouteArgs> {

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,
),
),
],
),
);
},
),
],
),
);
}
}

View File

@ -0,0 +1,63 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class SettingsCard extends StatelessWidget {
const SettingsCard({
super.key,
required this.icon,
required this.title,
required this.subtitle,
required this.settingRoute,
});
final IconData icon;
final String title;
final String subtitle;
final PageRouteInfo settingRoute;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
),
child: Card(
elevation: 0,
clipBehavior: Clip.antiAlias,
color: context.colorScheme.surfaceContainer,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
margin: const EdgeInsets.symmetric(vertical: 4.0),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16.0,
),
leading: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(16)),
color: context.isDarkTheme
? Colors.black26
: Colors.white.withAlpha(100),
),
padding: const EdgeInsets.all(16.0),
child: Icon(icon, color: context.primaryColor),
),
title: Text(
title,
style: context.textTheme.titleMedium!.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
),
subtitle: Text(
subtitle,
style: context.textTheme.labelLarge,
),
onTap: () => context.pushRoute(settingRoute),
),
),
);
}
}