feat(studio): app-wide i18n via flutter_localizations (v0.24.0)

Replaces the Store-only DE/EN toggle with an app-wide one
parked in the sidebar footer next to the theme button.
Pressing it flips every translated string at once: nav
labels, page titles, common buttons, the bilingual
store-index content.

Implementation:
- Adds `flutter_localizations` + `intl` to pubspec, plus
  `flutter.generate: true` so `flutter gen-l10n` runs in the
  build pipeline.
- ARB sources at `lib/l10n/app_en.arb` and `app_de.arb`. The
  EN file is the template; DE carries the German strings.
  Initial coverage: navigation, common buttons, page titles,
  channels / store / audit / modules / approvals headers,
  hub-unreachable copy, MCP + n8n panel headers + hints.
  Rest of the UI strings are still English-literal — those
  fall in incrementally as we touch each surface.
- Generated `AppLocalizations` lives at
  `lib/l10n/app_localizations*.dart` (regenerated via
  `flutter gen-l10n` on every ARB edit).
- `StudioAppState` gains `localeNotifier` alongside
  `modeNotifier`; persisted via SharedPreferences key
  `locale.code`.
- Sidebar `_LanguageToggle` reads/writes through the
  notifier. The Store's per-page locale state is gone:
  `_locale` now reads `Localizations.localeOf(context)
  .languageCode`, so the bilingual store-index content
  follows the global setting without a second toggle.
- `_NavPage.label` becomes `_NavPage.id` + `labelOf(context)`;
  Cmd+K palette and Sidebar both read the localized label.

Out of scope this iteration: localizing the remaining
~80% of UI strings (Settings dialog labels, Store search
hint, error messages). Those land incrementally — the i18n
infrastructure now means each is a one-line ARB edit + one
call-site swap.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-08 01:19:32 +02:00
parent d2a6ed32e2
commit ee83eb851a
16 changed files with 1440 additions and 67 deletions

View file

