From 5933664862950a6aec7a47f2a79445d592361d45 Mon Sep 17 00:00:00 2001 From: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Date: Wed, 29 Apr 2026 00:43:52 +0700 Subject: [PATCH] refactor to per row store --- .../lib/domain/models/config/app_config.dart | 28 +--- .../lib/domain/models/config/log_config.dart | 17 +- .../domain/models/config/system_config.dart | 27 +--- .../domain/models/config/theme_config.dart | 16 +- .../models/metadata/system_metadata.dart | 23 --- mobile/lib/domain/models/metadata_key.dart | 31 ++++ mobile/lib/domain/models/metadata_kind.dart | 16 -- mobile/lib/domain/models/metadata_value.dart | 5 - mobile/lib/domain/services/log.service.dart | 16 +- mobile/lib/extensions/json_extensions.dart | 4 - .../cached_metadata.repository.dart | 55 ------- .../repositories/metadata.repository.dart | 111 ++++++++++--- .../infrastructure/metadata.provider.dart | 28 +--- mobile/lib/providers/theme.provider.dart | 2 +- mobile/lib/services/auth.service.dart | 8 +- mobile/lib/utils/bootstrap.dart | 7 +- mobile/lib/utils/migration.dart | 9 +- .../preference_settings/theme_setting.dart | 9 +- .../domain/services/log_service_test.dart | 26 ++- .../test/infrastructure/repository.mock.dart | 4 +- .../metadata_repository_test.dart | 153 ++++++++++++++++++ mobile/test/services/auth.service_test.dart | 9 +- 22 files changed, 338 insertions(+), 266 deletions(-) delete mode 100644 mobile/lib/domain/models/metadata/system_metadata.dart create mode 100644 mobile/lib/domain/models/metadata_key.dart delete mode 100644 mobile/lib/domain/models/metadata_kind.dart delete mode 100644 mobile/lib/domain/models/metadata_value.dart delete mode 100644 mobile/lib/extensions/json_extensions.dart delete mode 100644 mobile/lib/infrastructure/repositories/cached_metadata.repository.dart create mode 100644 mobile/test/medium/repositories/metadata_repository_test.dart diff --git a/mobile/lib/domain/models/config/app_config.dart b/mobile/lib/domain/models/config/app_config.dart index e6e78f3923..3f0ca91db7 100644 --- a/mobile/lib/domain/models/config/app_config.dart +++ b/mobile/lib/domain/models/config/app_config.dart @@ -1,33 +1,11 @@ -import 'package:flutter/material.dart'; import 'package:immich_mobile/domain/models/config/theme_config.dart'; -import 'package:immich_mobile/domain/models/metadata_value.dart'; -import 'package:immich_mobile/extensions/json_extensions.dart'; - -class AppConfig implements MetadataValue { - static const String name = 'app-config'; +class AppConfig { final ThemeConfig theme; const AppConfig({this.theme = const ThemeConfig()}); - factory AppConfig.fromJson(Map json) { - final themeJson = json.nested(ThemeConfig.name); - return AppConfig( - theme: ThemeConfig( - mode: ThemeMode.values.firstWhere( - (e) => e.name == themeJson[ThemeConfig.keys.mode], - orElse: () => ThemeMode.system, - ), - ), - ); - } - - @override - Map toJson() => { - ThemeConfig.name: {ThemeConfig.keys.mode: theme.mode.name}, - }; - - AppConfig copyWith({ThemeMode? themeMode}) => AppConfig(theme: ThemeConfig(mode: themeMode ?? theme.mode)); + AppConfig copyWith({ThemeConfig? theme}) => .new(theme: theme ?? this.theme); @override bool operator ==(Object other) => identical(this, other) || (other is AppConfig && other.theme == theme); @@ -36,5 +14,5 @@ class AppConfig implements MetadataValue { int get hashCode => theme.hashCode; @override - String toString() => '$name: { $theme }'; + String toString() => 'AppConfig(theme: $theme)'; } diff --git a/mobile/lib/domain/models/config/log_config.dart b/mobile/lib/domain/models/config/log_config.dart index 5f563e594d..ef6a3a1293 100644 --- a/mobile/lib/domain/models/config/log_config.dart +++ b/mobile/lib/domain/models/config/log_config.dart @@ -1,20 +1,11 @@ import 'package:immich_mobile/domain/models/log.model.dart'; -class _Keys { - const _Keys(); - - final level = 'level'; -} - class LogConfig { - static const String name = 'log'; - - // ignore: library_private_types_in_public_api - static const _Keys keys = _Keys(); - final LogLevel level; - const LogConfig({this.level = LogLevel.info}); + const LogConfig({this.level = .info}); + + LogConfig copyWith({LogLevel? level}) => .new(level: level ?? this.level); @override bool operator ==(Object other) => identical(this, other) || (other is LogConfig && other.level == level); @@ -23,5 +14,5 @@ class LogConfig { int get hashCode => level.hashCode; @override - String toString() => '$name: {${keys.level}: $level}'; + String toString() => 'LogConfig(level: $level)'; } diff --git a/mobile/lib/domain/models/config/system_config.dart b/mobile/lib/domain/models/config/system_config.dart index 19d1942a32..e722b0fb8d 100644 --- a/mobile/lib/domain/models/config/system_config.dart +++ b/mobile/lib/domain/models/config/system_config.dart @@ -1,30 +1,11 @@ import 'package:immich_mobile/domain/models/config/log_config.dart'; -import 'package:immich_mobile/domain/models/log.model.dart'; -import 'package:immich_mobile/domain/models/metadata_value.dart'; -import 'package:immich_mobile/extensions/json_extensions.dart'; - -class SystemConfig implements MetadataValue { - static const String name = 'system-config'; +class SystemConfig { final LogConfig log; - const SystemConfig({this.log = const LogConfig()}); + const SystemConfig({this.log = const .new()}); - factory SystemConfig.fromJson(Map json) { - final logJson = json.nested(LogConfig.name); - return SystemConfig( - log: LogConfig( - level: LogLevel.values.firstWhere((e) => e.name == logJson[LogConfig.keys.level], orElse: () => LogLevel.info), - ), - ); - } - - @override - Map toJson() => { - LogConfig.name: {LogConfig.keys.level: log.level.name}, - }; - - SystemConfig copyWith({LogLevel? logLevel}) => SystemConfig(log: LogConfig(level: logLevel ?? log.level)); + SystemConfig copyWith({LogConfig? log}) => .new(log: log ?? this.log); @override bool operator ==(Object other) => identical(this, other) || (other is SystemConfig && other.log == log); @@ -33,5 +14,5 @@ class SystemConfig implements MetadataValue { int get hashCode => log.hashCode; @override - String toString() => '$name: { $log }'; + String toString() => 'SystemConfig(log: $log)'; } diff --git a/mobile/lib/domain/models/config/theme_config.dart b/mobile/lib/domain/models/config/theme_config.dart index df155be552..6e0c007151 100644 --- a/mobile/lib/domain/models/config/theme_config.dart +++ b/mobile/lib/domain/models/config/theme_config.dart @@ -1,19 +1,11 @@ import 'package:flutter/material.dart'; -class _Keys { - const _Keys(); - - final mode = 'mode'; -} - class ThemeConfig { - static const String name = 'theme'; - // ignore: library_private_types_in_public_api - static const _Keys keys = _Keys(); - final ThemeMode mode; - const ThemeConfig({this.mode = ThemeMode.system}); + const ThemeConfig({this.mode = .system}); + + ThemeConfig copyWith({ThemeMode? mode}) => .new(mode: mode ?? this.mode); @override bool operator ==(Object other) => identical(this, other) || (other is ThemeConfig && other.mode == mode); @@ -22,5 +14,5 @@ class ThemeConfig { int get hashCode => mode.hashCode; @override - String toString() => '$name: {${keys.mode}: $mode}'; + String toString() => 'ThemeConfig(mode: $mode)'; } diff --git a/mobile/lib/domain/models/metadata/system_metadata.dart b/mobile/lib/domain/models/metadata/system_metadata.dart deleted file mode 100644 index b3e4fa4295..0000000000 --- a/mobile/lib/domain/models/metadata/system_metadata.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:immich_mobile/domain/models/metadata_value.dart'; - -class SystemMetadata implements MetadataValue { - static const String name = 'system-metadata'; - - const SystemMetadata(); - - factory SystemMetadata.fromJson(Map json) => const SystemMetadata(); - - @override - Map toJson() => const {}; - - SystemMetadata copyWith() => this; - - @override - bool operator ==(Object other) => other is SystemMetadata; - - @override - int get hashCode => 0; - - @override - String toString() => '$name: {}'; -} diff --git a/mobile/lib/domain/models/metadata_key.dart b/mobile/lib/domain/models/metadata_key.dart new file mode 100644 index 0000000000..0a8b210e63 --- /dev/null +++ b/mobile/lib/domain/models/metadata_key.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; + +enum MetadataDomain { + appConfig('app-config'), + systemConfig('system-config'); + + final String prefix; + const MetadataDomain(this.prefix); +} + +enum MetadataKey { + themeMode(.appConfig, 'theme.mode', .system, ThemeMode.values), + logLevel(.systemConfig, 'log.level', .info, LogLevel.values); + + final MetadataDomain domain; + final String name; + final T defaultValue; + final List? enumValues; + + const MetadataKey(this.domain, this.name, this.defaultValue, [this.enumValues]); + + String get key => '${domain.prefix}.$name'; + + static MetadataKey? fromKey(String key) { + for (final m in MetadataKey.values) { + if (m.key == key) return m; + } + return null; + } +} diff --git a/mobile/lib/domain/models/metadata_kind.dart b/mobile/lib/domain/models/metadata_kind.dart deleted file mode 100644 index 7369ffc4dc..0000000000 --- a/mobile/lib/domain/models/metadata_kind.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:immich_mobile/domain/models/config/app_config.dart'; -import 'package:immich_mobile/domain/models/config/system_config.dart'; -import 'package:immich_mobile/domain/models/metadata/system_metadata.dart'; -import 'package:immich_mobile/domain/models/metadata_value.dart'; - -enum MetadataKind { - appConfig(AppConfig.name, AppConfig.fromJson, AppConfig()), - systemConfig(SystemConfig.name, SystemConfig.fromJson, SystemConfig()), - systemMetadata(SystemMetadata.name, SystemMetadata.fromJson, SystemMetadata()); - - final String key; - final T Function(Map) fromJson; - final T defaultValue; - - const MetadataKind(this.key, this.fromJson, this.defaultValue); -} diff --git a/mobile/lib/domain/models/metadata_value.dart b/mobile/lib/domain/models/metadata_value.dart deleted file mode 100644 index 87e97de0f0..0000000000 --- a/mobile/lib/domain/models/metadata_value.dart +++ /dev/null @@ -1,5 +0,0 @@ -abstract class MetadataValue { - const MetadataValue(); - - Map toJson(); -} diff --git a/mobile/lib/domain/services/log.service.dart b/mobile/lib/domain/services/log.service.dart index 15aa6be1ff..c28af23f95 100644 --- a/mobile/lib/domain/services/log.service.dart +++ b/mobile/lib/domain/services/log.service.dart @@ -2,9 +2,9 @@ import 'dart:async'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; -import 'package:immich_mobile/domain/models/metadata_kind.dart'; -import 'package:immich_mobile/infrastructure/repositories/cached_metadata.repository.dart'; +import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart'; import 'package:immich_mobile/utils/debug_print.dart'; import 'package:logging/logging.dart'; @@ -12,10 +12,10 @@ import 'package:logging/logging.dart'; /// /// It listens to Dart's [Logger.root], buffers logs in memory (optionally), /// writes them to a persistent [LogRepository], and manages log levels via -/// [CachedMetadataRepository]. +/// [MetadataRepository]. class LogService { final LogRepository _logRepository; - final CachedMetadataRepository _metadataRepository; + final MetadataRepository _metadataRepository; final List _msgBuffer = []; @@ -38,7 +38,7 @@ class LogService { static Future init({ required LogRepository logRepository, - required CachedMetadataRepository metadataRepository, + required MetadataRepository metadataRepository, bool shouldBuffer = true, }) async { _instance ??= await create( @@ -51,12 +51,12 @@ class LogService { static Future create({ required LogRepository logRepository, - required CachedMetadataRepository metadataRepository, + required MetadataRepository metadataRepository, bool shouldBuffer = true, }) async { final instance = LogService._(logRepository, metadataRepository, shouldBuffer); await logRepository.truncate(limit: kLogTruncateLimit); - final level = instance._metadataRepository.read(MetadataKind.systemConfig).log.level; + final level = instance._metadataRepository.systemConfig.log.level; Logger.root.level = Level.LEVELS.elementAtOrNull(level.index) ?? Level.INFO; return instance; } @@ -91,7 +91,7 @@ class LogService { } Future setLogLevel(LogLevel level) async { - await _metadataRepository.update(MetadataKind.systemConfig, (current) => current.copyWith(logLevel: level)); + await _metadataRepository.write(MetadataKey.logLevel, level); Logger.root.level = level.toLevel(); } diff --git a/mobile/lib/extensions/json_extensions.dart b/mobile/lib/extensions/json_extensions.dart deleted file mode 100644 index 8524500ac5..0000000000 --- a/mobile/lib/extensions/json_extensions.dart +++ /dev/null @@ -1,4 +0,0 @@ -extension JsonHelper on Map { - /// Returns the nested map under [key] or an empty const map if the key is absent or the value is not a map - Map nested(String key) => (this[key] as Map?) ?? const {}; -} diff --git a/mobile/lib/infrastructure/repositories/cached_metadata.repository.dart b/mobile/lib/infrastructure/repositories/cached_metadata.repository.dart deleted file mode 100644 index ab1c65a9a2..0000000000 --- a/mobile/lib/infrastructure/repositories/cached_metadata.repository.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:immich_mobile/domain/models/config/app_config.dart'; -import 'package:immich_mobile/domain/models/metadata_kind.dart'; -import 'package:immich_mobile/domain/models/metadata_value.dart'; -import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart'; - -class CachedMetadataRepository { - final MetadataRepository _repository; - final Map _cache = {}; - - CachedMetadataRepository._(this._repository); - - static CachedMetadataRepository? _instance; - static CachedMetadataRepository get instance { - if (_instance == null) { - throw UnsupportedError('CachedMetadataRepository not initialized. Call ensureInitialized() first'); - } - return _instance!; - } - - static Future ensureInitialized({required MetadataRepository repository}) async { - if (_instance == null) { - final instance = CachedMetadataRepository._(repository); - await instance._hydrate(); - _instance = instance; - } - return _instance!; - } - - Future _hydrate() async { - for (final kind in MetadataKind.values) { - _cache[kind] = await _repository.get(kind); - } - } - - T read(MetadataKind kind) => (_cache[kind] as T?) ?? kind.defaultValue; - - Future update(MetadataKind kind, T Function(T current) mutator) async { - final current = read(kind); - final updated = mutator(current); - if (_cache[kind] == updated) return; - await _repository.set(kind, updated); - _cache[kind] = updated; - } - - Future setAppConfig(AppConfig Function(AppConfig current) mutator) { - return update(MetadataKind.appConfig, (c) => mutator.call(c)); - } - - Future clear(MetadataKind kind) async { - await _repository.delete(kind); - _cache[kind] = kind.defaultValue; - } - - Stream watch(MetadataKind kind) => _repository.watch(kind); -} diff --git a/mobile/lib/infrastructure/repositories/metadata.repository.dart b/mobile/lib/infrastructure/repositories/metadata.repository.dart index bb40da1e3d..7bf5234c81 100644 --- a/mobile/lib/infrastructure/repositories/metadata.repository.dart +++ b/mobile/lib/infrastructure/repositories/metadata.repository.dart @@ -1,43 +1,110 @@ -import 'dart:convert'; - import 'package:drift/drift.dart'; -import 'package:immich_mobile/domain/models/metadata_kind.dart'; -import 'package:immich_mobile/domain/models/metadata_value.dart'; +import 'package:immich_mobile/domain/models/config/app_config.dart'; +import 'package:immich_mobile/domain/models/config/log_config.dart'; +import 'package:immich_mobile/domain/models/config/system_config.dart'; +import 'package:immich_mobile/domain/models/config/theme_config.dart'; +import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; class MetadataRepository extends DriftDatabaseRepository { final Drift _db; + final Map _cache = {}; - const MetadataRepository(this._db) : super(_db); + MetadataRepository._(this._db) : super(_db); - Future get(MetadataKind kind) async { - final row = await (_db.select(_db.metadataEntity)..where((t) => t.key.equals(kind.key))).getSingleOrNull(); - return _toValue(kind, row) as T; + static MetadataRepository? _instance; + + static MetadataRepository get instance { + final instance = _instance; + if (instance == null) { + throw StateError('MetadataRepository not initialized. Call ensureInitialized() first'); + } + return instance; } - Future set(MetadataKind kind, T value) async { + static Future ensureInitialized(Drift db) async { + if (_instance == null) { + final instance = MetadataRepository._(db); + await instance._hydrate(); + _instance = instance; + } + return _instance!; + } + + static Future refresh() async { + instance._cache.clear(); + await instance._hydrate(); + } + + Future _hydrate() async { + final rows = await _db.select(_db.metadataEntity).get(); + for (final row in rows) { + final key = MetadataKey.fromKey(row.key); + if (key != null) _cache[key] = _decode(key, row.value); + } + } + + T _read(MetadataKey key) => (_cache[key] as T?) ?? key.defaultValue; + + Future write(MetadataKey key, T value) async { + if (_read(key) == value) return; + await _db .into(_db.metadataEntity) .insertOnConflictUpdate( - MetadataEntityCompanion.insert( - key: kind.key, - value: jsonEncode(value.toJson()), - updatedAt: Value(DateTime.now()), - ), + MetadataEntityCompanion.insert(key: key.key, value: _encode(value), updatedAt: Value(DateTime.now())), ); + _cache[key] = value; } - Future delete(MetadataKind kind) async { - await (_db.delete(_db.metadataEntity)..where((t) => t.key.equals(kind.key))).go(); + String _encode(T value) => switch (value) { + Enum() => value.name, + DateTime() => value.toIso8601String(), + _ => throw ArgumentError('Unsupported metadata value type: ${value.runtimeType}'), + }; + + T _decode(MetadataKey key, String raw) { + final enumValues = key.enumValues; + if (enumValues != null) { + return enumValues.where((v) => (v as Enum).name == raw).firstOrNull ?? key.defaultValue; + } + return switch (key.defaultValue) { + DateTime() => (DateTime.tryParse(raw) ?? key.defaultValue) as T, + _ => throw ArgumentError('Unsupported metadata value type: ${key.defaultValue.runtimeType}'), + }; } - Stream watch(MetadataKind kind) { - return (_db.select( - _db.metadataEntity, - )..where((t) => t.key.equals(kind.key))).watchSingleOrNull().map((row) => _toValue(kind, row) as T); + Future delete(MetadataKey key) async { + _cache[key] = key.defaultValue; + await (_db.delete(_db.metadataEntity)..where((t) => t.key.equals(key.key))).go(); } - MetadataValue _toValue(MetadataKind kind, MetadataEntityData? row) => - row == null ? kind.defaultValue : kind.fromJson(jsonDecode(row.value) as Map); + Future clearDomain(MetadataDomain domain) async { + for (final k in MetadataKey.values.where((k) => k.domain == domain)) { + _cache[k] = k.defaultValue; + } + + await (_db.delete(_db.metadataEntity)..where((t) => t.key.like('${domain.prefix}.%'))).go(); + } + + AppConfig get appConfig => AppConfig(theme: ThemeConfig(mode: _read(MetadataKey.themeMode))); + + SystemConfig get systemConfig => SystemConfig(log: LogConfig(level: _read(MetadataKey.logLevel))); + + Stream watchAppConfig() => _watchDomain(MetadataDomain.appConfig).map((_) => appConfig).distinct(); + + Stream watchSystemConfig() => + _watchDomain(MetadataDomain.systemConfig).map((_) => systemConfig).distinct(); + + Stream _watchDomain(MetadataDomain domain) { + final query = _db.select(_db.metadataEntity)..where((t) => t.key.like('${domain.prefix}.%')); + return query.watch().map((rows) => rows.forEach(_updateCacheForRow)); + } + + void _updateCacheForRow(MetadataEntityData row) { + final key = MetadataKey.fromKey(row.key); + if (key == null) return; + _cache[key] = _decode(key, row.value); + } } diff --git a/mobile/lib/providers/infrastructure/metadata.provider.dart b/mobile/lib/providers/infrastructure/metadata.provider.dart index 8961715e0d..e6b2232b0e 100644 --- a/mobile/lib/providers/infrastructure/metadata.provider.dart +++ b/mobile/lib/providers/infrastructure/metadata.provider.dart @@ -1,32 +1,20 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/config/app_config.dart'; import 'package:immich_mobile/domain/models/config/system_config.dart'; -import 'package:immich_mobile/domain/models/metadata/system_metadata.dart'; -import 'package:immich_mobile/domain/models/metadata_kind.dart'; -import 'package:immich_mobile/infrastructure/repositories/cached_metadata.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart'; -final metadataProvider = Provider.autoDispose((_) => CachedMetadataRepository.instance); +final metadataProvider = Provider((_) => MetadataRepository.instance); final appConfigProvider = Provider.autoDispose((ref) { - final subscription = ref.watch(metadataProvider).watch(MetadataKind.appConfig).listen((event) { - ref.state = event; - }); + final repo = ref.watch(metadataProvider); + final subscription = repo.watchAppConfig().listen((event) => ref.state = event); ref.onDispose(subscription.cancel); - return ref.watch(metadataProvider).read(MetadataKind.appConfig); + return repo.appConfig; }); final systemConfigProvider = Provider.autoDispose((ref) { - final subscription = ref.watch(metadataProvider).watch(MetadataKind.systemConfig).listen((event) { - ref.state = event; - }); + final repo = ref.watch(metadataProvider); + final subscription = repo.watchSystemConfig().listen((event) => ref.state = event); ref.onDispose(subscription.cancel); - return ref.watch(metadataProvider).read(MetadataKind.systemConfig); -}); - -final systemMetadataProvider = Provider.autoDispose((ref) { - final subscription = ref.watch(metadataProvider).watch(MetadataKind.systemMetadata).listen((event) { - ref.state = event; - }); - ref.onDispose(subscription.cancel); - return ref.watch(metadataProvider).read(MetadataKind.systemMetadata); + return repo.systemConfig; }); diff --git a/mobile/lib/providers/theme.provider.dart b/mobile/lib/providers/theme.provider.dart index 5804862c19..2ea3e15c5d 100644 --- a/mobile/lib/providers/theme.provider.dart +++ b/mobile/lib/providers/theme.provider.dart @@ -9,7 +9,7 @@ import 'package:immich_mobile/theme/dynamic_theme.dart'; import 'package:immich_mobile/theme/theme_data.dart'; import 'package:immich_mobile/utils/debug_print.dart'; -final immichThemeModeProvider = StateProvider((ref) => ref.read(appConfigProvider).theme.mode); +final immichThemeModeProvider = StateProvider((ref) => ref.watch(appConfigProvider).theme.mode); final immichThemePresetProvider = StateProvider((ref) { final appSettingsProvider = ref.watch(appSettingsServiceProvider); diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart index 3f66744c57..f11f112c37 100644 --- a/mobile/lib/services/auth.service.dart +++ b/mobile/lib/services/auth.service.dart @@ -1,11 +1,11 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/metadata_kind.dart'; +import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/infrastructure/repositories/cached_metadata.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/models/auth/login_response.model.dart'; @@ -40,7 +40,7 @@ class AuthService { final NetworkService _networkService; final BackgroundSyncManager _backgroundSyncManager; final AppSettingsService _appSettingsService; - final CachedMetadataRepository _metadataRepository; + final MetadataRepository _metadataRepository; final _log = Logger("AuthService"); AuthService( @@ -134,7 +134,7 @@ class AuthService { Store.delete(StoreKey.preferredWifiName), Store.delete(StoreKey.localEndpoint), Store.delete(StoreKey.externalEndpointList), - _metadataRepository.clear(MetadataKind.appConfig), + _metadataRepository.clearDomain(MetadataDomain.appConfig), ]); } diff --git a/mobile/lib/utils/bootstrap.dart b/mobile/lib/utils/bootstrap.dart index 0a6398e722..68ebfe9c9f 100644 --- a/mobile/lib/utils/bootstrap.dart +++ b/mobile/lib/utils/bootstrap.dart @@ -3,7 +3,6 @@ import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; -import 'package:immich_mobile/infrastructure/repositories/cached_metadata.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart'; @@ -50,13 +49,11 @@ abstract final class Bootstrap { await StoreService.init(storeRepository: storeRepo, listenUpdates: listenStoreUpdates); - final cachedMetadataRepository = await CachedMetadataRepository.ensureInitialized( - repository: MetadataRepository(drift), - ); + final metadataRepo = await MetadataRepository.ensureInitialized(drift); await LogService.init( logRepository: LogRepository(logDb), - metadataRepository: cachedMetadataRepository, + metadataRepository: metadataRepo, shouldBuffer: shouldBufferLogs, ); diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 564f7c70ed..c08a4e4353 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -3,12 +3,12 @@ import 'dart:async'; import 'package:drift/drift.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; -import 'package:immich_mobile/domain/models/metadata_kind.dart'; +import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/infrastructure/repositories/cached_metadata.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; @@ -43,20 +43,19 @@ Future _migrateTo26(Drift drift) async { const int themeModeKey = 102; const int logLevelKey = 115; - final cache = CachedMetadataRepository.instance; + final repo = MetadataRepository.instance; final migrated = []; final themeMode = await _readLegacyStoreString(drift, themeModeKey); if (themeMode != null) { final mode = ThemeMode.values.firstWhere((m) => m.name == themeMode, orElse: () => ThemeMode.system); - await cache.update(MetadataKind.appConfig, (current) => current.copyWith(themeMode: mode)); + await repo.write(MetadataKey.themeMode, mode); migrated.add(themeModeKey); } final logLevelIndex = await _readLegacyStoreInt(drift, logLevelKey); if (logLevelIndex != null) { final logLevel = LogLevel.values.elementAtOrNull(logLevelIndex) ?? LogLevel.info; - await cache.update(MetadataKind.systemConfig, (current) => current.copyWith(logLevel: logLevel)); await LogService.I.setLogLevel(logLevel); migrated.add(logLevelKey); } diff --git a/mobile/lib/widgets/settings/preference_settings/theme_setting.dart b/mobile/lib/widgets/settings/preference_settings/theme_setting.dart index f3d032491b..05eb65f16e 100644 --- a/mobile/lib/widgets/settings/preference_settings/theme_setting.dart +++ b/mobile/lib/widgets/settings/preference_settings/theme_setting.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/metadata_kind.dart'; +import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; -import 'package:immich_mobile/infrastructure/repositories/cached_metadata.repository.dart'; import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/providers/theme.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; @@ -38,7 +37,7 @@ class ThemeSetting extends HookConsumerWidget { ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.light; currentTheme.value = ThemeMode.light; } - ref.read(metadataProvider).setAppConfig((config) => config.copyWith(themeMode: currentTheme.value)); + ref.read(metadataProvider).write(MetadataKey.themeMode, currentTheme.value); } void onSystemThemeChange(bool isSystem) { @@ -58,9 +57,7 @@ class ThemeSetting extends HookConsumerWidget { ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.dark; } } - ref - .read(metadataProvider) - .update(MetadataKind.appConfig, (appConfig) => appConfig.copyWith(themeMode: currentTheme.value)); + ref.read(metadataProvider).write(MetadataKey.themeMode, currentTheme.value); } void onSurfaceColorSettingChange(bool useColorfulInterface) { diff --git a/mobile/test/domain/services/log_service_test.dart b/mobile/test/domain/services/log_service_test.dart index d1d482b888..7d8a4953c2 100644 --- a/mobile/test/domain/services/log_service_test.dart +++ b/mobile/test/domain/services/log_service_test.dart @@ -4,7 +4,7 @@ import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/config/log_config.dart'; import 'package:immich_mobile/domain/models/config/system_config.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; -import 'package:immich_mobile/domain/models/metadata_kind.dart'; +import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; import 'package:logging/logging.dart'; @@ -30,21 +30,20 @@ final _kWarnLog = LogMessage( void main() { late LogService sut; late LogRepository mockLogRepo; - late MockCachedMetadataRepository mockMetadataRepository; + late MockMetadataRepository mockMetadataRepository; setUp(() async { mockLogRepo = MockLogRepository(); - mockMetadataRepository = MockCachedMetadataRepository(); + mockMetadataRepository = MockMetadataRepository(); registerFallbackValue(_kInfoLog); - SystemConfig identityMutator(SystemConfig c) => c; - registerFallbackValue(identityMutator); + registerFallbackValue(LogLevel.info); when(() => mockLogRepo.truncate(limit: any(named: 'limit'))).thenAnswer((_) async => {}); when( - () => mockMetadataRepository.read(MetadataKind.systemConfig), + () => mockMetadataRepository.systemConfig, ).thenReturn(const SystemConfig(log: LogConfig(level: LogLevel.fine))); - when(() => mockMetadataRepository.update(MetadataKind.systemConfig, any())).thenAnswer((_) async {}); + when(() => mockMetadataRepository.write(MetadataKey.logLevel, any())).thenAnswer((_) async {}); when(() => mockLogRepo.getAll()).thenAnswer((_) async => []); when(() => mockLogRepo.insert(any())).thenAnswer((_) async => true); when(() => mockLogRepo.insertAll(any())).thenAnswer((_) async => true); @@ -63,7 +62,7 @@ void main() { }); test('Sets log level based on the metadata repository', () { - verify(() => mockMetadataRepository.read(MetadataKind.systemConfig)).called(1); + verify(() => mockMetadataRepository.systemConfig).called(1); expect(Logger.root.level, Level.FINE); }); }); @@ -74,13 +73,10 @@ void main() { }); test('Updates the log level via metadata repository', () { - final mutator = - verify( - () => mockMetadataRepository.update(MetadataKind.systemConfig, captureAny()), - ).captured.firstOrNull - as SystemConfig Function(SystemConfig)?; - final result = mutator?.call(const SystemConfig()); - expect(result?.log.level, LogLevel.shout); + final captured = verify( + () => mockMetadataRepository.write(MetadataKey.logLevel, captureAny()), + ).captured.firstOrNull; + expect(captured, LogLevel.shout); }); test('Sets log level on logger', () { diff --git a/mobile/test/infrastructure/repository.mock.dart b/mobile/test/infrastructure/repository.mock.dart index 5a368c73b8..74ecf39038 100644 --- a/mobile/test/infrastructure/repository.mock.dart +++ b/mobile/test/infrastructure/repository.mock.dart @@ -1,8 +1,8 @@ import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; -import 'package:immich_mobile/infrastructure/repositories/cached_metadata.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; @@ -18,7 +18,7 @@ import 'package:mocktail/mocktail.dart'; class MockDriftStoreRepository extends Mock implements DriftStoreRepository {} -class MockCachedMetadataRepository extends Mock implements CachedMetadataRepository {} +class MockMetadataRepository extends Mock implements MetadataRepository {} class MockLogRepository extends Mock implements LogRepository {} diff --git a/mobile/test/medium/repositories/metadata_repository_test.dart b/mobile/test/medium/repositories/metadata_repository_test.dart new file mode 100644 index 0000000000..98c747b32a --- /dev/null +++ b/mobile/test/medium/repositories/metadata_repository_test.dart @@ -0,0 +1,153 @@ +import 'package:drift/drift.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/domain/models/metadata_key.dart'; +import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart'; + +import '../repository_context.dart'; + +void main() { + late MediumRepositoryContext ctx; + late MetadataRepository sut; + + setUpAll(() async { + ctx = MediumRepositoryContext(); + sut = await MetadataRepository.ensureInitialized(ctx.db); + }); + + tearDownAll(() async { + await ctx.dispose(); + }); + + setUp(() async { + await ctx.db.delete(ctx.db.metadataEntity).go(); + await MetadataRepository.refresh(); + }); + + group('defaults', () { + test('appConfig returns key defaults when DB is empty', () { + expect(sut.appConfig.theme.mode, ThemeMode.system); + }); + + test('systemConfig returns key defaults when DB is empty', () { + expect(sut.systemConfig.log.level, LogLevel.info); + }); + }); + + group('write', () { + test('persists a value and reflects it in the composed view', () async { + await sut.write(.themeMode, ThemeMode.dark); + expect(sut.appConfig.theme.mode, ThemeMode.dark); + }); + + test('persists across domains independently', () async { + await sut.write(.themeMode, ThemeMode.light); + await sut.write(.logLevel, LogLevel.severe); + expect(sut.appConfig.theme.mode, ThemeMode.light); + expect(sut.systemConfig.log.level, LogLevel.severe); + }); + }); + + group('delete', () { + test('removes the row and reverts to default', () async { + await sut.write(.themeMode, ThemeMode.dark); + expect(sut.appConfig.theme.mode, ThemeMode.dark); + + await sut.delete(.themeMode); + expect(sut.appConfig.theme.mode, ThemeMode.system); + + final rows = await ctx.db.select(ctx.db.metadataEntity).get(); + expect(rows, isEmpty); + }); + }); + + group('clearDomain', () { + test('clears every key in the domain and leaves other domains alone', () async { + await sut.write(.themeMode, ThemeMode.dark); + await sut.write(.logLevel, LogLevel.severe); + + await sut.clearDomain(.appConfig); + + expect(sut.appConfig.theme.mode, ThemeMode.system); + expect(sut.systemConfig.log.level, LogLevel.severe); + + final remainingKeys = (await ctx.db.select(ctx.db.metadataEntity).get()).map((r) => r.key); + expect(remainingKeys, [MetadataKey.logLevel.key]); + }); + }); + + group('refresh', () { + test('picks up rows that were inserted directly into the DB', () async { + await ctx.db + .into(ctx.db.metadataEntity) + .insert( + MetadataEntityCompanion.insert( + key: MetadataKey.themeMode.key, + value: ThemeMode.dark.name, + updatedAt: Value(DateTime.now()), + ), + ); + + // Cache hasn't seen this row yet — view still returns the default. + expect(sut.appConfig.theme.mode, ThemeMode.system); + + await MetadataRepository.refresh(); + + expect(sut.appConfig.theme.mode, ThemeMode.dark); + }); + + test('drops cached values for rows that were deleted out from under the repo', () async { + await sut.write(.themeMode, ThemeMode.dark); + // Wipe the row directly. Cache still holds the old value. + await ctx.db.delete(ctx.db.metadataEntity).go(); + expect(sut.appConfig.theme.mode, ThemeMode.dark); + + await MetadataRepository.refresh(); + + expect(sut.appConfig.theme.mode, ThemeMode.system); + }); + + test('skips rows whose key is unknown to MetadataKey', () async { + await ctx.db + .into(ctx.db.metadataEntity) + .insert( + MetadataEntityCompanion.insert( + key: 'app-config.unknown.future-key', + value: 'whatever', + updatedAt: Value(DateTime.now()), + ), + ); + + await MetadataRepository.refresh(); + expect(sut.appConfig.theme.mode, ThemeMode.system); + }); + }); + + group('watch', () { + test('watchAppConfig emits the new value after a write', () async { + final expectation = expectLater(sut.watchAppConfig().map((c) => c.theme.mode), emitsThrough(ThemeMode.dark)); + await sut.write(MetadataKey.themeMode, ThemeMode.dark); + await expectation; + }); + + test('watchAppConfig does not emit when only system-config rows change', () async { + final emissions = []; + // skip(1) drops the on-subscribe replay so we only capture emissions caused by the write below. + final sub = sut.watchAppConfig().skip(1).listen((c) => emissions.add(c.theme.mode)); + + await sut.write(MetadataKey.logLevel, LogLevel.severe); + await pumpEventQueue(); + await sub.cancel(); + + expect(emissions, isEmpty); + }); + + test('watchSystemConfig emits the new value after a write', () async { + final expectation = expectLater(sut.watchSystemConfig().map((c) => c.log.level), emitsThrough(LogLevel.warning)); + await sut.write(MetadataKey.logLevel, LogLevel.warning); + await expectation; + }); + }); +} diff --git a/mobile/test/services/auth.service_test.dart b/mobile/test/services/auth.service_test.dart index 3f34ab9c28..95663ee818 100644 --- a/mobile/test/services/auth.service_test.dart +++ b/mobile/test/services/auth.service_test.dart @@ -2,6 +2,7 @@ import 'package:drift/drift.dart' hide isNull; import 'package:drift/native.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; @@ -24,7 +25,7 @@ void main() { late MockNetworkService networkService; late MockBackgroundSyncManager backgroundSyncManager; late MockAppSettingService appSettingsService; - late MockCachedMetadataRepository metadataRepository; + late MockMetadataRepository metadataRepository; late Drift db; setUp(() async { @@ -34,7 +35,7 @@ void main() { networkService = MockNetworkService(); backgroundSyncManager = MockBackgroundSyncManager(); appSettingsService = MockAppSettingService(); - metadataRepository = MockCachedMetadataRepository(); + metadataRepository = MockMetadataRepository(); sut = AuthService( authApiRepository, @@ -114,6 +115,10 @@ void main() { }); group('logout', () { + setUp(() { + when(() => metadataRepository.clearDomain(MetadataDomain.appConfig)).thenAnswer((_) async {}); + }); + test('Should logout user', () async { when(() => authApiRepository.logout()).thenAnswer((_) async => {}); when(() => backgroundSyncManager.cancel()).thenAnswer((_) async => {});