From ee83eb851a44fb49d8bea68de3d4a63724f0fc18 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Fri, 8 May 2026 01:19:32 +0200 Subject: [PATCH] feat(studio): app-wide i18n via flutter_localizations (v0.24.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- l10n.yaml | 13 + lib/data/hub.dart | 18 + lib/l10n/app_de.arb | 93 +++++ lib/l10n/app_en.arb | 94 +++++ lib/l10n/app_localizations.dart | 572 +++++++++++++++++++++++++++++ lib/l10n/app_localizations_de.dart | 240 ++++++++++++ lib/l10n/app_localizations_en.dart | 244 ++++++++++++ lib/main.dart | 137 +++++-- lib/pages/approvals.dart | 9 +- lib/pages/audit.dart | 3 +- lib/pages/doctor.dart | 3 +- lib/pages/modules.dart | 3 +- lib/pages/store.dart | 53 +-- pubspec.lock | 13 + pubspec.yaml | 6 +- test/widget_test.dart | 6 +- 16 files changed, 1440 insertions(+), 67 deletions(-) create mode 100644 l10n.yaml create mode 100644 lib/l10n/app_de.arb create mode 100644 lib/l10n/app_en.arb create mode 100644 lib/l10n/app_localizations.dart create mode 100644 lib/l10n/app_localizations_de.dart create mode 100644 lib/l10n/app_localizations_en.dart diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 0000000..f92281e --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,13 @@ +# `flutter gen-l10n` config. Generates a typed +# `AppLocalizations` accessor from the ARB files in +# `lib/l10n/`. Re-run on every ARB edit: +# +# flutter gen-l10n +# +# (or just `flutter pub get` / `flutter run` — gen-l10n is +# wired into the build pipeline because pubspec.yaml has +# `flutter.generate: true`.) +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart +output-class: AppLocalizations diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 8dd28c6..4cf5737 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -6,6 +6,7 @@ // protobuf imports. import 'package:fai_dart_sdk/fai_dart_sdk.dart'; +import 'package:flutter/widgets.dart'; import 'package:shared_preferences/shared_preferences.dart'; class HubService { @@ -53,6 +54,7 @@ class HubService { } static const _kThemeKey = 'theme.mode'; + static const _kLocaleKey = 'locale.code'; /// Read the persisted theme mode (system / light / dark) for /// initial app startup. Defaults to system. @@ -68,6 +70,22 @@ class HubService { await prefs.setString(_kThemeKey, mode.wire); } + /// Read the persisted UI locale. Defaults to English when + /// nothing is stored — operators on a fresh install land in + /// English; the sidebar toggle flips to German on demand. + Future loadLocale() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_kLocaleKey); + if (raw == 'de') return const Locale('de'); + return const Locale('en'); + } + + /// Persist the locale code (`en` / `de`). + Future saveLocale(Locale locale) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kLocaleKey, locale.languageCode); + } + Future healthy() => _client.healthy(); Future> listModules() async { diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb new file mode 100644 index 0000000..ce67195 --- /dev/null +++ b/lib/l10n/app_de.arb @@ -0,0 +1,93 @@ +{ + "@@locale": "de", + + "appTitle": "F∆I Studio", + + "navDoctor": "Diagnose", + "navModules": "Module", + "navStore": "Store", + "navFlows": "Flows", + "navAudit": "Protokoll", + "navApprovals": "Freigaben", + + "connectionConnected": "verbunden", + "connectionUnreachable": "nicht erreichbar", + "connectionConnecting": "verbinde…", + "connectionStartHub": "Hub starten", + + "buttonCancel": "Abbrechen", + "buttonSave": "Speichern", + "buttonClose": "Schließen", + "buttonRetry": "Erneut versuchen", + "buttonRefresh": "Aktualisieren", + "buttonInstall": "Installieren", + "buttonUninstall": "Deinstallieren", + "buttonDetails": "Details", + "buttonAdd": "Hinzufügen", + "buttonRemove": "Entfernen", + "buttonOpen": "Öffnen", + "buttonExplain": "Erklären", + "buttonReadDocs": "Doku öffnen", + + "settingsTitle": "Hub-Endpunkt", + "settingsHost": "Host", + "settingsPort": "Port", + "settingsTls": "TLS", + "settingsSaveAndConnect": "Speichern & verbinden", + "settingsLanguage": "Sprache", + "settingsLanguageHint": "Sprache der App. Wirkt auf alle Seiten; mehrsprachige Store-Einträge folgen ebenfalls.", + + "channelsHeader": "KANÄLE", + "channelsActive": "aktiv", + "channelsRunning": "läuft", + "channelsStopped": "gestoppt", + "channelsConnect": "Studio mit diesem Kanal verbinden", + "channelsSwitch": "Diesen Kanal aktivieren", + "channelsEnableAutostart": "Autostart bei Anmeldung aktivieren", + "channelsDisableAutostart": "Autostart deaktivieren", + + "storeSearchHint": "Module suchen — Name, Tagline, Tag, Capability…", + "storeFeatured": "EMPFOHLEN", + "storeFeaturedHint": "kuratierte Auswahl · klicken für Details", + "storeNoMatches": "Keine Treffer", + "storeNoMatchesHint": "Filter anpassen oder Suche leeren, um alle Einträge zu sehen.", + "storeClearFilters": "Filter zurücksetzen", + "storeStatusAll": "Alle", + "storeStatusPublished": "Veröffentlicht", + "storeStatusAlpha": "Alpha", + "storeStatusPlanned": "Geplant", + "storeInstalledOnly": "Installiert", + "storeNResults": "{n} Treffer", + "@storeNResults": { + "placeholders": { "n": { "type": "int" } } + }, + + "auditTitle": "Protokoll", + "auditNoEvents": "Noch keine Ereignisse", + "auditNoEventsHint": "Starte einen Flow, um den Audit-Stream zu füllen.\nEreignisse erscheinen innerhalb von Sekunden.", + + "modulesTitle": "Module", + "modulesNoneTitle": "Noch keine Module", + "modulesRecentActivity": "LETZTE AKTIVITÄT", + + "approvalsTitle": "Freigaben", + "approvalsTabPending": "Offen", + "approvalsTabHistory": "Verlauf", + "approvalsInboxZero": "Posteingang leer", + "approvalsInboxHint": "Alles erledigt. Neue `system.approval@^0`-Schritte landen automatisch hier.", + + "doctorTitle": "Diagnose", + + "mcpHeader": "MCP-CLIENTS", + "mcpHint": "Externe MCP-Server bringen ihre Tools als `mcp..` Capabilities ein. Einmal konfigurieren, im Store sichtbar.", + "mcpEmpty": "Keine MCP-Server konfiguriert. + klicken zum Hinzufügen.", + "n8nHeader": "N8N-ENDPUNKTE", + "n8nHint": "Aktive n8n-Workflows erscheinen als `n8n..` Capabilities. Einmal konfigurieren, im Store sichtbar.", + "n8nEmpty": "Keine n8n-Endpunkte konfiguriert. + klicken zum Hinzufügen.", + + "hubUnreachable": "Hub nicht erreichbar", + "hubUnreachableHint": "Hub starten mit `fai serve`.", + + "languageEnglish": "English", + "languageGerman": "Deutsch" +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb new file mode 100644 index 0000000..5f19313 --- /dev/null +++ b/lib/l10n/app_en.arb @@ -0,0 +1,94 @@ +{ + "@@locale": "en", + + "appTitle": "F∆I Studio", + "@appTitle": { "description": "App-bar title in the OS task switcher." }, + + "navDoctor": "Doctor", + "navModules": "Modules", + "navStore": "Store", + "navFlows": "Flows", + "navAudit": "Audit", + "navApprovals": "Approvals", + + "connectionConnected": "connected", + "connectionUnreachable": "unreachable", + "connectionConnecting": "connecting…", + "connectionStartHub": "Start hub", + + "buttonCancel": "Cancel", + "buttonSave": "Save", + "buttonClose": "Close", + "buttonRetry": "Retry", + "buttonRefresh": "Refresh", + "buttonInstall": "Install", + "buttonUninstall": "Uninstall", + "buttonDetails": "Details", + "buttonAdd": "Add", + "buttonRemove": "Remove", + "buttonOpen": "Open", + "buttonExplain": "Explain", + "buttonReadDocs": "Read docs", + + "settingsTitle": "Hub endpoint", + "settingsHost": "Host", + "settingsPort": "Port", + "settingsTls": "TLS", + "settingsSaveAndConnect": "Save & connect", + "settingsLanguage": "Language", + "settingsLanguageHint": "App language. Affects every page; bilingual store entries also flip via this toggle.", + + "channelsHeader": "CHANNELS", + "channelsActive": "active", + "channelsRunning": "running", + "channelsStopped": "stopped", + "channelsConnect": "Connect Studio to this channel", + "channelsSwitch": "Make this the active channel", + "channelsEnableAutostart": "Enable autostart at login", + "channelsDisableAutostart": "Disable autostart", + + "storeSearchHint": "Search modules — name, tagline, tag, capability…", + "storeFeatured": "FEATURED", + "storeFeaturedHint": "curated picks · click for details", + "storeNoMatches": "No matches", + "storeNoMatchesHint": "Adjust the filters or clear the search to see all entries.", + "storeClearFilters": "Clear filters", + "storeStatusAll": "All", + "storeStatusPublished": "Published", + "storeStatusAlpha": "Alpha", + "storeStatusPlanned": "Planned", + "storeInstalledOnly": "Installed", + "storeNResults": "{n} result{n, plural, =1{} other{s}}", + "@storeNResults": { + "placeholders": { "n": { "type": "int" } } + }, + + "auditTitle": "Audit", + "auditNoEvents": "No events yet", + "auditNoEventsHint": "Run a flow to populate the audit stream.\nEvents appear here within seconds.", + + "modulesTitle": "Modules", + "modulesNoneTitle": "No modules yet", + "modulesRecentActivity": "RECENT ACTIVITY", + + "approvalsTitle": "Approvals", + "approvalsTabPending": "Pending", + "approvalsTabHistory": "History", + "approvalsInboxZero": "Inbox zero", + "approvalsInboxHint": "All caught up. New `system.approval@^0` steps land here automatically.", + + "doctorTitle": "Doctor", + + "mcpHeader": "MCP CLIENTS", + "mcpHint": "External MCP servers federate their tools as `mcp..` capabilities. Configure once, see them in the Store.", + "mcpEmpty": "No MCP servers configured. Click + to add one.", + "n8nHeader": "N8N ENDPOINTS", + "n8nHint": "Active n8n workflows surface as `n8n..` capabilities. Configure once, see them in the Store.", + "n8nEmpty": "No n8n endpoints configured. Click + to add one.", + + "hubUnreachable": "Hub unreachable", + "hubUnreachableHint": "Start the hub with `fai serve`.", + + "languageEnglish": "English", + "languageGerman": "Deutsch" +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..0a22542 --- /dev/null +++ b/lib/l10n/app_localizations.dart @@ -0,0 +1,572 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_de.dart'; +import 'app_localizations_en.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations? of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('de'), + Locale('en'), + ]; + + /// App-bar title in the OS task switcher. + /// + /// In en, this message translates to: + /// **'F∆I Studio'** + String get appTitle; + + /// No description provided for @navDoctor. + /// + /// In en, this message translates to: + /// **'Doctor'** + String get navDoctor; + + /// No description provided for @navModules. + /// + /// In en, this message translates to: + /// **'Modules'** + String get navModules; + + /// No description provided for @navStore. + /// + /// In en, this message translates to: + /// **'Store'** + String get navStore; + + /// No description provided for @navFlows. + /// + /// In en, this message translates to: + /// **'Flows'** + String get navFlows; + + /// No description provided for @navAudit. + /// + /// In en, this message translates to: + /// **'Audit'** + String get navAudit; + + /// No description provided for @navApprovals. + /// + /// In en, this message translates to: + /// **'Approvals'** + String get navApprovals; + + /// No description provided for @connectionConnected. + /// + /// In en, this message translates to: + /// **'connected'** + String get connectionConnected; + + /// No description provided for @connectionUnreachable. + /// + /// In en, this message translates to: + /// **'unreachable'** + String get connectionUnreachable; + + /// No description provided for @connectionConnecting. + /// + /// In en, this message translates to: + /// **'connecting…'** + String get connectionConnecting; + + /// No description provided for @connectionStartHub. + /// + /// In en, this message translates to: + /// **'Start hub'** + String get connectionStartHub; + + /// No description provided for @buttonCancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get buttonCancel; + + /// No description provided for @buttonSave. + /// + /// In en, this message translates to: + /// **'Save'** + String get buttonSave; + + /// No description provided for @buttonClose. + /// + /// In en, this message translates to: + /// **'Close'** + String get buttonClose; + + /// No description provided for @buttonRetry. + /// + /// In en, this message translates to: + /// **'Retry'** + String get buttonRetry; + + /// No description provided for @buttonRefresh. + /// + /// In en, this message translates to: + /// **'Refresh'** + String get buttonRefresh; + + /// No description provided for @buttonInstall. + /// + /// In en, this message translates to: + /// **'Install'** + String get buttonInstall; + + /// No description provided for @buttonUninstall. + /// + /// In en, this message translates to: + /// **'Uninstall'** + String get buttonUninstall; + + /// No description provided for @buttonDetails. + /// + /// In en, this message translates to: + /// **'Details'** + String get buttonDetails; + + /// No description provided for @buttonAdd. + /// + /// In en, this message translates to: + /// **'Add'** + String get buttonAdd; + + /// No description provided for @buttonRemove. + /// + /// In en, this message translates to: + /// **'Remove'** + String get buttonRemove; + + /// No description provided for @buttonOpen. + /// + /// In en, this message translates to: + /// **'Open'** + String get buttonOpen; + + /// No description provided for @buttonExplain. + /// + /// In en, this message translates to: + /// **'Explain'** + String get buttonExplain; + + /// No description provided for @buttonReadDocs. + /// + /// In en, this message translates to: + /// **'Read docs'** + String get buttonReadDocs; + + /// No description provided for @settingsTitle. + /// + /// In en, this message translates to: + /// **'Hub endpoint'** + String get settingsTitle; + + /// No description provided for @settingsHost. + /// + /// In en, this message translates to: + /// **'Host'** + String get settingsHost; + + /// No description provided for @settingsPort. + /// + /// In en, this message translates to: + /// **'Port'** + String get settingsPort; + + /// No description provided for @settingsTls. + /// + /// In en, this message translates to: + /// **'TLS'** + String get settingsTls; + + /// No description provided for @settingsSaveAndConnect. + /// + /// In en, this message translates to: + /// **'Save & connect'** + String get settingsSaveAndConnect; + + /// No description provided for @settingsLanguage. + /// + /// In en, this message translates to: + /// **'Language'** + String get settingsLanguage; + + /// No description provided for @settingsLanguageHint. + /// + /// In en, this message translates to: + /// **'App language. Affects every page; bilingual store entries also flip via this toggle.'** + String get settingsLanguageHint; + + /// No description provided for @channelsHeader. + /// + /// In en, this message translates to: + /// **'CHANNELS'** + String get channelsHeader; + + /// No description provided for @channelsActive. + /// + /// In en, this message translates to: + /// **'active'** + String get channelsActive; + + /// No description provided for @channelsRunning. + /// + /// In en, this message translates to: + /// **'running'** + String get channelsRunning; + + /// No description provided for @channelsStopped. + /// + /// In en, this message translates to: + /// **'stopped'** + String get channelsStopped; + + /// No description provided for @channelsConnect. + /// + /// In en, this message translates to: + /// **'Connect Studio to this channel'** + String get channelsConnect; + + /// No description provided for @channelsSwitch. + /// + /// In en, this message translates to: + /// **'Make this the active channel'** + String get channelsSwitch; + + /// No description provided for @channelsEnableAutostart. + /// + /// In en, this message translates to: + /// **'Enable autostart at login'** + String get channelsEnableAutostart; + + /// No description provided for @channelsDisableAutostart. + /// + /// In en, this message translates to: + /// **'Disable autostart'** + String get channelsDisableAutostart; + + /// No description provided for @storeSearchHint. + /// + /// In en, this message translates to: + /// **'Search modules — name, tagline, tag, capability…'** + String get storeSearchHint; + + /// No description provided for @storeFeatured. + /// + /// In en, this message translates to: + /// **'FEATURED'** + String get storeFeatured; + + /// No description provided for @storeFeaturedHint. + /// + /// In en, this message translates to: + /// **'curated picks · click for details'** + String get storeFeaturedHint; + + /// No description provided for @storeNoMatches. + /// + /// In en, this message translates to: + /// **'No matches'** + String get storeNoMatches; + + /// No description provided for @storeNoMatchesHint. + /// + /// In en, this message translates to: + /// **'Adjust the filters or clear the search to see all entries.'** + String get storeNoMatchesHint; + + /// No description provided for @storeClearFilters. + /// + /// In en, this message translates to: + /// **'Clear filters'** + String get storeClearFilters; + + /// No description provided for @storeStatusAll. + /// + /// In en, this message translates to: + /// **'All'** + String get storeStatusAll; + + /// No description provided for @storeStatusPublished. + /// + /// In en, this message translates to: + /// **'Published'** + String get storeStatusPublished; + + /// No description provided for @storeStatusAlpha. + /// + /// In en, this message translates to: + /// **'Alpha'** + String get storeStatusAlpha; + + /// No description provided for @storeStatusPlanned. + /// + /// In en, this message translates to: + /// **'Planned'** + String get storeStatusPlanned; + + /// No description provided for @storeInstalledOnly. + /// + /// In en, this message translates to: + /// **'Installed'** + String get storeInstalledOnly; + + /// No description provided for @storeNResults. + /// + /// In en, this message translates to: + /// **'{n} result{n, plural, =1{} other{s}}'** + String storeNResults(int n); + + /// No description provided for @auditTitle. + /// + /// In en, this message translates to: + /// **'Audit'** + String get auditTitle; + + /// No description provided for @auditNoEvents. + /// + /// In en, this message translates to: + /// **'No events yet'** + String get auditNoEvents; + + /// No description provided for @auditNoEventsHint. + /// + /// In en, this message translates to: + /// **'Run a flow to populate the audit stream.\nEvents appear here within seconds.'** + String get auditNoEventsHint; + + /// No description provided for @modulesTitle. + /// + /// In en, this message translates to: + /// **'Modules'** + String get modulesTitle; + + /// No description provided for @modulesNoneTitle. + /// + /// In en, this message translates to: + /// **'No modules yet'** + String get modulesNoneTitle; + + /// No description provided for @modulesRecentActivity. + /// + /// In en, this message translates to: + /// **'RECENT ACTIVITY'** + String get modulesRecentActivity; + + /// No description provided for @approvalsTitle. + /// + /// In en, this message translates to: + /// **'Approvals'** + String get approvalsTitle; + + /// No description provided for @approvalsTabPending. + /// + /// In en, this message translates to: + /// **'Pending'** + String get approvalsTabPending; + + /// No description provided for @approvalsTabHistory. + /// + /// In en, this message translates to: + /// **'History'** + String get approvalsTabHistory; + + /// No description provided for @approvalsInboxZero. + /// + /// In en, this message translates to: + /// **'Inbox zero'** + String get approvalsInboxZero; + + /// No description provided for @approvalsInboxHint. + /// + /// In en, this message translates to: + /// **'All caught up. New `system.approval@^0` steps land here automatically.'** + String get approvalsInboxHint; + + /// No description provided for @doctorTitle. + /// + /// In en, this message translates to: + /// **'Doctor'** + String get doctorTitle; + + /// No description provided for @mcpHeader. + /// + /// In en, this message translates to: + /// **'MCP CLIENTS'** + String get mcpHeader; + + /// No description provided for @mcpHint. + /// + /// In en, this message translates to: + /// **'External MCP servers federate their tools as `mcp..` capabilities. Configure once, see them in the Store.'** + String get mcpHint; + + /// No description provided for @mcpEmpty. + /// + /// In en, this message translates to: + /// **'No MCP servers configured. Click + to add one.'** + String get mcpEmpty; + + /// No description provided for @n8nHeader. + /// + /// In en, this message translates to: + /// **'N8N ENDPOINTS'** + String get n8nHeader; + + /// No description provided for @n8nHint. + /// + /// In en, this message translates to: + /// **'Active n8n workflows surface as `n8n..` capabilities. Configure once, see them in the Store.'** + String get n8nHint; + + /// No description provided for @n8nEmpty. + /// + /// In en, this message translates to: + /// **'No n8n endpoints configured. Click + to add one.'** + String get n8nEmpty; + + /// No description provided for @hubUnreachable. + /// + /// In en, this message translates to: + /// **'Hub unreachable'** + String get hubUnreachable; + + /// No description provided for @hubUnreachableHint. + /// + /// In en, this message translates to: + /// **'Start the hub with `fai serve`.'** + String get hubUnreachableHint; + + /// No description provided for @languageEnglish. + /// + /// In en, this message translates to: + /// **'English'** + String get languageEnglish; + + /// No description provided for @languageGerman. + /// + /// In en, this message translates to: + /// **'Deutsch'** + String get languageGerman; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['de', 'en'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'de': + return AppLocalizationsDe(); + case 'en': + return AppLocalizationsEn(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.', + ); +} diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart new file mode 100644 index 0000000..b641124 --- /dev/null +++ b/lib/l10n/app_localizations_de.dart @@ -0,0 +1,240 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for German (`de`). +class AppLocalizationsDe extends AppLocalizations { + AppLocalizationsDe([String locale = 'de']) : super(locale); + + @override + String get appTitle => 'F∆I Studio'; + + @override + String get navDoctor => 'Diagnose'; + + @override + String get navModules => 'Module'; + + @override + String get navStore => 'Store'; + + @override + String get navFlows => 'Flows'; + + @override + String get navAudit => 'Protokoll'; + + @override + String get navApprovals => 'Freigaben'; + + @override + String get connectionConnected => 'verbunden'; + + @override + String get connectionUnreachable => 'nicht erreichbar'; + + @override + String get connectionConnecting => 'verbinde…'; + + @override + String get connectionStartHub => 'Hub starten'; + + @override + String get buttonCancel => 'Abbrechen'; + + @override + String get buttonSave => 'Speichern'; + + @override + String get buttonClose => 'Schließen'; + + @override + String get buttonRetry => 'Erneut versuchen'; + + @override + String get buttonRefresh => 'Aktualisieren'; + + @override + String get buttonInstall => 'Installieren'; + + @override + String get buttonUninstall => 'Deinstallieren'; + + @override + String get buttonDetails => 'Details'; + + @override + String get buttonAdd => 'Hinzufügen'; + + @override + String get buttonRemove => 'Entfernen'; + + @override + String get buttonOpen => 'Öffnen'; + + @override + String get buttonExplain => 'Erklären'; + + @override + String get buttonReadDocs => 'Doku öffnen'; + + @override + String get settingsTitle => 'Hub-Endpunkt'; + + @override + String get settingsHost => 'Host'; + + @override + String get settingsPort => 'Port'; + + @override + String get settingsTls => 'TLS'; + + @override + String get settingsSaveAndConnect => 'Speichern & verbinden'; + + @override + String get settingsLanguage => 'Sprache'; + + @override + String get settingsLanguageHint => + 'Sprache der App. Wirkt auf alle Seiten; mehrsprachige Store-Einträge folgen ebenfalls.'; + + @override + String get channelsHeader => 'KANÄLE'; + + @override + String get channelsActive => 'aktiv'; + + @override + String get channelsRunning => 'läuft'; + + @override + String get channelsStopped => 'gestoppt'; + + @override + String get channelsConnect => 'Studio mit diesem Kanal verbinden'; + + @override + String get channelsSwitch => 'Diesen Kanal aktivieren'; + + @override + String get channelsEnableAutostart => 'Autostart bei Anmeldung aktivieren'; + + @override + String get channelsDisableAutostart => 'Autostart deaktivieren'; + + @override + String get storeSearchHint => + 'Module suchen — Name, Tagline, Tag, Capability…'; + + @override + String get storeFeatured => 'EMPFOHLEN'; + + @override + String get storeFeaturedHint => 'kuratierte Auswahl · klicken für Details'; + + @override + String get storeNoMatches => 'Keine Treffer'; + + @override + String get storeNoMatchesHint => + 'Filter anpassen oder Suche leeren, um alle Einträge zu sehen.'; + + @override + String get storeClearFilters => 'Filter zurücksetzen'; + + @override + String get storeStatusAll => 'Alle'; + + @override + String get storeStatusPublished => 'Veröffentlicht'; + + @override + String get storeStatusAlpha => 'Alpha'; + + @override + String get storeStatusPlanned => 'Geplant'; + + @override + String get storeInstalledOnly => 'Installiert'; + + @override + String storeNResults(int n) { + return '$n Treffer'; + } + + @override + String get auditTitle => 'Protokoll'; + + @override + String get auditNoEvents => 'Noch keine Ereignisse'; + + @override + String get auditNoEventsHint => + 'Starte einen Flow, um den Audit-Stream zu füllen.\nEreignisse erscheinen innerhalb von Sekunden.'; + + @override + String get modulesTitle => 'Module'; + + @override + String get modulesNoneTitle => 'Noch keine Module'; + + @override + String get modulesRecentActivity => 'LETZTE AKTIVITÄT'; + + @override + String get approvalsTitle => 'Freigaben'; + + @override + String get approvalsTabPending => 'Offen'; + + @override + String get approvalsTabHistory => 'Verlauf'; + + @override + String get approvalsInboxZero => 'Posteingang leer'; + + @override + String get approvalsInboxHint => + 'Alles erledigt. Neue `system.approval@^0`-Schritte landen automatisch hier.'; + + @override + String get doctorTitle => 'Diagnose'; + + @override + String get mcpHeader => 'MCP-CLIENTS'; + + @override + String get mcpHint => + 'Externe MCP-Server bringen ihre Tools als `mcp..` Capabilities ein. Einmal konfigurieren, im Store sichtbar.'; + + @override + String get mcpEmpty => + 'Keine MCP-Server konfiguriert. + klicken zum Hinzufügen.'; + + @override + String get n8nHeader => 'N8N-ENDPUNKTE'; + + @override + String get n8nHint => + 'Aktive n8n-Workflows erscheinen als `n8n..` Capabilities. Einmal konfigurieren, im Store sichtbar.'; + + @override + String get n8nEmpty => + 'Keine n8n-Endpunkte konfiguriert. + klicken zum Hinzufügen.'; + + @override + String get hubUnreachable => 'Hub nicht erreichbar'; + + @override + String get hubUnreachableHint => 'Hub starten mit `fai serve`.'; + + @override + String get languageEnglish => 'English'; + + @override + String get languageGerman => 'Deutsch'; +} diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart new file mode 100644 index 0000000..a3132e0 --- /dev/null +++ b/lib/l10n/app_localizations_en.dart @@ -0,0 +1,244 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get appTitle => 'F∆I Studio'; + + @override + String get navDoctor => 'Doctor'; + + @override + String get navModules => 'Modules'; + + @override + String get navStore => 'Store'; + + @override + String get navFlows => 'Flows'; + + @override + String get navAudit => 'Audit'; + + @override + String get navApprovals => 'Approvals'; + + @override + String get connectionConnected => 'connected'; + + @override + String get connectionUnreachable => 'unreachable'; + + @override + String get connectionConnecting => 'connecting…'; + + @override + String get connectionStartHub => 'Start hub'; + + @override + String get buttonCancel => 'Cancel'; + + @override + String get buttonSave => 'Save'; + + @override + String get buttonClose => 'Close'; + + @override + String get buttonRetry => 'Retry'; + + @override + String get buttonRefresh => 'Refresh'; + + @override + String get buttonInstall => 'Install'; + + @override + String get buttonUninstall => 'Uninstall'; + + @override + String get buttonDetails => 'Details'; + + @override + String get buttonAdd => 'Add'; + + @override + String get buttonRemove => 'Remove'; + + @override + String get buttonOpen => 'Open'; + + @override + String get buttonExplain => 'Explain'; + + @override + String get buttonReadDocs => 'Read docs'; + + @override + String get settingsTitle => 'Hub endpoint'; + + @override + String get settingsHost => 'Host'; + + @override + String get settingsPort => 'Port'; + + @override + String get settingsTls => 'TLS'; + + @override + String get settingsSaveAndConnect => 'Save & connect'; + + @override + String get settingsLanguage => 'Language'; + + @override + String get settingsLanguageHint => + 'App language. Affects every page; bilingual store entries also flip via this toggle.'; + + @override + String get channelsHeader => 'CHANNELS'; + + @override + String get channelsActive => 'active'; + + @override + String get channelsRunning => 'running'; + + @override + String get channelsStopped => 'stopped'; + + @override + String get channelsConnect => 'Connect Studio to this channel'; + + @override + String get channelsSwitch => 'Make this the active channel'; + + @override + String get channelsEnableAutostart => 'Enable autostart at login'; + + @override + String get channelsDisableAutostart => 'Disable autostart'; + + @override + String get storeSearchHint => + 'Search modules — name, tagline, tag, capability…'; + + @override + String get storeFeatured => 'FEATURED'; + + @override + String get storeFeaturedHint => 'curated picks · click for details'; + + @override + String get storeNoMatches => 'No matches'; + + @override + String get storeNoMatchesHint => + 'Adjust the filters or clear the search to see all entries.'; + + @override + String get storeClearFilters => 'Clear filters'; + + @override + String get storeStatusAll => 'All'; + + @override + String get storeStatusPublished => 'Published'; + + @override + String get storeStatusAlpha => 'Alpha'; + + @override + String get storeStatusPlanned => 'Planned'; + + @override + String get storeInstalledOnly => 'Installed'; + + @override + String storeNResults(int n) { + String _temp0 = intl.Intl.pluralLogic( + n, + locale: localeName, + other: 's', + one: '', + ); + return '$n result$_temp0'; + } + + @override + String get auditTitle => 'Audit'; + + @override + String get auditNoEvents => 'No events yet'; + + @override + String get auditNoEventsHint => + 'Run a flow to populate the audit stream.\nEvents appear here within seconds.'; + + @override + String get modulesTitle => 'Modules'; + + @override + String get modulesNoneTitle => 'No modules yet'; + + @override + String get modulesRecentActivity => 'RECENT ACTIVITY'; + + @override + String get approvalsTitle => 'Approvals'; + + @override + String get approvalsTabPending => 'Pending'; + + @override + String get approvalsTabHistory => 'History'; + + @override + String get approvalsInboxZero => 'Inbox zero'; + + @override + String get approvalsInboxHint => + 'All caught up. New `system.approval@^0` steps land here automatically.'; + + @override + String get doctorTitle => 'Doctor'; + + @override + String get mcpHeader => 'MCP CLIENTS'; + + @override + String get mcpHint => + 'External MCP servers federate their tools as `mcp..` capabilities. Configure once, see them in the Store.'; + + @override + String get mcpEmpty => 'No MCP servers configured. Click + to add one.'; + + @override + String get n8nHeader => 'N8N ENDPOINTS'; + + @override + String get n8nHint => + 'Active n8n workflows surface as `n8n..` capabilities. Configure once, see them in the Store.'; + + @override + String get n8nEmpty => 'No n8n endpoints configured. Click + to add one.'; + + @override + String get hubUnreachable => 'Hub unreachable'; + + @override + String get hubUnreachableHint => 'Start the hub with `fai serve`.'; + + @override + String get languageEnglish => 'English'; + + @override + String get languageGerman => 'Deutsch'; +} diff --git a/lib/main.dart b/lib/main.dart index 645bada..d9388b1 100644 --- a/lib/main.dart +++ b/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 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 { /// `MaterialApp(home: const StudioShell())` because the /// const child's element doesn't get a `didUpdateWidget`. late final ValueNotifier 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 localeNotifier; Future setMode(ThemeModeValue m) async { modeNotifier.value = m; await HubService.instance.saveThemeMode(m); } + Future 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 { Widget build(BuildContext context) { return ValueListenableBuilder( 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( + 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 { 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 { final hits = [ 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( + 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, + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/pages/approvals.dart b/lib/pages/approvals.dart index 9d58981..ce451e6 100644 --- a/lib/pages/approvals.dart +++ b/lib/pages/approvals.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import '../data/hub.dart'; +import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; import '../widgets/widgets.dart'; @@ -122,12 +123,12 @@ class _ApprovalsPageState extends State return Scaffold( backgroundColor: theme.scaffoldBackgroundColor, appBar: AppBar( - title: const Text('Approvals'), + title: Text(AppLocalizations.of(context)!.approvalsTitle), bottom: TabBar( controller: _tab, - tabs: const [ - Tab(text: 'Pending'), - Tab(text: 'History'), + tabs: [ + Tab(text: AppLocalizations.of(context)!.approvalsTabPending), + Tab(text: AppLocalizations.of(context)!.approvalsTabHistory), ], ), actions: [ diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index 5332ba9..29a4ae6 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import '../data/hub.dart'; +import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; import '../widgets/widgets.dart'; @@ -103,7 +104,7 @@ class _AuditPageState extends State { return Scaffold( backgroundColor: theme.scaffoldBackgroundColor, appBar: AppBar( - title: const Text('Audit'), + title: Text(AppLocalizations.of(context)!.auditTitle), actions: [ Padding( padding: const EdgeInsets.only(right: FaiSpace.lg), diff --git a/lib/pages/doctor.dart b/lib/pages/doctor.dart index 94b6dc1..75f1711 100644 --- a/lib/pages/doctor.dart +++ b/lib/pages/doctor.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../data/hub.dart'; import '../data/system_actions.dart'; +import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; import '../widgets/widgets.dart'; @@ -31,7 +32,7 @@ class _DoctorPageState extends State { return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, appBar: AppBar( - title: const Text('Doctor'), + title: Text(AppLocalizations.of(context)!.doctorTitle), actions: [ IconButton( icon: const Icon(Icons.refresh, size: 18), diff --git a/lib/pages/modules.dart b/lib/pages/modules.dart index 3df4929..aa06399 100644 --- a/lib/pages/modules.dart +++ b/lib/pages/modules.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../data/hub.dart'; +import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; import '../widgets/widgets.dart'; @@ -40,7 +41,7 @@ class _ModulesPageState extends State { return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, appBar: AppBar( - title: const Text('Modules'), + title: Text(AppLocalizations.of(context)!.modulesTitle), actions: [ IconButton( icon: const Icon(Icons.refresh, size: 18), diff --git a/lib/pages/store.dart b/lib/pages/store.dart index 4e69083..132342f 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -26,9 +26,15 @@ class _StorePageState extends State { String _category = ''; String _status = ''; bool _installedOnly = false; - String _locale = 'en'; late Future> _future; + /// Active UI locale, read from the app-wide MaterialApp + /// `locale` setting. Drives which side of the bilingual + /// store-index entries renders (taglineEn vs taglineDe, + /// descriptionEn vs descriptionDe). The sidebar's + /// language toggle flips this for the whole app. + String get _locale => Localizations.localeOf(context).languageCode; + @override void initState() { super.initState(); @@ -97,13 +103,10 @@ class _StorePageState extends State { appBar: AppBar( title: const Text('Store'), actions: [ - // Tiny DE/EN toggle — the store-index ships bilingual - // copy and operators in regulated environments often - // share screens with non-English colleagues. - _LocaleToggle( - value: _locale, - onChanged: (v) => setState(() => _locale = v), - ), + // The DE/EN toggle now lives in the sidebar footer + // and flips the entire app's locale. The Store + // detail-sheet content follows automatically because + // _locale reads from `Localizations.localeOf`. IconButton( icon: const Icon(Icons.refresh, size: 18), tooltip: 'Reload', @@ -421,29 +424,6 @@ class _ChoiceChip extends StatelessWidget { } } -class _LocaleToggle extends StatelessWidget { - final String value; - final ValueChanged onChanged; - const _LocaleToggle({required this.value, required this.onChanged}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(right: FaiSpace.sm), - child: Tooltip( - message: value == 'en' - ? 'Showing English copy. Click to switch to German.' - : 'Deutsche Beschreibung — Click for English.', - child: TextButton.icon( - onPressed: () => onChanged(value == 'en' ? 'de' : 'en'), - icon: const Icon(Icons.translate, size: 16), - label: Text(value.toUpperCase()), - ), - ), - ); - } -} - /// Responsive grid — wraps to as many columns as the viewport /// allows. Cards are click-to-expand; install is also a top- /// level action so single-click installs are still possible. @@ -893,7 +873,9 @@ class _StoreDetailSheet extends StatefulWidget { } class _StoreDetailSheetState extends State<_StoreDetailSheet> { - late String _locale = widget.locale; + /// Locale comes from MaterialApp via `Localizations.localeOf`; + /// the per-sheet state from before is no longer needed. + String get _locale => Localizations.localeOf(context).languageCode; bool _busy = false; String? _toast; Future<({String errorKind, String text, String sourceUrl})>? _docsFuture; @@ -1085,10 +1067,9 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> { ], ), ), - _LocaleToggle( - value: _locale, - onChanged: (v) => setState(() => _locale = v), - ), + // Locale follows the app-wide setting + // from the sidebar; the detail sheet + // does not host its own toggle anymore. ], ), const SizedBox(height: FaiSpace.lg), diff --git a/pubspec.lock b/pubspec.lock index 7dfb0c2..aa015f3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -125,6 +125,11 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_markdown: dependency: "direct main" description: @@ -223,6 +228,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" io: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ab6e60d..03eaa7b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.23.0 +version: 0.24.0 environment: sdk: ^3.11.0-200.1.beta @@ -9,7 +9,10 @@ environment: dependencies: flutter: sdk: flutter + flutter_localizations: + sdk: flutter cupertino_icons: ^1.0.8 + intl: any # Sibling package; will move to Forgejo path once published. fai_dart_sdk: @@ -26,3 +29,4 @@ dev_dependencies: flutter: uses-material-design: true + generate: true diff --git a/test/widget_test.dart b/test/widget_test.dart index 69cc9b0..f9f9cc5 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,5 +1,6 @@ // Smoke test: app boots and shows every navigation destination. +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fai_studio/data/hub.dart'; import 'package:fai_studio/main.dart'; @@ -8,7 +9,10 @@ void main() { testWidgets('Studio shell shows every navigation destination', (tester) async { await tester.pumpWidget( - const StudioApp(initialThemeMode: ThemeModeValue.system), + const StudioApp( + initialThemeMode: ThemeModeValue.system, + initialLocale: Locale('en'), + ), ); expect(find.text('F∆I Studio'), findsOneWidget);