Feature: Add support for localization, introduce slang for translations, and integrate German and English locale support throughout the app

This commit is contained in:
2025-09-27 11:58:25 +02:00
parent 3e04b9cbe3
commit 0a0e421158
11 changed files with 627 additions and 44 deletions

View File

@@ -0,0 +1,182 @@
/// Generated file. Do not edit.
///
/// Source: assets/i18n
/// To regenerate, run: `dart run slang`
///
/// Locales: 2
/// Strings: 28 (14 per locale)
///
/// Built on 2025-09-27 at 09:55 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';
import 'package:slang/generated.dart';
import 'package:slang_flutter/slang_flutter.dart';
export 'package:slang_flutter/slang_flutter.dart';
import 'translations_de.g.dart' deferred as l_de;
part 'translations_en.g.dart';
/// Supported locales.
///
/// Usage:
/// - LocaleSettings.setLocale(AppLocale.en) // set locale
/// - Locale locale = AppLocale.en.flutterLocale // get flutter locale from enum
/// - if (LocaleSettings.currentLocale == AppLocale.en) // locale check
enum AppLocale with BaseAppLocale<AppLocale, Translations> {
en(languageCode: 'en'),
de(languageCode: 'de');
const AppLocale({
required this.languageCode,
this.scriptCode, // ignore: unused_element, unused_element_parameter
this.countryCode, // ignore: unused_element, unused_element_parameter
});
@override final String languageCode;
@override final String? scriptCode;
@override final String? countryCode;
@override
Future<Translations> build({
Map<String, Node>? overrides,
PluralResolver? cardinalResolver,
PluralResolver? ordinalResolver,
}) async {
switch (this) {
case AppLocale.en:
return TranslationsEn(
overrides: overrides,
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
case AppLocale.de:
await l_de.loadLibrary();
return l_de.TranslationsDe(
overrides: overrides,
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
}
}
@override
Translations buildSync({
Map<String, Node>? overrides,
PluralResolver? cardinalResolver,
PluralResolver? ordinalResolver,
}) {
switch (this) {
case AppLocale.en:
return TranslationsEn(
overrides: overrides,
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
case AppLocale.de:
return l_de.TranslationsDe(
overrides: overrides,
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
}
}
/// Gets current instance managed by [LocaleSettings].
Translations get translations => LocaleSettings.instance.getTranslations(this);
}
/// Method A: Simple
///
/// No rebuild after locale change.
/// Translation happens during initialization of the widget (call of t).
/// Configurable via 'translate_var'.
///
/// Usage:
/// String a = t.someKey.anotherKey;
/// String b = t['someKey.anotherKey']; // Only for edge cases!
Translations get t => LocaleSettings.instance.currentTranslations;
/// Method B: Advanced
///
/// All widgets using this method will trigger a rebuild when locale changes.
/// Use this if you have e.g. a settings page where the user can select the locale during runtime.
///
/// Step 1:
/// wrap your App with
/// TranslationProvider(
/// child: MyApp()
/// );
///
/// Step 2:
/// final t = Translations.of(context); // Get t variable.
/// String a = t.someKey.anotherKey; // Use t variable.
/// String b = t['someKey.anotherKey']; // Only for edge cases!
class TranslationProvider extends BaseTranslationProvider<AppLocale, Translations> {
TranslationProvider({required super.child}) : super(settings: LocaleSettings.instance);
static InheritedLocaleData<AppLocale, Translations> of(BuildContext context) => InheritedLocaleData.of<AppLocale, Translations>(context);
}
/// Method B shorthand via [BuildContext] extension method.
/// Configurable via 'translate_var'.
///
/// Usage (e.g. in a widget's build method):
/// context.t.someKey.anotherKey
extension BuildContextTranslationsExtension on BuildContext {
Translations get t => TranslationProvider.of(this).translations;
}
/// Manages all translation instances and the current locale
class LocaleSettings extends BaseFlutterLocaleSettings<AppLocale, Translations> {
LocaleSettings._() : super(
utils: AppLocaleUtils.instance,
lazy: true,
);
static final instance = LocaleSettings._();
// static aliases (checkout base methods for documentation)
static AppLocale get currentLocale => instance.currentLocale;
static Stream<AppLocale> getLocaleStream() => instance.getLocaleStream();
static Future<AppLocale> setLocale(AppLocale locale, {bool? listenToDeviceLocale = false}) => instance.setLocale(locale, listenToDeviceLocale: listenToDeviceLocale);
static Future<AppLocale> setLocaleRaw(String rawLocale, {bool? listenToDeviceLocale = false}) => instance.setLocaleRaw(rawLocale, listenToDeviceLocale: listenToDeviceLocale);
static Future<AppLocale> useDeviceLocale() => instance.useDeviceLocale();
static Future<void> setPluralResolver({String? language, AppLocale? locale, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver}) => instance.setPluralResolver(
language: language,
locale: locale,
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
// synchronous versions
static AppLocale setLocaleSync(AppLocale locale, {bool? listenToDeviceLocale = false}) => instance.setLocaleSync(locale, listenToDeviceLocale: listenToDeviceLocale);
static AppLocale setLocaleRawSync(String rawLocale, {bool? listenToDeviceLocale = false}) => instance.setLocaleRawSync(rawLocale, listenToDeviceLocale: listenToDeviceLocale);
static AppLocale useDeviceLocaleSync() => instance.useDeviceLocaleSync();
static void setPluralResolverSync({String? language, AppLocale? locale, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver}) => instance.setPluralResolverSync(
language: language,
locale: locale,
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
}
/// Provides utility functions without any side effects.
class AppLocaleUtils extends BaseAppLocaleUtils<AppLocale, Translations> {
AppLocaleUtils._() : super(
baseLocale: AppLocale.en,
locales: AppLocale.values,
);
static final instance = AppLocaleUtils._();
// static aliases (checkout base methods for documentation)
static AppLocale parse(String rawLocale) => instance.parse(rawLocale);
static AppLocale parseLocaleParts({required String languageCode, String? scriptCode, String? countryCode}) => instance.parseLocaleParts(languageCode: languageCode, scriptCode: scriptCode, countryCode: countryCode);
static AppLocale findDeviceLocale() => instance.findDeviceLocale();
static List<Locale> get supportedLocales => instance.supportedLocales;
static List<String> get supportedLocalesRaw => instance.supportedLocalesRaw;
}

View File

@@ -0,0 +1,128 @@
///
/// Generated file. Do not edit.
///
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';
import 'package:slang/generated.dart';
import 'translations.g.dart';
// Path: <root>
class TranslationsDe implements Translations {
/// You can call this constructor and build your own translation instance of this locale.
/// Constructing via the enum [AppLocale.build] is preferred.
TranslationsDe({Map<String, Node>? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata<AppLocale, Translations>? meta})
: assert(overrides == null, 'Set "translation_overrides: true" in order to enable this feature.'),
$meta = meta ?? TranslationMetadata(
locale: AppLocale.de,
overrides: overrides ?? {},
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
) {
$meta.setFlatMapFunction(_flatMapFunction);
}
/// Metadata for the translations of <de>.
@override final TranslationMetadata<AppLocale, Translations> $meta;
/// Access flat map
@override dynamic operator[](String key) => $meta.getTranslation(key);
late final TranslationsDe _root = this; // ignore: unused_field
@override
TranslationsDe $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => TranslationsDe(meta: meta ?? this.$meta);
// Translations
@override String hello({required Object name}) => 'Hallo ${name}';
@override late final _TranslationsLoginDe login = _TranslationsLoginDe._(_root);
@override late final _TranslationsSettingsDe settings = _TranslationsSettingsDe._(_root);
}
// Path: login
class _TranslationsLoginDe implements TranslationsLoginEn {
_TranslationsLoginDe._(this._root);
final TranslationsDe _root; // ignore: unused_field
// Translations
@override String get success => 'Login erfolgreich';
}
// Path: settings
class _TranslationsSettingsDe implements TranslationsSettingsEn {
_TranslationsSettingsDe._(this._root);
final TranslationsDe _root; // ignore: unused_field
// Translations
@override String get title => 'Einstellungen';
@override late final _TranslationsSettingsSectionsDe sections = _TranslationsSettingsSectionsDe._(_root);
@override late final _TranslationsSettingsItemsDe items = _TranslationsSettingsItemsDe._(_root);
@override late final _TranslationsSettingsMessagesDe messages = _TranslationsSettingsMessagesDe._(_root);
}
// Path: settings.sections
class _TranslationsSettingsSectionsDe implements TranslationsSettingsSectionsEn {
_TranslationsSettingsSectionsDe._(this._root);
final TranslationsDe _root; // ignore: unused_field
// Translations
@override String get account => 'Konto & Daten';
@override String get app => 'App';
@override String get help => 'Hilfe & Rechtliches';
}
// Path: settings.items
class _TranslationsSettingsItemsDe implements TranslationsSettingsItemsEn {
_TranslationsSettingsItemsDe._(this._root);
final TranslationsDe _root; // ignore: unused_field
// Translations
@override String get appSettings => 'App-Einstellungen';
@override String get personalData => 'Persönliche Daten';
@override String get accountManagement => 'Kontoverwaltung';
@override String get helpCenter => 'Hilfe';
@override String get feedback => 'Feedback';
@override String get legalPrivacy => 'Rechtliches & Datenschutz';
@override String get logout => 'Abmelden';
}
// Path: settings.messages
class _TranslationsSettingsMessagesDe implements TranslationsSettingsMessagesEn {
_TranslationsSettingsMessagesDe._(this._root);
final TranslationsDe _root; // ignore: unused_field
// Translations
@override String get logoutNotImplemented => 'Logout… (noch nicht implementiert)';
}
/// Flat map(s) containing all translations.
/// Only for edge cases! For simple maps, use the map function of this library.
extension on TranslationsDe {
dynamic _flatMapFunction(String path) {
switch (path) {
case 'hello': return ({required Object name}) => 'Hallo ${name}';
case 'login.success': return 'Login erfolgreich';
case 'settings.title': return 'Einstellungen';
case 'settings.sections.account': return 'Konto & Daten';
case 'settings.sections.app': return 'App';
case 'settings.sections.help': return 'Hilfe & Rechtliches';
case 'settings.items.appSettings': return 'App-Einstellungen';
case 'settings.items.personalData': return 'Persönliche Daten';
case 'settings.items.accountManagement': return 'Kontoverwaltung';
case 'settings.items.helpCenter': return 'Hilfe';
case 'settings.items.feedback': return 'Feedback';
case 'settings.items.legalPrivacy': return 'Rechtliches & Datenschutz';
case 'settings.items.logout': return 'Abmelden';
case 'settings.messages.logoutNotImplemented': return 'Logout… (noch nicht implementiert)';
default: return null;
}
}
}

View File

@@ -0,0 +1,161 @@
///
/// Generated file. Do not edit.
///
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
part of 'translations.g.dart';
// Path: <root>
typedef TranslationsEn = Translations; // ignore: unused_element
class Translations implements BaseTranslations<AppLocale, Translations> {
/// Returns the current translations of the given [context].
///
/// Usage:
/// final t = Translations.of(context);
static Translations of(BuildContext context) => InheritedLocaleData.of<AppLocale, Translations>(context).translations;
/// You can call this constructor and build your own translation instance of this locale.
/// Constructing via the enum [AppLocale.build] is preferred.
Translations({Map<String, Node>? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata<AppLocale, Translations>? meta})
: assert(overrides == null, 'Set "translation_overrides: true" in order to enable this feature.'),
$meta = meta ?? TranslationMetadata(
locale: AppLocale.en,
overrides: overrides ?? {},
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
) {
$meta.setFlatMapFunction(_flatMapFunction);
}
/// Metadata for the translations of <en>.
@override final TranslationMetadata<AppLocale, Translations> $meta;
/// Access flat map
dynamic operator[](String key) => $meta.getTranslation(key);
late final Translations _root = this; // ignore: unused_field
Translations $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => Translations(meta: meta ?? this.$meta);
// Translations
/// en: 'Hello $name'
String hello({required Object name}) => 'Hello ${name}';
late final TranslationsLoginEn login = TranslationsLoginEn._(_root);
late final TranslationsSettingsEn settings = TranslationsSettingsEn._(_root);
}
// Path: login
class TranslationsLoginEn {
TranslationsLoginEn._(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Logged in successfully'
String get success => 'Logged in successfully';
}
// Path: settings
class TranslationsSettingsEn {
TranslationsSettingsEn._(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Settings'
String get title => 'Settings';
late final TranslationsSettingsSectionsEn sections = TranslationsSettingsSectionsEn._(_root);
late final TranslationsSettingsItemsEn items = TranslationsSettingsItemsEn._(_root);
late final TranslationsSettingsMessagesEn messages = TranslationsSettingsMessagesEn._(_root);
}
// Path: settings.sections
class TranslationsSettingsSectionsEn {
TranslationsSettingsSectionsEn._(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Account & Data'
String get account => 'Account & Data';
/// en: 'App'
String get app => 'App';
/// en: 'Help & Legal'
String get help => 'Help & Legal';
}
// Path: settings.items
class TranslationsSettingsItemsEn {
TranslationsSettingsItemsEn._(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'App settings'
String get appSettings => 'App settings';
/// en: 'Personal data'
String get personalData => 'Personal data';
/// en: 'Account management'
String get accountManagement => 'Account management';
/// en: 'Help'
String get helpCenter => 'Help';
/// en: 'Feedback'
String get feedback => 'Feedback';
/// en: 'Legal & Privacy'
String get legalPrivacy => 'Legal & Privacy';
/// en: 'Sign out'
String get logout => 'Sign out';
}
// Path: settings.messages
class TranslationsSettingsMessagesEn {
TranslationsSettingsMessagesEn._(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Logout… (not implemented yet)'
String get logoutNotImplemented => 'Logout… (not implemented yet)';
}
/// Flat map(s) containing all translations.
/// Only for edge cases! For simple maps, use the map function of this library.
extension on Translations {
dynamic _flatMapFunction(String path) {
switch (path) {
case 'hello': return ({required Object name}) => 'Hello ${name}';
case 'login.success': return 'Logged in successfully';
case 'settings.title': return 'Settings';
case 'settings.sections.account': return 'Account & Data';
case 'settings.sections.app': return 'App';
case 'settings.sections.help': return 'Help & Legal';
case 'settings.items.appSettings': return 'App settings';
case 'settings.items.personalData': return 'Personal data';
case 'settings.items.accountManagement': return 'Account management';
case 'settings.items.helpCenter': return 'Help';
case 'settings.items.feedback': return 'Feedback';
case 'settings.items.legalPrivacy': return 'Legal & Privacy';
case 'settings.items.logout': return 'Sign out';
case 'settings.messages.logoutNotImplemented': return 'Logout… (not implemented yet)';
default: return null;
}
}
}

View File

@@ -1,44 +1,74 @@
import 'package:app/core/i18n/translations.g.dart';
import 'package:flutter/material.dart';
import 'package:fluttery/fluttery.dart';
import 'package:fluttery/preferences.dart';
class LocaleController extends ChangeNotifier {
final Preferences _prefs;
LocaleController() : _prefs = App.service<Preferences>();
final Preferences _prefs = App.service<Preferences>();
static const _key = 'language';
LanguagePref _current = LanguagePref.system;
LanguagePref _current = LanguagePref.en; // Default = Englisch
/// Einmal beim App-Start aufrufen
Future<void> init() async {
final saved = await _prefs.getString(_key);
_current = switch (saved) {
'de' => LanguagePref.de,
'system' => LanguagePref.system,
'en' || _ => LanguagePref.en, // default fallback = en
};
_current = _fromString(saved) ?? LanguagePref.system;
_applyToSlang(_current);
notifyListeners();
}
void setLanguage(LanguagePref pref) {
if (_current == pref) return;
_current = pref;
/// Sprache ändern (persistieren + sofort anwenden)
Future<void> setLanguage(LanguagePref lang) async {
_current = lang;
await _prefs.setString(_key, lang.name);
_applyToSlang(lang);
notifyListeners();
_prefs.setString(_key, pref.name); // fire-and-forget
}
void _applyToSlang(LanguagePref pref) {
if (pref == LanguagePref.system) {
LocaleSettings.useDeviceLocale();
return;
}
final code = pref.code;
if (code == null) {
LocaleSettings.useDeviceLocale();
return;
}
if (AppLocaleUtils.supportedLocalesRaw.contains(code)) {
LocaleSettings.setLocaleRaw(code);
} else {
LocaleSettings.setLocale(AppLocale.en);
}
}
LanguagePref? _fromString(String? value) {
if (value == null || value.isEmpty) return null;
return LanguagePref.values.firstWhere(
(e) => e.name == value,
orElse: () => LanguagePref.system,
);
}
LanguagePref get language => _current;
Locale? get locale => _current.locale;
bool get isSystem => _current == LanguagePref.system;
}
enum LanguagePref {
system(null), // folgt System
de(Locale('de')), // Deutsch
en(Locale('en')); // Englisch (default)
system(null),
de(Locale('de')),
en(Locale('en'));
final Locale? locale;
const LanguagePref(this.locale);
String? get code => locale?.languageCode;
}

View File

@@ -1,9 +1,11 @@
import 'package:app/core/app/router.dart';
import 'package:app/core/app/startup/domain/initialize_app.dart';
import 'package:app/core/i18n/translations.g.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:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:fluttery/fluttery.dart';
import 'package:fluttery/logger.dart';
import 'package:go_router/go_router.dart';
@@ -37,7 +39,9 @@ Future<void> main() async {
ChangeNotifierProvider(create: (context) => scaleController),
ChangeNotifierProvider(create: (context) => localeController),
],
child: FinlogApp(router: buildAppRouter(startRoute)),
child: TranslationProvider(
child: FinlogApp(router: buildAppRouter(startRoute)),
),
),
);
}
@@ -51,14 +55,14 @@ class FinlogApp extends StatelessWidget {
Widget build(BuildContext context) {
final theme = context.watch<ThemeController>();
final textScale = context.watch<ScaleController>();
final localeCtrl = context.watch<LocaleController>();
return AnimatedBuilder(
animation: theme,
builder: (context, _) => MaterialApp.router(
title: 'Finlog',
locale: localeCtrl.locale,
supportedLocales: const [Locale('de'), Locale('en')],
locale: TranslationProvider.of(context).flutterLocale,
supportedLocales: AppLocaleUtils.supportedLocales,
localizationsDelegates: GlobalMaterialLocalizations.delegates,
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
themeMode: theme.themeMode,

View File

@@ -1,3 +1,4 @@
import 'package:app/core/i18n/translations.g.dart';
import 'package:app/core/ui/panel.dart';
import 'package:app/modules/settings/modules/app/app_settings_view.dart';
import 'package:app/modules/settings/modules/help/feedback_view.dart';
@@ -12,8 +13,10 @@ class SettingsView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final t = Translations.of(context);
return Scaffold(
appBar: AppBar(title: const Text('Einstellungen')),
appBar: AppBar(title: Text(t.settings.title)),
body: PanelNavigator(rootBuilder: (ctx) => _CategoryList()),
);
}
@@ -24,6 +27,7 @@ class _CategoryList extends StatelessWidget {
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final t = Translations.of(context);
Widget tile(IconData icon, String label, Widget Function() detail) {
return Padding(
@@ -53,29 +57,41 @@ class _CategoryList extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _SectionHeader('App-Einstellungen'),
tile(Icons.tune, 'App-Einstellungen', () => const AppSettingsView()),
_SectionHeader(t.settings.sections.app),
tile(
Icons.tune,
t.settings.items.appSettings,
() => const AppSettingsView(),
),
const SizedBox(height: 12),
const _SectionHeader('Meine Daten'),
_SectionHeader(t.settings.sections.account),
tile(
Icons.badge_outlined,
'Persönliche Daten',
t.settings.items.personalData,
() => const PersonalPanel(),
),
tile(
Icons.manage_accounts_outlined,
'Kontoverwaltung',
t.settings.items.accountManagement,
() => const AccountPanel(),
),
const SizedBox(height: 12),
const _SectionHeader('Hilfe'),
tile(Icons.help_outline, 'Hilfe', () => const HelpPanel()),
tile(Icons.feedback_outlined, 'Feedback', () => const FeedbackPanel()),
_SectionHeader(t.settings.sections.help),
tile(
Icons.help_outline,
t.settings.items.helpCenter,
() => const HelpPanel(),
),
tile(
Icons.feedback_outlined,
t.settings.items.feedback,
() => const FeedbackPanel(),
),
tile(
Icons.gavel_outlined,
'Rechtliches & Datenschutz',
t.settings.items.legalPrivacy, // "Rechtliches & Datenschutz"
() => const LegalPanel(),
),
const SizedBox(height: 24),
@@ -83,12 +99,10 @@ class _CategoryList extends StatelessWidget {
const Divider(),
ListTile(
leading: const Icon(Icons.logout),
title: const Text('Abmelden'),
title: Text(t.settings.items.logout),
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Logout… (noch nicht implementiert)'),
),
SnackBar(content: Text(t.settings.messages.logoutNotImplemented)),
);
},
),