@ -11,6 +11,7 @@ import 'package:flutter/services.dart';
import 'data/hub.dart';
import 'data/system_actions.dart';
import 'l10n/app_localizations.dart';
import 'pages/approvals.dart';
import 'pages/audit.dart';
import 'pages/doctor.dart';
@ -25,21 +26,27 @@ import 'widgets/widgets.dart';
/// Studio's own build version. Bump on every UI commit so the
/// running app self-identifies visible in the sidebar header
/// and quick-glance proof that you're seeing the current build.
const String kStudioVersion = '0.23.0';
const String kStudioVersion = '0.24.0';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Restore the persisted endpoint and theme mode before the
// first frame so neither flicker on startup.
// Restore the persisted endpoint, theme mode, and locale
// before the first frame so none of them flicker on startup.
await HubService.instance.loadPersistedEndpoint();
final themeMode = await HubService.instance.loadThemeMode();
runApp(StudioApp(initialThemeMode: themeMode));
final locale = await HubService.instance.loadLocale();
runApp(StudioApp(initialThemeMode: themeMode, initialLocale: locale));
}
class StudioApp extends StatefulWidget {
final ThemeModeValue initialThemeMode;
final Locale initialLocale;
const StudioApp({super.key, required this.initialThemeMode});
const StudioApp({
super.key,
required this.initialThemeMode,
required this.initialLocale,
});
/// Lookup helper so descendants can flip the theme without
/// passing callbacks down through every level.
@ -57,21 +64,32 @@ class StudioAppState extends State<StudioApp> {
/// `MaterialApp(home: const StudioShell())` because the
/// const child's element doesn't get a `didUpdateWidget`.
late final ValueNotifier<ThemeModeValue> modeNotifier;
/// Active app locale. The sidebar's language toggle flips
/// this, MaterialApp re-renders with the new
/// AppLocalizations, and every translated string updates.
late final ValueNotifier<Locale> localeNotifier;
Future<void> setMode(ThemeModeValue m) async {
modeNotifier.value = m;
await HubService.instance.saveThemeMode(m);
}
Future<void> setLocale(Locale l) async {
localeNotifier.value = l;
await HubService.instance.saveLocale(l);
}
@override
void initState() {
super.initState();
modeNotifier = ValueNotifier(widget.initialThemeMode);
localeNotifier = ValueNotifier(widget.initialLocale);
}
@override
void dispose() {
modeNotifier.dispose();
localeNotifier.dispose();
super.dispose();
}
@ -90,13 +108,19 @@ class StudioAppState extends State<StudioApp> {
Widget build(BuildContext context) {
return ValueListenableBuilder<ThemeModeValue>(
valueListenable: modeNotifier,
builder: (_, mode, _) => MaterialApp(
title: 'F∆I Studio',
debugShowCheckedModeBanner: false,
theme: FaiTheme.light(),
darkTheme: FaiTheme.dark(),
themeMode: _flutterMode(mode),
home: const StudioShell(),
builder: (_, mode, _) => ValueListenableBuilder<Locale>(
valueListenable: localeNotifier,
builder: (_, locale, _) => MaterialApp(
title: 'F∆I Studio',
debugShowCheckedModeBanner: false,
theme: FaiTheme.light(),
darkTheme: FaiTheme.dark(),
themeMode: _flutterMode(mode),
locale: locale,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: const StudioShell(),
),
),
);
}
@ -117,37 +141,37 @@ class _StudioShellState extends State<StudioShell> {
static const _pages = <_NavPage>[
_NavPage(
label: 'Doctor',
id: 'doctor',
icon: Icons.health_and_safety_outlined,
selectedIcon: Icons.health_and_safety,
page: DoctorPage(),
),
_NavPage(
label: 'Modules',
id: 'modules',
icon: Icons.extension_outlined,
selectedIcon: Icons.extension,
page: ModulesPage(),
),
_NavPage(
label: 'Store',
id: 'store',
icon: Icons.storefront_outlined,
selectedIcon: Icons.storefront,
page: StorePage(),
),
_NavPage(
label: 'Flows',
id: 'flows',
icon: Icons.account_tree_outlined,
selectedIcon: Icons.account_tree,
page: FlowsPage(),
),
_NavPage(
label: 'Audit',
id: 'audit',
icon: Icons.timeline_outlined,
selectedIcon: Icons.timeline,
page: AuditPage(),
),
_NavPage(
label: 'Approvals',
id: 'approvals',
icon: Icons.inbox_outlined,
selectedIcon: Icons.inbox,
page: ApprovalsPage(),
@ -194,7 +218,7 @@ class _StudioShellState extends State<StudioShell> {
final hits = <FaiSearchHit>[
for (var i = 0; i < _pages.length; i++)
FaiSearchHit(
label: _pages[i].label,
label: _pages[i].labelOf(context),
hint: 'page · ⌘${i + 1}',
icon: _pages[i].icon,
group: 'Pages',
@ -431,6 +455,7 @@ class _Sidebar extends StatelessWidget {
child: Row(
children: [
_ThemeToggle(),
_LanguageToggle(),
const Expanded(
child: Center(child: _SidebarClock()),
),
@ -693,7 +718,7 @@ class _SidebarItemState extends State<_SidebarItem> {
),
const SizedBox(width: FaiSpace.md),
Text(
widget.page.label,
widget.page.labelOf(context),
style: theme.textTheme.labelMedium?.copyWith(color: color),
),
],
@ -706,17 +731,43 @@ class _SidebarItemState extends State<_SidebarItem> {
}
class _NavPage {
final String label;
/// Stable id used for the Cmd+K palette and the sidebar
/// shortcut keys; never shown to the operator. The visible
/// label comes from [labelOf] which reads the active
/// AppLocalizations.
final String id;
final IconData icon;
final IconData selectedIcon;
final Widget page;
const _NavPage({
required this.label,
required this.id,
required this.icon,
required this.selectedIcon,
required this.page,
});
/// Resolve the localized label via AppLocalizations.
String labelOf(BuildContext context) {
final l = AppLocalizations.of(context);
if (l == null) return id;
switch (id) {
case 'doctor':
return l.navDoctor;
case 'modules':
return l.navModules;
case 'store':
return l.navStore;
case 'flows':
return l.navFlows;
case 'audit':
return l.navAudit;
case 'approvals':
return l.navApprovals;
default:
return id;
}
}
}
class _ThemeToggle extends StatelessWidget {
@ -830,3 +881,45 @@ class _SidebarClockState extends State<_SidebarClock> {
);
}
}
/// Two-state language toggle (DE / EN) parked in the sidebar
/// footer next to the theme button. Flipping it updates the
/// app-wide locale via the parent [`StudioAppState`]; every
/// AppLocalizations consumer rebuilds with the new strings.
/// The Store-page detail sheet still renders bilingual store
/// fields off the same locale, so a single click flips
/// chrome + content together.
class _LanguageToggle extends StatelessWidget {
@override
Widget build(BuildContext context) {
final app = StudioApp.of(context);
if (app == null) return const SizedBox.shrink();
return ValueListenableBuilder<Locale>(
valueListenable: app.localeNotifier,
builder: (context, locale, _) {
final isDe = locale.languageCode == 'de';
return Tooltip(
message: isDe
? 'Sprache: Deutsch — Klick wechselt zu English'
: 'Language: English — click to switch to Deutsch',
child: TextButton(
onPressed: () => app.setLocale(Locale(isDe ? 'en' : 'de')),
style: TextButton.styleFrom(
minimumSize: const Size(36, 32),
padding: const EdgeInsets.symmetric(horizontal: 6),
visualDensity: VisualDensity.compact,
),
child: Text(
isDe ? 'DE' : 'EN',
style: FaiTheme.mono(
size: 11,
weight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
),
);
},
);
}
}