mirror of
https://github.com/immich-app/immich.git
synced 2025-07-31 15:08:44 -04:00
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:
parent
97daf42fd5
commit
2efca67217
19
i18n/en.json
19
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",
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -18,4 +18,8 @@ class LocalAlbumService {
|
||||
Future<void> update(LocalAlbum album) {
|
||||
return _repository.upsert(album);
|
||||
}
|
||||
|
||||
Future<int> getCount() {
|
||||
return _repository.getCount();
|
||||
}
|
||||
}
|
||||
|
@ -12,4 +12,8 @@ class DriftMemoryService {
|
||||
Future<List<DriftMemory>> getMemoryLane(String ownerId) {
|
||||
return _repository.getAll(ownerId);
|
||||
}
|
||||
|
||||
Future<int> getCount() {
|
||||
return _repository.getCount();
|
||||
}
|
||||
}
|
||||
|
@ -147,4 +147,8 @@ class RemoteAlbumService {
|
||||
|
||||
return _repository.addUsers(albumId, userIds);
|
||||
}
|
||||
|
||||
Future<int> getCount() {
|
||||
return _repository.getCount();
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
@ -398,4 +398,8 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
||||
|
||||
return results.isNotEmpty ? results.first : null;
|
||||
}
|
||||
|
||||
Future<int> getCount() {
|
||||
return _db.managers.localAlbumEntity.count();
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -58,6 +58,10 @@ class DriftMemoryRepository extends DriftDatabaseRepository {
|
||||
|
||||
return memoriesMap.values.toList();
|
||||
}
|
||||
|
||||
Future<int> getCount() {
|
||||
return _db.managers.memoryEntity.count();
|
||||
}
|
||||
}
|
||||
|
||||
extension on MemoryEntityData {
|
||||
|
@ -268,6 +268,10 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
||||
return album;
|
||||
}).watchSingleOrNull();
|
||||
}
|
||||
|
||||
Future<int> getCount() {
|
||||
return _db.managers.remoteAlbumEntity.count();
|
||||
}
|
||||
}
|
||||
|
||||
extension on RemoteAlbumEntityData {
|
||||
|
@ -238,4 +238,8 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<int> getCount() {
|
||||
return _db.managers.remoteAssetEntity.count();
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
29
mobile/lib/pages/settings/beta_sync_settings.page.dart
Normal file
29
mobile/lib/pages/settings/beta_sync_settings.page.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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 =
|
||||
|
@ -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: '/'),
|
||||
|
@ -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> {
|
||||
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
63
mobile/lib/widgets/settings/settings_card.dart
Normal file
63
mobile/lib/widgets/settings/settings_card.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user