From 2efca67217ea7c1306c02624c65dc321235aee82 Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Tue, 22 Jul 2025 11:24:32 -0500 Subject: [PATCH] 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 --- i18n/en.json | 19 + mobile/lib/domain/services/asset.service.dart | 11 + .../domain/services/local_album.service.dart | 4 + .../lib/domain/services/memory.service.dart | 4 + .../domain/services/remote_album.service.dart | 4 + mobile/lib/domain/utils/background_sync.dart | 27 ++ .../repositories/local_album.repository.dart | 4 + .../repositories/local_asset.repository.dart | 10 + .../repositories/memory.repository.dart | 4 + .../repositories/remote_album.repository.dart | 4 + .../repositories/remote_asset.repository.dart | 4 + mobile/lib/pages/common/settings.page.dart | 55 +-- .../settings/beta_sync_settings.page.dart | 29 ++ .../providers/background_sync.provider.dart | 6 + .../lib/providers/sync_status.provider.dart | 69 +++- mobile/lib/routing/router.dart | 6 +- mobile/lib/routing/router.gr.dart | 16 + .../beta_sync_settings.dart | 348 ++++++++++++++++++ .../beta_sync_settings/entity_count_tile.dart | 99 +++++ .../lib/widgets/settings/settings_card.dart | 63 ++++ 20 files changed, 742 insertions(+), 44 deletions(-) create mode 100644 mobile/lib/pages/settings/beta_sync_settings.page.dart create mode 100644 mobile/lib/widgets/settings/beta_sync_settings/beta_sync_settings.dart create mode 100644 mobile/lib/widgets/settings/beta_sync_settings/entity_count_tile.dart create mode 100644 mobile/lib/widgets/settings/settings_card.dart diff --git a/i18n/en.json b/i18n/en.json index 8961943678..ddcb01bf94 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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", diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index 63b1aad8c1..995ed42dc8 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -76,4 +76,15 @@ class AssetService { Future> getPlaces() { return _remoteAssetRepository.getPlaces(); } + + Future<(int local, int remote)> getAssetCounts() async { + return ( + await _localAssetRepository.getCount(), + await _remoteAssetRepository.getCount() + ); + } + + Future getLocalHashedCount() { + return _localAssetRepository.getHashedCount(); + } } diff --git a/mobile/lib/domain/services/local_album.service.dart b/mobile/lib/domain/services/local_album.service.dart index 79cc58f3e0..6c1479fdc9 100644 --- a/mobile/lib/domain/services/local_album.service.dart +++ b/mobile/lib/domain/services/local_album.service.dart @@ -18,4 +18,8 @@ class LocalAlbumService { Future update(LocalAlbum album) { return _repository.upsert(album); } + + Future getCount() { + return _repository.getCount(); + } } diff --git a/mobile/lib/domain/services/memory.service.dart b/mobile/lib/domain/services/memory.service.dart index c94b8a9f0a..50ed1e0f75 100644 --- a/mobile/lib/domain/services/memory.service.dart +++ b/mobile/lib/domain/services/memory.service.dart @@ -12,4 +12,8 @@ class DriftMemoryService { Future> getMemoryLane(String ownerId) { return _repository.getAll(ownerId); } + + Future getCount() { + return _repository.getCount(); + } } diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart index ebb24d5fe5..0f36fac2b9 100644 --- a/mobile/lib/domain/services/remote_album.service.dart +++ b/mobile/lib/domain/services/remote_album.service.dart @@ -147,4 +147,8 @@ class RemoteAlbumService { return _repository.addUsers(albumId, userIds); } + + Future getCount() { + return _repository.getCount(); + } } diff --git a/mobile/lib/domain/utils/background_sync.dart b/mobile/lib/domain/utils/background_sync.dart index 4a44c4d8f2..af66dda7a9 100644 --- a/mobile/lib/domain/utils/background_sync.dart +++ b/mobile/lib/domain/utils/background_sync.dart @@ -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? _syncTask; Cancelable? _syncWebsocketTask; Cancelable? _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 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; }); } diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart index 5f192a20cf..16c363b839 100644 --- a/mobile/lib/infrastructure/repositories/local_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart @@ -398,4 +398,8 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository { return results.isNotEmpty ? results.first : null; } + + Future getCount() { + return _db.managers.localAlbumEntity.count(); + } } diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index 8d21c858a2..bf1ec37615 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -63,4 +63,14 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { return query.map((row) => row.toDto()).getSingleOrNull(); } + + Future getCount() { + return _db.managers.localAssetEntity.count(); + } + + Future getHashedCount() { + return _db.managers.localAssetEntity + .filter((e) => e.checksum.isNull().not()) + .count(); + } } diff --git a/mobile/lib/infrastructure/repositories/memory.repository.dart b/mobile/lib/infrastructure/repositories/memory.repository.dart index ff5f75c2ac..8582290c61 100644 --- a/mobile/lib/infrastructure/repositories/memory.repository.dart +++ b/mobile/lib/infrastructure/repositories/memory.repository.dart @@ -58,6 +58,10 @@ class DriftMemoryRepository extends DriftDatabaseRepository { return memoriesMap.values.toList(); } + + Future getCount() { + return _db.managers.memoryEntity.count(); + } } extension on MemoryEntityData { diff --git a/mobile/lib/infrastructure/repositories/remote_album.repository.dart b/mobile/lib/infrastructure/repositories/remote_album.repository.dart index c3c4570559..ca8dc2cfd5 100644 --- a/mobile/lib/infrastructure/repositories/remote_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_album.repository.dart @@ -268,6 +268,10 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { return album; }).watchSingleOrNull(); } + + Future getCount() { + return _db.managers.remoteAlbumEntity.count(); + } } extension on RemoteAlbumEntityData { diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index 52cfe2e7c2..865c35be54 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -238,4 +238,8 @@ class RemoteAssetRepository extends DriftDatabaseRepository { }); }); } + + Future getCount() { + return _db.managers.remoteAssetEntity.count(); + } } diff --git a/mobile/lib/pages/common/settings.page.dart b/mobile/lib/pages/common/settings.page.dart index e45001270c..b19ff87aa9 100644 --- a/mobile/lib/pages/common/settings.page.dart +++ b/mobile/lib/pages/common/settings.page.dart @@ -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 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, ], ); diff --git a/mobile/lib/pages/settings/beta_sync_settings.page.dart b/mobile/lib/pages/settings/beta_sync_settings.page.dart new file mode 100644 index 0000000000..ba23ccf5eb --- /dev/null +++ b/mobile/lib/pages/settings/beta_sync_settings.page.dart @@ -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(), + ); + } +} diff --git a/mobile/lib/providers/background_sync.provider.dart b/mobile/lib/providers/background_sync.provider.dart index dc9cc0d59f..e6e83b64df 100644 --- a/mobile/lib/providers/background_sync.provider.dart +++ b/mobile/lib/providers/background_sync.provider.dart @@ -8,6 +8,12 @@ final backgroundSyncProvider = Provider((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; diff --git a/mobile/lib/providers/sync_status.provider.dart b/mobile/lib/providers/sync_status.provider.dart index 18d851aa19..5640ad6c89 100644 --- a/mobile/lib/providers/sync_status.provider.dart +++ b/mobile/lib/providers/sync_status.provider.dart @@ -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 { @@ -46,9 +74,15 @@ class SyncStatusNotifier extends Notifier { 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 { 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 = diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index ba31ccef2b..5b3a92d4e2 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -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: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 0e24f776d8..a59b15856f 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -503,6 +503,22 @@ class BackupOptionsRoute extends PageRouteInfo { ); } +/// generated route for +/// [BetaSyncSettingsPage] +class BetaSyncSettingsRoute extends PageRouteInfo { + const BetaSyncSettingsRoute({List? 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 { diff --git a/mobile/lib/widgets/settings/beta_sync_settings/beta_sync_settings.dart b/mobile/lib/widgets/settings/beta_sync_settings/beta_sync_settings.dart new file mode 100644 index 0000000000..3d37fb102b --- /dev/null +++ b/mobile/lib/widgets/settings/beta_sync_settings/beta_sync_settings.dart @@ -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> 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 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>( + 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), + ), + ), + ); + } +} diff --git a/mobile/lib/widgets/settings/beta_sync_settings/entity_count_tile.dart b/mobile/lib/widgets/settings/beta_sync_settings/entity_count_tile.dart new file mode 100644 index 0000000000..1e140fbf2e --- /dev/null +++ b/mobile/lib/widgets/settings/beta_sync_settings/entity_count_tile.dart @@ -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, + ), + ), + ], + ), + ); + }, + ), + ], + ), + ); + } +} diff --git a/mobile/lib/widgets/settings/settings_card.dart b/mobile/lib/widgets/settings/settings_card.dart new file mode 100644 index 0000000000..257c0ce2d6 --- /dev/null +++ b/mobile/lib/widgets/settings/settings_card.dart @@ -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), + ), + ), + ); + } +}