From 82d0c40e6a910c87b572ec0b3a600454b8b247e1 Mon Sep 17 00:00:00 2001 From: insleker Date: Sat, 20 Sep 2025 19:31:27 +0800 Subject: [PATCH] refactor: preferences repository to contain only 1 provicder --- lib/app.dart | 133 +++++++-------- .../repositories/preferences_repository.dart | 158 ++++++++---------- .../preferences/widgets/settings_screen.dart | 110 +++++------- test/widget/export_flow_test.dart | 1 - 4 files changed, 173 insertions(+), 229 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 2a78a86..a1a98f6 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -14,80 +14,69 @@ class MyApp extends StatelessWidget { return ProviderScope( child: Consumer( builder: (context, ref, _) { - // Ensure SharedPreferences loaded before building MaterialApp - final sp = ref.watch(sharedPreferencesProvider); - return sp.when( - loading: () => const SizedBox.shrink(), - error: - (e, st) => MaterialApp( - onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle, - supportedLocales: AppLocalizations.supportedLocales, - localizationsDelegates: - AppLocalizations.localizationsDelegates, - home: Builder( - builder: - (ctx) => Scaffold( - body: Center( - child: Text( - AppLocalizations.of( - ctx, - ).errorWithMessage(e.toString()), - ), + final prefs = ref.watch(preferencesRepositoryProvider); + final seed = themeSeedFromPrefs(prefs); + final appLocale = + supportedLanguageTags().contains(prefs.language) + ? parseLanguageTag(prefs.language) + : null; + final themeMode = () { + switch (prefs.theme) { + case 'light': + return ThemeMode.light; + case 'dark': + return ThemeMode.dark; + case 'system': + default: + return ThemeMode.system; + } + }(); + + return MaterialApp.router( + onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: seed, + brightness: Brightness.light, + ), + ), + darkTheme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: seed, + brightness: Brightness.dark, + ), + ), + themeMode: themeMode, + locale: appLocale, + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: [ + ...AppLocalizations.localizationsDelegates, + LocaleNamesLocalizationsDelegate(), + ], + routerConfig: ref.watch(routerProvider), + builder: (context, child) { + final router = ref.watch(routerProvider); + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context).appTitle), + actions: [ + OutlinedButton.icon( + key: const Key('btn_appbar_settings'), + icon: const Icon(Icons.settings), + label: Text(AppLocalizations.of(context).settings), + onPressed: + () => showDialog( + context: + router + .routerDelegate + .navigatorKey + .currentContext!, + builder: (_) => const SettingsDialog(), ), - ), - ), - ), - data: (_) { - final themeMode = ref.watch(themeModeProvider); - final seed = ref.watch(themeSeedColorProvider); - final appLocale = ref.watch(localeProvider); - return MaterialApp.router( - onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle, - theme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: seed, - brightness: Brightness.light, - ), - ), - darkTheme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: seed, - brightness: Brightness.dark, - ), - ), - themeMode: themeMode, - locale: appLocale, - supportedLocales: AppLocalizations.supportedLocales, - localizationsDelegates: [ - ...AppLocalizations.localizationsDelegates, - LocaleNamesLocalizationsDelegate(), - ], - routerConfig: ref.watch(routerProvider), - builder: (context, child) { - final router = ref.watch(routerProvider); - return Scaffold( - appBar: AppBar( - title: Text(AppLocalizations.of(context).appTitle), - actions: [ - OutlinedButton.icon( - key: const Key('btn_appbar_settings'), - icon: const Icon(Icons.settings), - label: Text(AppLocalizations.of(context).settings), - onPressed: - () => showDialog( - context: - router - .routerDelegate - .navigatorKey - .currentContext!, - builder: (_) => const SettingsDialog(), - ), - ), - ], ), - body: child, - ); - }, + ], + ), + body: child, ); }, ); diff --git a/lib/data/repositories/preferences_repository.dart b/lib/data/repositories/preferences_repository.dart index 2980047..2330d95 100644 --- a/lib/data/repositories/preferences_repository.dart +++ b/lib/data/repositories/preferences_repository.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -26,6 +27,27 @@ Set _supportedTags() { return AppLocalizations.supportedLocales.map((l) => toLanguageTag(l)).toSet(); } +// Public helpers for other layers to consume without extra providers +Set supportedLanguageTags() => _supportedTags(); +Locale parseLanguageTag(String tag) => _parseLanguageTag(tag); +Color themeSeedFromPrefs(PreferencesState prefs) { + final c = PreferencesStateNotifier._tryParseColor(prefs.theme_color); + return c ?? Colors.blue; +} + +Future> languageAutonyms() async { + final tags = _supportedTags().toList()..sort(); + final delegate = LocaleNamesLocalizationsDelegate(); + final Map result = {}; + for (final tag in tags) { + final locale = _parseLanguageTag(tag); + final names = await delegate.load(locale); + final name = names.nameOf(tag) ?? tag; + result[tag] = name; + } + return result; +} + // Keys const _kTheme = 'theme'; // 'light'|'dark'|'system' // Theme color persisted as hex ARGB string (e.g., '#FF2196F3'). @@ -68,7 +90,8 @@ String _normalizeLanguageTag(String tag) { } class PreferencesStateNotifier extends StateNotifier { - final SharedPreferences prefs; + late final SharedPreferences _prefs; + final Completer _ready = Completer(); static Color? _tryParseColor(String? s) { if (s == null || s.isEmpty) return null; final v = s.trim(); @@ -139,21 +162,41 @@ class PreferencesStateNotifier extends StateNotifier { return '#$a$r$g$b'; } - PreferencesStateNotifier(this.prefs) + PreferencesStateNotifier([SharedPreferences? prefs]) : super( PreferencesState( - theme: prefs.getString(_kTheme) ?? 'system', + theme: 'system', language: _normalizeLanguageTag( - prefs.getString(_kLanguage) ?? - WidgetsBinding.instance.platformDispatcher.locale - .toLanguageTag(), + WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag(), ), - exportDpi: _readDpi(prefs), - theme_color: prefs.getString(_kThemeColor) ?? '#FF2196F3', // blue + exportDpi: 144.0, + theme_color: '#FF2196F3', // blue ), ) { - // normalize language to supported/fallback + _init(prefs); + } + + Future _init(SharedPreferences? injected) async { + _prefs = injected ?? await SharedPreferences.getInstance(); + // Load persisted values (with sane defaults) + final loaded = PreferencesState( + theme: _prefs.getString(_kTheme) ?? 'system', + language: _normalizeLanguageTag( + _prefs.getString(_kLanguage) ?? + WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag(), + ), + exportDpi: _readDpi(_prefs), + theme_color: _prefs.getString(_kThemeColor) ?? '#FF2196F3', + ); + state = loaded; _ensureValid(); + if (!_ready.isCompleted) _ready.complete(); + } + + Future _ensureReady() async { + if (!_ready.isCompleted) { + await _ready.future; + } } static double _readDpi(SharedPreferences prefs) { @@ -167,18 +210,18 @@ class PreferencesStateNotifier extends StateNotifier { final themeValid = {'light', 'dark', 'system'}; if (!themeValid.contains(state.theme)) { state = state.copyWith(theme: 'system'); - prefs.setString(_kTheme, 'system'); + _prefs.setString(_kTheme, 'system'); } final normalized = _normalizeLanguageTag(state.language); if (normalized != state.language) { state = state.copyWith(language: normalized); - prefs.setString(_kLanguage, normalized); + _prefs.setString(_kLanguage, normalized); } // Ensure DPI is one of allowed values const allowed = [96.0, 144.0, 200.0, 300.0]; if (!allowed.contains(state.exportDpi)) { state = state.copyWith(exportDpi: 144.0); - prefs.setDouble(_kExportDpi, 144.0); + _prefs.setDouble(_kExportDpi, 144.0); } // Ensure theme color is a valid hex or known name; normalize to hex final parsed = _tryParseColor(state.theme_color); @@ -186,12 +229,12 @@ class PreferencesStateNotifier extends StateNotifier { final fallback = Colors.blue; final hex = _toHex(fallback); state = state.copyWith(theme_color: hex); - prefs.setString(_kThemeColor, hex); + _prefs.setString(_kThemeColor, hex); } else { final hex = _toHex(parsed); if (state.theme_color != hex) { state = state.copyWith(theme_color: hex); - prefs.setString(_kThemeColor, hex); + _prefs.setString(_kThemeColor, hex); } } } @@ -200,13 +243,15 @@ class PreferencesStateNotifier extends StateNotifier { final valid = {'light', 'dark', 'system'}; if (!valid.contains(theme)) return; state = state.copyWith(theme: theme); - await prefs.setString(_kTheme, theme); + await _ensureReady(); + await _prefs.setString(_kTheme, theme); } Future setLanguage(String language) async { final normalized = _normalizeLanguageTag(language); state = state.copyWith(language: normalized); - await prefs.setString(_kLanguage, normalized); + await _ensureReady(); + await _prefs.setString(_kLanguage, normalized); } Future setThemeColor(String themeColor) async { @@ -214,7 +259,8 @@ class PreferencesStateNotifier extends StateNotifier { final c = _tryParseColor(themeColor) ?? Colors.blue; final hex = _toHex(c); state = state.copyWith(theme_color: hex); - await prefs.setString(_kThemeColor, hex); + await _ensureReady(); + await _prefs.setString(_kThemeColor, hex); } Future resetToDefaults() async { @@ -227,85 +273,27 @@ class PreferencesStateNotifier extends StateNotifier { exportDpi: 144.0, theme_color: '#FF2196F3', ); - await prefs.setString(_kTheme, 'system'); - await prefs.setString(_kLanguage, normalized); - await prefs.setString(_kPageView, 'continuous'); - await prefs.setDouble(_kExportDpi, 144.0); - await prefs.setString(_kThemeColor, '#FF2196F3'); + await _ensureReady(); + await _prefs.setString(_kTheme, 'system'); + await _prefs.setString(_kLanguage, normalized); + await _prefs.setString(_kPageView, 'continuous'); + await _prefs.setDouble(_kExportDpi, 144.0); + await _prefs.setString(_kThemeColor, '#FF2196F3'); } Future setExportDpi(double dpi) async { const allowed = [96.0, 144.0, 200.0, 300.0]; if (!allowed.contains(dpi)) return; state = state.copyWith(exportDpi: dpi); - await prefs.setDouble(_kExportDpi, dpi); + await _ensureReady(); + await _prefs.setDouble(_kExportDpi, dpi); } } -final sharedPreferencesProvider = FutureProvider(( - ref, -) async { - final p = await SharedPreferences.getInstance(); - return p; -}); - final preferencesRepositoryProvider = StateNotifierProvider((ref) { - // In tests, you can override sharedPreferencesProvider - final prefs = ref - .watch(sharedPreferencesProvider) - .maybeWhen( - data: (p) => p, - orElse: () => throw StateError('SharedPreferences not ready'), - ); - return PreferencesStateNotifier(prefs); + // Construct with lazy SharedPreferences initialization. + return PreferencesStateNotifier(); }); // pageViewModeProvider removed; the app always runs in continuous mode. - -/// Derive the active ThemeMode based on preference and platform brightness -final themeModeProvider = Provider((ref) { - final prefs = ref.watch(preferencesRepositoryProvider); - switch (prefs.theme) { - case 'light': - return ThemeMode.light; - case 'dark': - return ThemeMode.dark; - case 'system': - default: - return ThemeMode.system; - } -}); - -/// Maps the selected theme color name to an actual Color for theming. -final themeSeedColorProvider = Provider((ref) { - final prefs = ref.watch(preferencesRepositoryProvider); - final c = PreferencesStateNotifier._tryParseColor(prefs.theme_color); - return c ?? Colors.blue; -}); - -final localeProvider = Provider((ref) { - final prefs = ref.watch(preferencesRepositoryProvider); - final supported = _supportedTags(); - // Return explicit Locale for supported ones; if not supported, null to follow device - if (supported.contains(prefs.language)) { - return _parseLanguageTag(prefs.language); - } - return null; -}); - -/// Provides a map of BCP-47 tag -> autonym (self name), independent of UI locale. -final languageAutonymsProvider = FutureProvider>(( - ref, -) async { - final tags = _supportedTags().toList()..sort(); - final delegate = LocaleNamesLocalizationsDelegate(); - final Map result = {}; - for (final tag in tags) { - final locale = _parseLanguageTag(tag); - final names = await delegate.load(locale); - final name = names.nameOf(tag) ?? tag; - result[tag] = name; - } - return result; -}); diff --git a/lib/ui/features/preferences/widgets/settings_screen.dart b/lib/ui/features/preferences/widgets/settings_screen.dart index e71c4d9..048c474 100644 --- a/lib/ui/features/preferences/widgets/settings_screen.dart +++ b/lib/ui/features/preferences/widgets/settings_screen.dart @@ -62,77 +62,45 @@ class _SettingsDialogState extends ConsumerState { SizedBox(width: 140, child: Text('${l.language}:')), const SizedBox(width: 8), Expanded( - child: ref - .watch(languageAutonymsProvider) - .when( - loading: - () => const SizedBox( - height: 48, - child: Center( - child: CircularProgressIndicator(), - ), - ), - error: (_, __) { - final tags = - AppLocalizations.supportedLocales - .map((loc) => toLanguageTag(loc)) - .toList() - ..sort(); - return DropdownButton( - key: const Key('ddl_language'), - isExpanded: true, - value: _language, - items: - tags - .map( - (tag) => DropdownMenuItem( - value: tag, - child: Text(tag), - ), - ) - .toList(), - onChanged: (v) async { - if (v == null) return; - setState(() => _language = v); - await ref - .read( - preferencesRepositoryProvider.notifier, - ) - .setLanguage(v); - }, - ); + child: FutureBuilder>( + future: languageAutonyms(), + builder: (context, snapshot) { + if (snapshot.connectionState == + ConnectionState.waiting) { + return const SizedBox( + height: 48, + child: Center(child: CircularProgressIndicator()), + ); + } + final names = snapshot.data; + final tags = + AppLocalizations.supportedLocales + .map((loc) => toLanguageTag(loc)) + .toList() + ..sort(); + return DropdownButton( + key: const Key('ddl_language'), + isExpanded: true, + value: _language, + items: + tags + .map( + (tag) => DropdownMenuItem( + value: tag, + child: Text(names?[tag] ?? tag), + ), + ) + .toList(), + onChanged: (v) async { + if (v == null) return; + setState(() => _language = v); + await ref + .read(preferencesRepositoryProvider.notifier) + .setLanguage(v); }, - data: (names) { - final tags = - AppLocalizations.supportedLocales - .map((loc) => toLanguageTag(loc)) - .toList() - ..sort(); - return DropdownButton( - key: const Key('ddl_language'), - isExpanded: true, - value: _language, - items: - tags - .map( - (tag) => DropdownMenuItem( - value: tag, - child: Text(names[tag] ?? tag), - ), - ) - .toList(), - onChanged: (v) async { - if (v == null) return; - setState(() => _language = v); - await ref - .read( - preferencesRepositoryProvider.notifier, - ) - .setLanguage(v); - }, - ); - }, - ), + ); + }, + ), ), ], ), @@ -256,7 +224,7 @@ class _ThemeColorCircle extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final seed = ref.watch(themeSeedColorProvider); + final seed = themeSeedFromPrefs(ref.watch(preferencesRepositoryProvider)); return InkWell( key: const Key('btn_theme_color_picker'), onTap: () async { diff --git a/test/widget/export_flow_test.dart b/test/widget/export_flow_test.dart index 1e4bed0..da67c2a 100644 --- a/test/widget/export_flow_test.dart +++ b/test/widget/export_flow_test.dart @@ -52,7 +52,6 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ - sharedPreferencesProvider.overrideWith((_) async => prefs), preferencesRepositoryProvider.overrideWith( (ref) => PreferencesStateNotifier(prefs), ),