Refactor: Introduce LocaleController and ScaleController, unify text and language settings, and simplify AppSettingsView structure.

This commit is contained in:
2025-09-26 22:55:18 +02:00
parent d5f85c2f41
commit 3e04b9cbe3
5 changed files with 156 additions and 153 deletions

View File

@@ -2,8 +2,6 @@ import 'package:flutter/material.dart';
import 'package:fluttery/fluttery.dart'; import 'package:fluttery/fluttery.dart';
import 'package:fluttery/preferences.dart'; import 'package:fluttery/preferences.dart';
/// Controls the current app language (system, de, en, ...).
/// Loads the locale from Preferences or falls back to system default.
class LocaleController extends ChangeNotifier { class LocaleController extends ChangeNotifier {
final Preferences _prefs; final Preferences _prefs;
@@ -11,32 +9,36 @@ class LocaleController extends ChangeNotifier {
static const _key = 'language'; static const _key = 'language';
String _current = 'system'; LanguagePref _current = LanguagePref.en; // Default = Englisch
Future<void> init() async { Future<void> init() async {
final saved = await _prefs.getString(_key); final saved = await _prefs.getString(_key);
_current = saved ?? 'system'; _current = switch (saved) {
'de' => LanguagePref.de,
'system' => LanguagePref.system,
'en' || _ => LanguagePref.en, // default fallback = en
};
notifyListeners(); notifyListeners();
} }
void setLanguage(String lang) { void setLanguage(LanguagePref pref) {
_current = lang; if (_current == pref) return;
_current = pref;
notifyListeners(); notifyListeners();
_prefs.setString(_key, lang); _prefs.setString(_key, pref.name); // fire-and-forget
} }
/// Returns the actual Locale or null = system. LanguagePref get language => _current;
Locale? get locale {
switch (_current) {
case 'de':
return const Locale('de');
case 'en':
return const Locale('en');
case 'system':
default:
return null;
}
}
String get language => _current; Locale? get locale => _current.locale;
}
enum LanguagePref {
system(null), // folgt System
de(Locale('de')), // Deutsch
en(Locale('en')); // Englisch (default)
final Locale? locale;
const LanguagePref(this.locale);
} }

View File

@@ -2,8 +2,6 @@ import 'package:flutter/material.dart';
import 'package:fluttery/fluttery.dart'; import 'package:fluttery/fluttery.dart';
import 'package:fluttery/preferences.dart'; import 'package:fluttery/preferences.dart';
/// Controls the current text scale (system, small, medium, large).
/// Loads the value from Preferences or falls back to system default.
class ScaleController extends ChangeNotifier { class ScaleController extends ChangeNotifier {
final Preferences _prefs; final Preferences _prefs;
@@ -11,37 +9,40 @@ class ScaleController extends ChangeNotifier {
static const _key = 'textScale'; static const _key = 'textScale';
/// supported sizes TextScalePref _current = TextScalePref.system;
static const Map<String, double> _factors = {
'system': 1.0,
'small': 0.9,
'medium': 1.1,
'large': 1.25,
};
String _current = 'system'; /// Faktor direkt vom Enum
double get factor => _current.factor;
/// Loads text scale from Preferences.
Future<void> init() async { Future<void> init() async {
final saved = await _prefs.getString(_key); final saved = await _prefs.getString(_key);
_current = saved ?? 'system'; _current = switch (saved) {
if (!_factors.containsKey(_current)) { 'small' => TextScalePref.small,
_current = 'system'; 'medium' => TextScalePref.medium,
} 'large' => TextScalePref.large,
_ => TextScalePref.system,
};
notifyListeners(); notifyListeners();
} }
/// Set text scale and persist. /// Set text scale and persist.
void setScale(String scale) { void setScale(TextScalePref pref) {
if (!_factors.containsKey(scale)) return; if (_current == pref) return;
_current = scale; _current = pref;
notifyListeners(); notifyListeners();
_prefs.setString(_key, scale); _prefs.setString(_key, pref.name); // fire-and-forget
} }
/// get current factor (e.g. for MediaQuery.textScaler) TextScalePref get scale => _current;
double get factor => _factors[_current]!; }
/// get current string (system/small/medium/large) enum TextScalePref {
String get scale => _current; system(1.0),
small(0.9),
medium(1.1),
large(1.4);
final double factor;
const TextScalePref(this.factor);
} }

View File

@@ -1,5 +1,7 @@
import 'package:app/core/app/router.dart'; import 'package:app/core/app/router.dart';
import 'package:app/core/app/startup/domain/initialize_app.dart'; import 'package:app/core/app/startup/domain/initialize_app.dart';
import 'package:app/core/ui/controller/locale_controller.dart';
import 'package:app/core/ui/controller/scale_controller.dart';
import 'package:app/core/ui/controller/theme.dart'; import 'package:app/core/ui/controller/theme.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fluttery/fluttery.dart'; import 'package:fluttery/fluttery.dart';
@@ -25,37 +27,48 @@ Future<void> main() async {
final themeController = ThemeController(); final themeController = ThemeController();
await themeController.init(); await themeController.init();
final scaleController = ScaleController();
final localeController = LocaleController();
runApp( runApp(
MultiProvider( MultiProvider(
providers: [ChangeNotifierProvider(create: (context) => themeController)], providers: [
child: FinlogApp( ChangeNotifierProvider(create: (context) => themeController),
router: buildAppRouter(startRoute), ChangeNotifierProvider(create: (context) => scaleController),
themeController: themeController, ChangeNotifierProvider(create: (context) => localeController),
), ],
child: FinlogApp(router: buildAppRouter(startRoute)),
), ),
); );
} }
class FinlogApp extends StatelessWidget { class FinlogApp extends StatelessWidget {
final GoRouter router; final GoRouter router;
final ThemeController themeController;
const FinlogApp({ const FinlogApp({super.key, required this.router});
super.key,
required this.router,
required this.themeController,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = context.watch<ThemeController>();
final textScale = context.watch<ScaleController>();
final localeCtrl = context.watch<LocaleController>();
return AnimatedBuilder( return AnimatedBuilder(
animation: themeController, animation: theme,
builder: (context, _) => MaterialApp.router( builder: (context, _) => MaterialApp.router(
title: 'Finlog', title: 'Finlog',
locale: localeCtrl.locale,
supportedLocales: const [Locale('de'), Locale('en')],
theme: ThemeData.light(), theme: ThemeData.light(),
darkTheme: ThemeData.dark(), darkTheme: ThemeData.dark(),
themeMode: themeController.themeMode, themeMode: theme.themeMode,
routerConfig: router, routerConfig: router,
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: TextScaler.linear(textScale.factor)),
child: child ?? Container(),
),
), ),
); );
} }

