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
20 changed files with 742 additions and 44 deletions
@@ -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();
}
}
+14 -41
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,
],
);
@@ -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;
+67 -2
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 =
+5 -1
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: '/'),
+16
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> {
@@ -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,
),
),
],
),
);
},
),
],
),
);
}
}
@@ -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),
),
),
);
}
}