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:
parent
d2a6ed32e2
commit
ee83eb851a
16 changed files with 1440 additions and 67 deletions
137
lib/main.dart
137
lib/main.dart
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue