refactor: preferences repository to contain only 1 provicder
This commit is contained in:
parent
7032f22327
commit
82d0c40e6a
47
lib/app.dart
47
lib/app.dart
|
|
@ -14,33 +14,24 @@ 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()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
data: (_) {
|
|
||||||
final themeMode = ref.watch(themeModeProvider);
|
|
||||||
final seed = ref.watch(themeSeedColorProvider);
|
|
||||||
final appLocale = ref.watch(localeProvider);
|
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle,
|
onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle,
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
|
|
@ -90,8 +81,6 @@ class MyApp extends StatelessWidget {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -62,47 +62,17 @@ 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) {
|
||||||
|
return const SizedBox(
|
||||||
height: 48,
|
height: 48,
|
||||||
child: Center(
|
child: Center(child: CircularProgressIndicator()),
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
error: (_, __) {
|
|
||||||
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(tag),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
onChanged: (v) async {
|
|
||||||
if (v == null) return;
|
|
||||||
setState(() => _language = v);
|
|
||||||
await ref
|
|
||||||
.read(
|
|
||||||
preferencesRepositoryProvider.notifier,
|
|
||||||
)
|
|
||||||
.setLanguage(v);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
data: (names) {
|
final names = snapshot.data;
|
||||||
final tags =
|
final tags =
|
||||||
AppLocalizations.supportedLocales
|
AppLocalizations.supportedLocales
|
||||||
.map((loc) => toLanguageTag(loc))
|
.map((loc) => toLanguageTag(loc))
|
||||||
|
|
@ -117,7 +87,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
.map(
|
.map(
|
||||||
(tag) => DropdownMenuItem<String>(
|
(tag) => DropdownMenuItem<String>(
|
||||||
value: tag,
|
value: tag,
|
||||||
child: Text(names[tag] ?? tag),
|
child: Text(names?[tag] ?? tag),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
|
@ -125,9 +95,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
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);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue