refactor: preferences repository to contain only 1 provicder

This commit is contained in:
insleker 2025-09-20 19:31:27 +08:00
parent 7032f22327
commit 82d0c40e6a
4 changed files with 173 additions and 229 deletions

View File

@ -14,80 +14,69 @@ class MyApp extends StatelessWidget {
return ProviderScope( return ProviderScope(
child: Consumer( child: Consumer(
builder: (context, ref, _) { builder: (context, ref, _) {
// Ensure SharedPreferences loaded before building MaterialApp final prefs = ref.watch(preferencesRepositoryProvider);
final sp = ref.watch(sharedPreferencesProvider); final seed = themeSeedFromPrefs(prefs);
return sp.when( final appLocale =
loading: () => const SizedBox.shrink(), supportedLanguageTags().contains(prefs.language)
error: ? parseLanguageTag(prefs.language)
(e, st) => MaterialApp( : null;
onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle, final themeMode = () {
supportedLocales: AppLocalizations.supportedLocales, switch (prefs.theme) {
localizationsDelegates: case 'light':
AppLocalizations.localizationsDelegates, return ThemeMode.light;
home: Builder( case 'dark':
builder: return ThemeMode.dark;
(ctx) => Scaffold( case 'system':
body: Center( default:
child: Text( return ThemeMode.system;
AppLocalizations.of( }
ctx, }();
).errorWithMessage(e.toString()),
), 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<bool>(
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<bool>(
context:
router
.routerDelegate
.navigatorKey
.currentContext!,
builder: (_) => const SettingsDialog(),
),
),
],
), ),
body: child, ],
); ),
}, body: child,
); );
}, },
); );

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@ -26,6 +27,27 @@ Set<String> _supportedTags() {
return AppLocalizations.supportedLocales.map((l) => toLanguageTag(l)).toSet(); return AppLocalizations.supportedLocales.map((l) => toLanguageTag(l)).toSet();
} }
// Public helpers for other layers to consume without extra providers
Set<String> supportedLanguageTags() => _supportedTags();
Locale parseLanguageTag(String tag) => _parseLanguageTag(tag);
Color themeSeedFromPrefs(PreferencesState prefs) {
final c = PreferencesStateNotifier._tryParseColor(prefs.theme_color);
return c ?? Colors.blue;
}
Future<Map<String, String>> languageAutonyms() async {
final tags = _supportedTags().toList()..sort();
final delegate = LocaleNamesLocalizationsDelegate();
final Map<String, String> 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 // Keys
const _kTheme = 'theme'; // 'light'|'dark'|'system' const _kTheme = 'theme'; // 'light'|'dark'|'system'
// Theme color persisted as hex ARGB string (e.g., '#FF2196F3'). // Theme color persisted as hex ARGB string (e.g., '#FF2196F3').
@ -68,7 +90,8 @@ String _normalizeLanguageTag(String tag) {
} }
class PreferencesStateNotifier extends StateNotifier<PreferencesState> { class PreferencesStateNotifier extends StateNotifier<PreferencesState> {
final SharedPreferences prefs; late final SharedPreferences _prefs;
final Completer<void> _ready = Completer<void>();
static Color? _tryParseColor(String? s) { static Color? _tryParseColor(String? s) {
if (s == null || s.isEmpty) return null; if (s == null || s.isEmpty) return null;
final v = s.trim(); final v = s.trim();
@ -139,21 +162,41 @@ class PreferencesStateNotifier extends StateNotifier<PreferencesState> {
return '#$a$r$g$b'; return '#$a$r$g$b';
} }
PreferencesStateNotifier(this.prefs) PreferencesStateNotifier([SharedPreferences? prefs])
: super( : super(
PreferencesState( PreferencesState(
theme: prefs.getString(_kTheme) ?? 'system', theme: 'system',
language: _normalizeLanguageTag( language: _normalizeLanguageTag(
prefs.getString(_kLanguage) ?? WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag(),
WidgetsBinding.instance.platformDispatcher.locale
.toLanguageTag(),
), ),
exportDpi: _readDpi(prefs), exportDpi: 144.0,
theme_color: prefs.getString(_kThemeColor) ?? '#FF2196F3', // blue theme_color: '#FF2196F3', // blue
), ),
) { ) {
// normalize language to supported/fallback _init(prefs);
}
Future<void> _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(); _ensureValid();
if (!_ready.isCompleted) _ready.complete();
}
Future<void> _ensureReady() async {
if (!_ready.isCompleted) {
await _ready.future;
}
} }
static double _readDpi(SharedPreferences prefs) { static double _readDpi(SharedPreferences prefs) {
@ -167,18 +210,18 @@ class PreferencesStateNotifier extends StateNotifier<PreferencesState> {
final themeValid = {'light', 'dark', 'system'}; final themeValid = {'light', 'dark', 'system'};
if (!themeValid.contains(state.theme)) { if (!themeValid.contains(state.theme)) {
state = state.copyWith(theme: 'system'); state = state.copyWith(theme: 'system');
prefs.setString(_kTheme, 'system'); _prefs.setString(_kTheme, 'system');
} }
final normalized = _normalizeLanguageTag(state.language); final normalized = _normalizeLanguageTag(state.language);
if (normalized != state.language) { if (normalized != state.language) {
state = state.copyWith(language: normalized); state = state.copyWith(language: normalized);
prefs.setString(_kLanguage, normalized); _prefs.setString(_kLanguage, normalized);
} }
// Ensure DPI is one of allowed values // Ensure DPI is one of allowed values
const allowed = [96.0, 144.0, 200.0, 300.0]; const allowed = [96.0, 144.0, 200.0, 300.0];
if (!allowed.contains(state.exportDpi)) { if (!allowed.contains(state.exportDpi)) {
state = state.copyWith(exportDpi: 144.0); 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 // Ensure theme color is a valid hex or known name; normalize to hex
final parsed = _tryParseColor(state.theme_color); final parsed = _tryParseColor(state.theme_color);
@ -186,12 +229,12 @@ class PreferencesStateNotifier extends StateNotifier<PreferencesState> {
final fallback = Colors.blue; final fallback = Colors.blue;
final hex = _toHex(fallback); final hex = _toHex(fallback);
state = state.copyWith(theme_color: hex); state = state.copyWith(theme_color: hex);
prefs.setString(_kThemeColor, hex); _prefs.setString(_kThemeColor, hex);
} else { } else {
final hex = _toHex(parsed); final hex = _toHex(parsed);
if (state.theme_color != hex) { if (state.theme_color != hex) {
state = state.copyWith(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<PreferencesState> {
final valid = {'light', 'dark', 'system'}; final valid = {'light', 'dark', 'system'};
if (!valid.contains(theme)) return; if (!valid.contains(theme)) return;
state = state.copyWith(theme: theme); state = state.copyWith(theme: theme);
await prefs.setString(_kTheme, theme); await _ensureReady();
await _prefs.setString(_kTheme, theme);
} }
Future<void> setLanguage(String language) async { Future<void> setLanguage(String language) async {
final normalized = _normalizeLanguageTag(language); final normalized = _normalizeLanguageTag(language);
state = state.copyWith(language: normalized); state = state.copyWith(language: normalized);
await prefs.setString(_kLanguage, normalized); await _ensureReady();
await _prefs.setString(_kLanguage, normalized);
} }
Future<void> setThemeColor(String themeColor) async { Future<void> setThemeColor(String themeColor) async {
@ -214,7 +259,8 @@ class PreferencesStateNotifier extends StateNotifier<PreferencesState> {
final c = _tryParseColor(themeColor) ?? Colors.blue; final c = _tryParseColor(themeColor) ?? Colors.blue;
final hex = _toHex(c); final hex = _toHex(c);
state = state.copyWith(theme_color: hex); state = state.copyWith(theme_color: hex);
await prefs.setString(_kThemeColor, hex); await _ensureReady();
await _prefs.setString(_kThemeColor, hex);
} }
Future<void> resetToDefaults() async { Future<void> resetToDefaults() async {
@ -227,85 +273,27 @@ class PreferencesStateNotifier extends StateNotifier<PreferencesState> {
exportDpi: 144.0, exportDpi: 144.0,
theme_color: '#FF2196F3', theme_color: '#FF2196F3',
); );
await prefs.setString(_kTheme, 'system'); await _ensureReady();
await prefs.setString(_kLanguage, normalized); await _prefs.setString(_kTheme, 'system');
await prefs.setString(_kPageView, 'continuous'); await _prefs.setString(_kLanguage, normalized);
await prefs.setDouble(_kExportDpi, 144.0); await _prefs.setString(_kPageView, 'continuous');
await prefs.setString(_kThemeColor, '#FF2196F3'); await _prefs.setDouble(_kExportDpi, 144.0);
await _prefs.setString(_kThemeColor, '#FF2196F3');
} }
Future<void> setExportDpi(double dpi) async { Future<void> setExportDpi(double dpi) async {
const allowed = [96.0, 144.0, 200.0, 300.0]; const allowed = [96.0, 144.0, 200.0, 300.0];
if (!allowed.contains(dpi)) return; if (!allowed.contains(dpi)) return;
state = state.copyWith(exportDpi: dpi); state = state.copyWith(exportDpi: dpi);
await prefs.setDouble(_kExportDpi, dpi); await _ensureReady();
await _prefs.setDouble(_kExportDpi, dpi);
} }
} }
final sharedPreferencesProvider = FutureProvider<SharedPreferences>((
ref,
) async {
final p = await SharedPreferences.getInstance();
return p;
});
final preferencesRepositoryProvider = final preferencesRepositoryProvider =
StateNotifierProvider<PreferencesStateNotifier, PreferencesState>((ref) { StateNotifierProvider<PreferencesStateNotifier, PreferencesState>((ref) {
// In tests, you can override sharedPreferencesProvider // Construct with lazy SharedPreferences initialization.
final prefs = ref return PreferencesStateNotifier();
.watch(sharedPreferencesProvider)
.maybeWhen(
data: (p) => p,
orElse: () => throw StateError('SharedPreferences not ready'),
);
return PreferencesStateNotifier(prefs);
}); });
// pageViewModeProvider removed; the app always runs in continuous mode. // pageViewModeProvider removed; the app always runs in continuous mode.
/// Derive the active ThemeMode based on preference and platform brightness
final themeModeProvider = Provider<ThemeMode>((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<Color>((ref) {
final prefs = ref.watch(preferencesRepositoryProvider);
final c = PreferencesStateNotifier._tryParseColor(prefs.theme_color);
return c ?? Colors.blue;
});
final localeProvider = Provider<Locale?>((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<Map<String, String>>((
ref,
) async {
final tags = _supportedTags().toList()..sort();
final delegate = LocaleNamesLocalizationsDelegate();
final Map<String, String> 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;
});

View File

@ -62,77 +62,45 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
SizedBox(width: 140, child: Text('${l.language}:')), SizedBox(width: 140, child: Text('${l.language}:')),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: ref child: FutureBuilder<Map<String, String>>(
.watch(languageAutonymsProvider) future: languageAutonyms(),
.when( builder: (context, snapshot) {
loading: if (snapshot.connectionState ==
() => const SizedBox( ConnectionState.waiting) {
height: 48, return const SizedBox(
child: Center( height: 48,
child: CircularProgressIndicator(), child: Center(child: CircularProgressIndicator()),
), );
), }
error: (_, __) { final names = snapshot.data;
final tags = final tags =
AppLocalizations.supportedLocales AppLocalizations.supportedLocales
.map((loc) => toLanguageTag(loc)) .map((loc) => toLanguageTag(loc))
.toList() .toList()
..sort(); ..sort();
return DropdownButton<String>( return DropdownButton<String>(
key: const Key('ddl_language'), key: const Key('ddl_language'),
isExpanded: true, isExpanded: true,
value: _language, value: _language,
items: items:
tags tags
.map( .map(
(tag) => DropdownMenuItem<String>( (tag) => DropdownMenuItem<String>(
value: tag, value: tag,
child: Text(tag), child: Text(names?[tag] ?? tag),
), ),
) )
.toList(), .toList(),
onChanged: (v) async { onChanged: (v) async {
if (v == null) return; if (v == null) return;
setState(() => _language = v); setState(() => _language = v);
await ref await ref
.read( .read(preferencesRepositoryProvider.notifier)
preferencesRepositoryProvider.notifier, .setLanguage(v);
)
.setLanguage(v);
},
);
}, },
data: (names) { );
final tags = },
AppLocalizations.supportedLocales ),
.map((loc) => toLanguageTag(loc))
.toList()
..sort();
return DropdownButton<String>(
key: const Key('ddl_language'),
isExpanded: true,
value: _language,
items:
tags
.map(
(tag) => DropdownMenuItem<String>(
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 @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final seed = ref.watch(themeSeedColorProvider); final seed = themeSeedFromPrefs(ref.watch(preferencesRepositoryProvider));
return InkWell( return InkWell(
key: const Key('btn_theme_color_picker'), key: const Key('btn_theme_color_picker'),
onTap: () async { onTap: () async {

View File

@ -52,7 +52,6 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
ProviderScope( ProviderScope(
overrides: [ overrides: [
sharedPreferencesProvider.overrideWith((_) async => prefs),
preferencesRepositoryProvider.overrideWith( preferencesRepositoryProvider.overrideWith(
(ref) => PreferencesStateNotifier(prefs), (ref) => PreferencesStateNotifier(prefs),
), ),