View File

@@ -1,3 +1,5 @@
import 'package:app/core/ui/controller/locale_controller.dart';
import 'package:app/core/ui/controller/scale_controller.dart';
import 'package:app/core/ui/controller/theme.dart'; import 'package:app/core/ui/controller/theme.dart';
import 'package:app/modules/settings/modules/app/model/app_settings_view_model.dart'; import 'package:app/modules/settings/modules/app/model/app_settings_view_model.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -8,9 +10,16 @@ class AppSettingsView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final themeModel = context.watch<ThemeController>(); final themeModel = context.read<ThemeController>();
final scaleModel = context.read<ScaleController>();
final localeModel = context.read<LocaleController>();
return ChangeNotifierProvider( return ChangeNotifierProvider(
create: (_) => AppSettingsViewModel(themeModel: themeModel)..load(), create: (_) => AppSettingsViewModel(
themeModel: themeModel,
scaleModel: scaleModel,
localeModel: localeModel,
)..load(),
child: const _AppSettingsContent(), child: const _AppSettingsContent(),
); );
} }
@@ -36,7 +45,7 @@ class _AppSettingsContent extends StatelessWidget {
children: [ children: [
_SystemBackgroundSection(), _SystemBackgroundSection(),
const SizedBox(height: 16), const SizedBox(height: 16),
_TextSizeSection(), _TextScaleSection(),
const SizedBox(height: 16), const SizedBox(height: 16),
_LanguageSection(), _LanguageSection(),
], ],
@@ -73,13 +82,13 @@ class _SystemBackgroundSection extends StatelessWidget {
onPressed: (i) { onPressed: (i) {
switch (i) { switch (i) {
case 0: case 0:
vm.setBackgroundPref(ThemeMode.system); vm.setThemeMode(ThemeMode.system);
break; break;
case 1: case 1:
vm.setBackgroundPref(ThemeMode.dark); vm.setThemeMode(ThemeMode.dark);
break; break;
case 2: case 2:
vm.setBackgroundPref(ThemeMode.light); vm.setThemeMode(ThemeMode.light);
break; break;
} }
}, },
@@ -120,36 +129,59 @@ class _SegItem extends StatelessWidget {
} }
} }
class _TextSizeSection extends StatelessWidget { class _TextScaleSection extends StatelessWidget {
const _TextScaleSection();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final vm = context.watch<AppSettingsViewModel>(); final vm = context.watch<AppSettingsViewModel>();
final selected = vm.textSize; final selected = vm.textScale;
Widget radio(AppTextSize size, String label, String sub) {
return RadioListTile<AppTextSize>(
value: size,
groupValue: selected,
onChanged: (v) => vm.setTextSize(v ?? AppTextSize.normal),
title: Text(label),
subtitle: Text(sub),
);
}
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Textgröße', style: Theme.of(context).textTheme.titleMedium), const Text('Textgröße', style: TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 8), const SizedBox(height: 8),
radio(AppTextSize.small, 'Klein', 'Kompakte Darstellung'), ToggleButtons(
radio(AppTextSize.normal, 'Standard', 'Empfohlene Einstellung'), isSelected: [
radio(AppTextSize.large, 'Groß', 'Größere, besser lesbare Texte'), selected == TextScalePref.system,
selected == TextScalePref.small,
selected == TextScalePref.medium,
selected == TextScalePref.large,
],
onPressed: (i) {
switch (i) {
case 0:
vm.setTextScale(TextScalePref.system);
break;
case 1:
vm.setTextScale(TextScalePref.small);
break;
case 2:
vm.setTextScale(TextScalePref.medium);
break;
case 3:
vm.setTextScale(TextScalePref.large);
break;
}
},
borderRadius: BorderRadius.circular(24),
constraints: const BoxConstraints(minHeight: 44, minWidth: 120),
children: const [
_SegItem(icon: Icons.phone_android, label: 'System'),
_SegItem(icon: Icons.text_fields, label: 'Klein'),
_SegItem(icon: Icons.text_fields, label: 'Mittel'),
_SegItem(icon: Icons.text_fields, label: 'Groß'),
],
),
], ],
); );
} }
} }
class _LanguageSection extends StatelessWidget { class _LanguageSection extends StatelessWidget {
const _LanguageSection();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final vm = context.watch<AppSettingsViewModel>(); final vm = context.watch<AppSettingsViewModel>();
@@ -160,18 +192,24 @@ class _LanguageSection extends StatelessWidget {
children: [ children: [
Text('Sprache', style: Theme.of(context).textTheme.titleMedium), Text('Sprache', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8), const SizedBox(height: 8),
DropdownButtonFormField<AppLanguage>(
value: vm.language, DropdownButtonFormField<LanguagePref>(
onChanged: (v) => vm.setLanguage(v ?? AppLanguage.de), initialValue: vm.language,
onChanged: (v) => vm.setLanguage(v ?? LanguagePref.en),
items: const [ items: const [
DropdownMenuItem(value: AppLanguage.de, child: Text('Deutsch')), DropdownMenuItem(
DropdownMenuItem(value: AppLanguage.en, child: Text('Englisch')), value: LanguagePref.system,
child: Text('Systemstandard'),
),
DropdownMenuItem(value: LanguagePref.de, child: Text('Deutsch')),
DropdownMenuItem(value: LanguagePref.en, child: Text('Englisch')),
], ],
decoration: InputDecoration( decoration: InputDecoration(
isDense: true, isDense: true,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'Änderungen wirken sich nach dem Speichern aus.', 'Änderungen wirken sich nach dem Speichern aus.',

View File

@@ -1,24 +1,23 @@
import 'package:app/core/ui/controller/locale_controller.dart';
import 'package:app/core/ui/controller/scale_controller.dart';
import 'package:app/core/ui/controller/theme.dart'; import 'package:app/core/ui/controller/theme.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Domain-ish enums for clarity and easy (de)serialization.
enum AppTextSize { small, normal, large }
enum AppLanguage { de, en }
class AppSettingsViewModel extends ChangeNotifier { class AppSettingsViewModel extends ChangeNotifier {
/// Use `null` to represent "system default background".
Color? _backgroundColorSystem;
AppTextSize _textSize = AppTextSize.normal;
AppLanguage _language = AppLanguage.de;
bool _isLoading = false; bool _isLoading = false;
bool _isSaving = false; bool _isSaving = false;
final ThemeController _theme; final ThemeController _theme;
final ScaleController _scale;
final LocaleController _locale;
AppSettingsViewModel({required ThemeController themeModel}) AppSettingsViewModel({
: _theme = themeModel; required ThemeController themeModel,
required ScaleController scaleModel,
required LocaleController localeModel,
}) : _theme = themeModel,
_scale = scaleModel,
_locale = localeModel;
/// Pretend to load from backend. Plug your repository here later. /// Pretend to load from backend. Plug your repository here later.
Future<void> load() async { Future<void> load() async {
@@ -28,80 +27,30 @@ class AppSettingsViewModel extends ChangeNotifier {
// TODO: Replace with real backend call. // TODO: Replace with real backend call.
await Future<void>.delayed(const Duration(milliseconds: 200)); await Future<void>.delayed(const Duration(milliseconds: 200));
// Example defaults (could come from server response).
_backgroundColorSystem = null; // null => Systemstandard
_textSize = AppTextSize.normal;
_language = AppLanguage.de;
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
} }
void setBackgroundPref(ThemeMode mode) { void setThemeMode(ThemeMode mode) {
_theme.setTheme(mode); _theme.setTheme(mode);
notifyListeners(); notifyListeners();
} }
/// Save to backend (stub). void setTextScale(TextScalePref pref) {
Future<void> save() async { _scale.setScale(pref);
if (_isSaving) return;
_isSaving = true;
notifyListeners();
// TODO: Replace with real backend update.
await Future<void>.delayed(const Duration(milliseconds: 300));
_isSaving = false;
notifyListeners(); notifyListeners();
} }
void setSystemBackgroundColor(Color? colorOrNullForSystem) { void setLanguage(LanguagePref lang) {
_backgroundColorSystem = colorOrNullForSystem; _locale.setLanguage(lang);
notifyListeners();
}
void setTextSize(AppTextSize size) {
_textSize = size;
notifyListeners();
}
void setLanguage(AppLanguage lang) {
_language = lang;
notifyListeners();
}
Map<String, dynamic> toJson() => {
'backgroundColorSystem': _backgroundColorSystem?.value,
'textSize': _textSize.name,
'language': _language.name,
};
void fromJson(Map<String, dynamic> json) {
final int? colorValue = json['backgroundColorSystem'] as int?;
_backgroundColorSystem = colorValue != null ? Color(colorValue) : null;
final ts = json['textSize'] as String?;
_textSize = AppTextSize.values.firstWhere(
(e) => e.name == ts,
orElse: () => AppTextSize.normal,
);
final lng = json['language'] as String?;
_language = AppLanguage.values.firstWhere(
(e) => e.name == lng,
orElse: () => AppLanguage.de,
);
notifyListeners(); notifyListeners();
} }
ThemeMode get themeMode => _theme.themeMode; ThemeMode get themeMode => _theme.themeMode;
Color? get backgroundColorSystem => _backgroundColorSystem; LanguagePref? get language => _locale.language;
AppTextSize get textSize => _textSize; TextScalePref get textScale => _scale.scale;
AppLanguage get language => _language;
bool get isLoading => _isLoading; bool get isLoading => _isLoading;