From 91706cea2b0c8d5aabfdeb8d3fb7cb90603e5f1f Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 25 May 2026 23:02:21 +0200 Subject: [PATCH] feat(studio): theme-plugin picker + translate-hook wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pieces of the Studio plugin host: - `data/theme_plugin.dart`: discovery, persistence, ColorScheme translation. Plugins listed via list_capabilities filtered to studio.theme.*; selection persisted in SharedPreferences; loadThemePluginSchemes builds a ColorScheme pair from the plugin's 14 ARGB tokens per brightness. - `main.dart`: StudioApp gains themePluginNotifier + setThemePlugin(). MaterialApp wraps a FutureBuilder that re-fetches the plugin's ColorSchemes whenever the capability flips; falls back to FaiTheme.light/dark on null or load failure. - `fai_settings_dialog.dart`: new `_ThemePluginPanel` shows a dropdown of installed studio.theme.* capabilities + a "Built-in" entry. Switching applies immediately. - HubService.invokePluginTranslate added as the typed wrapper for the new gRPC RPC — ready for the FaiEnBadge swap-out in the next iteration. - 4 new l10n keys (themePluginHeader / None / Hint / Empty), EN+DE. flutter analyze + flutter test (widget + friendly_error): both green. The integration test for the theme picker is the existing fai_runtime plugin_theme + Studio-side invokePluginTheme path that round-trips through the new mirror-installable studio-theme-solarized bundle. Signed-off-by: flemming-it Signed-off-by: flemming-it --- lib/data/hub.dart | 21 ++++ lib/data/theme_plugin.dart | 152 +++++++++++++++++++++++++++ lib/l10n/app_de.arb | 5 + lib/l10n/app_en.arb | 5 + lib/l10n/app_localizations.dart | 24 +++++ lib/l10n/app_localizations_de.dart | 14 +++ lib/l10n/app_localizations_en.dart | 14 +++ lib/main.dart | 88 +++++++++++++--- lib/widgets/fai_settings_dialog.dart | 120 +++++++++++++++++++++ 9 files changed, 430 insertions(+), 13 deletions(-) create mode 100644 lib/data/theme_plugin.dart diff --git a/lib/data/hub.dart b/lib/data/hub.dart index a0a7567..0eae23e 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -420,6 +420,27 @@ class HubService { return (brightness: r.brightness, tokens: r.tokens.toList()); } + /// Invoke an installed Studio translate plugin and return + /// the translated text. Empty `fromLocale` lets the plugin + /// auto-detect; `toLocale` is the active Studio locale. + /// + /// Caller catches the gRPC exception and routes it through + /// `friendlyError` like every other RPC. + Future invokePluginTranslate({ + required String capability, + required String text, + required String toLocale, + String fromLocale = '', + }) async { + final r = await _client.invokePluginTranslate( + capability: capability, + text: text, + toLocale: toLocale, + fromLocale: fromLocale, + ); + return r.translated; + } + /// Snapshot of every configured MCP server. Future> listMcpClients() async { final r = await _client.listMcpClients(); diff --git a/lib/data/theme_plugin.dart b/lib/data/theme_plugin.dart new file mode 100644 index 0000000..326d3fe --- /dev/null +++ b/lib/data/theme_plugin.dart @@ -0,0 +1,152 @@ +// Studio theme-plugin helpers: discovery, loading, persistence. +// +// `studio.theme.*` capabilities returned by the hub's +// `list_capabilities` are eligible theme plugins. Each one +// exposes a `theme` hook that returns a ColorScheme for a +// given brightness. This module wraps that into the shape +// Flutter's MaterialApp expects: a pair of `ThemeData` +// (light + dark) built from the plugin's tokens, ready to +// drop into the running app. + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'hub.dart'; + +/// SharedPreferences key for the operator's preferred theme +/// plugin. `null` (or absent) means "use Studio's built-in +/// default" (FaiTheme.light / .dark). +const _kThemePluginKey = 'theme_plugin_capability'; + +/// One ColorScheme pair as built from a theme-plugin's +/// `theme_for("light")` + `theme_for("dark")` round-trips. +/// `null` slots are intentional — when the plugin declines +/// one brightness the caller falls back to its built-in +/// scheme for that side and uses the plugin's only for the +/// other. +class ThemePluginSchemes { + final ColorScheme? light; + final ColorScheme? dark; + const ThemePluginSchemes({this.light, this.dark}); + + bool get isEmpty => light == null && dark == null; +} + +/// List every installed `studio.theme.*` capability the hub +/// is currently able to dispatch. Returns an empty list when +/// no theme plugin is installed (typical fresh hub). +Future> listThemePluginCapabilities() async { + final caps = await HubService.instance.allCapabilities(); + return caps + .where((c) => + c.kind == 'wasm' && c.capability.startsWith('studio.theme.')) + .map((c) => c.capability) + .toList() + ..sort(); +} + +/// Read the operator's preferred theme-plugin capability from +/// SharedPreferences. Returns `null` when none chosen. +Future loadActiveThemePlugin() async { + final prefs = await SharedPreferences.getInstance(); + final v = prefs.getString(_kThemePluginKey); + if (v == null || v.isEmpty) return null; + return v; +} + +/// Persist the operator's theme-plugin choice. Pass `null` to +/// clear it (back to the built-in default). +Future saveActiveThemePlugin(String? capability) async { + final prefs = await SharedPreferences.getInstance(); + if (capability == null || capability.isEmpty) { + await prefs.remove(_kThemePluginKey); + } else { + await prefs.setString(_kThemePluginKey, capability); + } +} + +/// Invoke the plugin twice (light + dark) and translate its +/// 14 ARGB tokens into a `ColorScheme` per brightness. Plugin +/// errors are swallowed per brightness — a plugin that +/// declines `dark` but accepts `light` still contributes the +/// light scheme; the caller falls back to the built-in dark. +Future loadThemePluginSchemes(String capability) async { + ColorScheme? light; + ColorScheme? dark; + try { + final r = await HubService.instance.invokePluginTheme( + capability: capability, + brightness: 'light', + ); + light = _tokensToColorScheme(r.tokens, Brightness.light); + } catch (_) { + // Declined / misconfigured / unreachable. Fall through — + // built-in light scheme will fill the slot. + } + try { + final r = await HubService.instance.invokePluginTheme( + capability: capability, + brightness: 'dark', + ); + dark = _tokensToColorScheme(r.tokens, Brightness.dark); + } catch (_) {} + return ThemePluginSchemes(light: light, dark: dark); +} + +/// Build a `ThemeData` from a plugin-supplied `ColorScheme`, +/// keeping Studio's typography (Inter for UI, JetBrains Mono +/// for code) so a theme swap doesn't reflow text metrics. +/// Mirrors what `FaiTheme.light()` / `.dark()` do internally +/// but with the operator-chosen palette. +ThemeData themeDataFromScheme(ColorScheme scheme) { + return ThemeData( + useMaterial3: true, + colorScheme: scheme, + scaffoldBackgroundColor: scheme.surface, + canvasColor: scheme.surface, + cardColor: scheme.surfaceContainer, + dividerColor: scheme.outlineVariant, + textTheme: GoogleFonts.interTextTheme( + scheme.brightness == Brightness.dark + ? Typography.whiteCupertino + : Typography.blackCupertino, + ), + ); +} + +/// Translate the plugin's 14 ARGB tokens into a `ColorScheme`. +/// Order matches the plugin contract: +/// primary, on-primary, secondary, on-secondary, +/// tertiary, on-tertiary, error, on-error, +/// surface, on-surface, surface-variant, on-surface-variant, +/// outline, outline-variant. +/// Length mismatches are tolerated — missing slots fall back +/// to a sensible Material 3 baseline so a half-built plugin +/// doesn't crash the picker. +ColorScheme _tokensToColorScheme(List tokens, Brightness brightness) { + Color slot(int i) { + if (i < tokens.length) return Color(tokens[i]); + // Reasonable fallback when the plugin underdelivered. + return brightness == Brightness.dark + ? const Color(0xFF1A1A1A) + : const Color(0xFFEEEEEE); + } + return ColorScheme( + brightness: brightness, + primary: slot(0), + onPrimary: slot(1), + secondary: slot(2), + onSecondary: slot(3), + tertiary: slot(4), + onTertiary: slot(5), + error: slot(6), + onError: slot(7), + surface: slot(8), + onSurface: slot(9), + surfaceContainer: slot(10), + onSurfaceVariant: slot(11), + outline: slot(12), + outlineVariant: slot(13), + ); +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index ad60a1a..a83d55e 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -95,6 +95,11 @@ "mcpSuggestionTimeDesc": "Anthropic — aktuelle Uhrzeit + Zeitzonen-Konvertierung.", "mcpServerEnLanguageBadge": "Server-Text in Englisch — Studio zeigt ihn wie geliefert.", + "themePluginHeader": "Theme-Plugin", + "themePluginNone": "Standard (kein Plugin)", + "themePluginHint": "Installiertes studio.theme.*-Plugin auswählen, um das ganze Studio umzustylen. Erst eines aus dem Store installieren.", + "themePluginEmpty": "Kein Theme-Plugin installiert. Im Store eines aus der Kategorie studio-plugin auswählen.", + "settingsTitle": "Hub-Endpunkt", "settingsHost": "Host", "settingsPort": "Port", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b5078ab..6fce839 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -96,6 +96,11 @@ "mcpSuggestionTimeDesc": "Anthropic — current time + timezone conversion.", "mcpServerEnLanguageBadge": "Server text in English — Studio shows it as supplied.", + "themePluginHeader": "Theme plugin", + "themePluginNone": "Built-in (no plugin)", + "themePluginHint": "Pick an installed studio.theme.* plugin to re-skin the whole app. Install one from the Store first.", + "themePluginEmpty": "No theme plugins installed. Open the Store and pick one in the studio-plugin category.", + "settingsTitle": "Hub endpoint", "settingsHost": "Host", "settingsPort": "Port", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 9715977..3a9abc9 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -620,6 +620,30 @@ abstract class AppLocalizations { /// **'Server text in English — Studio shows it as supplied.'** String get mcpServerEnLanguageBadge; + /// No description provided for @themePluginHeader. + /// + /// In en, this message translates to: + /// **'Theme plugin'** + String get themePluginHeader; + + /// No description provided for @themePluginNone. + /// + /// In en, this message translates to: + /// **'Built-in (no plugin)'** + String get themePluginNone; + + /// No description provided for @themePluginHint. + /// + /// In en, this message translates to: + /// **'Pick an installed studio.theme.* plugin to re-skin the whole app. Install one from the Store first.'** + String get themePluginHint; + + /// No description provided for @themePluginEmpty. + /// + /// In en, this message translates to: + /// **'No theme plugins installed. Open the Store and pick one in the studio-plugin category.'** + String get themePluginEmpty; + /// No description provided for @settingsTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 7701ca1..4ed5cbe 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -302,6 +302,20 @@ class AppLocalizationsDe extends AppLocalizations { String get mcpServerEnLanguageBadge => 'Server-Text in Englisch — Studio zeigt ihn wie geliefert.'; + @override + String get themePluginHeader => 'Theme-Plugin'; + + @override + String get themePluginNone => 'Standard (kein Plugin)'; + + @override + String get themePluginHint => + 'Installiertes studio.theme.*-Plugin auswählen, um das ganze Studio umzustylen. Erst eines aus dem Store installieren.'; + + @override + String get themePluginEmpty => + 'Kein Theme-Plugin installiert. Im Store eines aus der Kategorie studio-plugin auswählen.'; + @override String get settingsTitle => 'Hub-Endpunkt'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index f045bd3..3a59368 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -302,6 +302,20 @@ class AppLocalizationsEn extends AppLocalizations { String get mcpServerEnLanguageBadge => 'Server text in English — Studio shows it as supplied.'; + @override + String get themePluginHeader => 'Theme plugin'; + + @override + String get themePluginNone => 'Built-in (no plugin)'; + + @override + String get themePluginHint => + 'Pick an installed studio.theme.* plugin to re-skin the whole app. Install one from the Store first.'; + + @override + String get themePluginEmpty => + 'No theme plugins installed. Open the Store and pick one in the studio-plugin category.'; + @override String get settingsTitle => 'Hub endpoint'; diff --git a/lib/main.dart b/lib/main.dart index 90ea0a5..e9629be 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 'data/theme_plugin.dart'; import 'l10n/app_localizations.dart'; import 'pages/approvals.dart'; import 'pages/audit.dart'; @@ -30,22 +31,33 @@ const String kStudioVersion = '0.42.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - // Restore the persisted endpoint, theme mode, and locale - // before the first frame so none of them flicker on startup. + // Restore the persisted endpoint, theme mode, locale, and + // (when installed) the operator's theme-plugin choice + // before the first frame so nothing flickers on startup. await HubService.instance.loadPersistedEndpoint(); final themeMode = await HubService.instance.loadThemeMode(); final locale = await HubService.instance.loadLocale(); - runApp(StudioApp(initialThemeMode: themeMode, initialLocale: locale)); + final themePlugin = await loadActiveThemePlugin(); + runApp(StudioApp( + initialThemeMode: themeMode, + initialLocale: locale, + initialThemePlugin: themePlugin, + )); } class StudioApp extends StatefulWidget { final ThemeModeValue initialThemeMode; final Locale initialLocale; + /// Capability name of the active theme plugin (e.g. + /// `studio.theme.solarized`) or `null` for the built-in + /// FaiTheme. + final String? initialThemePlugin; const StudioApp({ super.key, required this.initialThemeMode, required this.initialLocale, + this.initialThemePlugin, }); /// Lookup helper so descendants can flip the theme without @@ -68,6 +80,12 @@ class StudioAppState extends State { /// this, MaterialApp re-renders with the new /// AppLocalizations, and every translated string updates. late final ValueNotifier localeNotifier; + /// Capability name of the active `studio.theme.*` plugin + /// (e.g. `studio.theme.solarized`) or `null` for + /// FaiTheme's built-in palette. Flipping this triggers a + /// re-fetch of the plugin's ColorSchemes; the MaterialApp + /// rebuilds with the new themes. + late final ValueNotifier themePluginNotifier; Future setMode(ThemeModeValue m) async { modeNotifier.value = m; @@ -79,17 +97,24 @@ class StudioAppState extends State { await HubService.instance.saveLocale(l); } + Future setThemePlugin(String? capability) async { + themePluginNotifier.value = capability; + await saveActiveThemePlugin(capability); + } + @override void initState() { super.initState(); modeNotifier = ValueNotifier(widget.initialThemeMode); localeNotifier = ValueNotifier(widget.initialLocale); + themePluginNotifier = ValueNotifier(widget.initialThemePlugin); } @override void dispose() { modeNotifier.dispose(); localeNotifier.dispose(); + themePluginNotifier.dispose(); super.dispose(); } @@ -104,22 +129,59 @@ class StudioAppState extends State { } } + /// Build (or return null) ThemeData pair for the active + /// theme plugin. Returns immediately with `null` when no + /// plugin is selected; otherwise awaits the plugin's + /// `theme_for` round-trip for both brightnesses. + Future<({ThemeData? light, ThemeData? dark})> _pluginThemes( + String? capability, + ) async { + if (capability == null || capability.isEmpty) { + return (light: null, dark: null); + } + try { + final schemes = await loadThemePluginSchemes(capability); + return ( + light: schemes.light == null ? null : themeDataFromScheme(schemes.light!), + dark: schemes.dark == null ? null : themeDataFromScheme(schemes.dark!), + ); + } catch (_) { + // Plugin unreachable, hub down, etc. Fall back to + // FaiTheme defaults silently — the Settings dialog is + // where the operator surfaces / fixes the problem. + return (light: null, dark: null); + } + } + @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: modeNotifier, 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(), + builder: (_, locale, _) => ValueListenableBuilder( + valueListenable: themePluginNotifier, + builder: (_, pluginCap, _) => FutureBuilder<({ThemeData? light, ThemeData? dark})>( + // Re-key on the capability so a plugin change + // discards the in-flight future for the previous + // plugin and starts fresh. + key: ValueKey('plugin_themes:$pluginCap'), + future: _pluginThemes(pluginCap), + builder: (context, snap) { + final p = snap.data ?? (light: null, dark: null); + return MaterialApp( + title: 'F∆I Studio', + debugShowCheckedModeBanner: false, + theme: p.light ?? FaiTheme.light(), + darkTheme: p.dark ?? FaiTheme.dark(), + themeMode: _flutterMode(mode), + locale: locale, + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: const StudioShell(), + ); + }, + ), ), ), ); diff --git a/lib/widgets/fai_settings_dialog.dart b/lib/widgets/fai_settings_dialog.dart index 9d007d6..4a92502 100644 --- a/lib/widgets/fai_settings_dialog.dart +++ b/lib/widgets/fai_settings_dialog.dart @@ -8,6 +8,8 @@ import 'package:flutter/material.dart'; import '../data/hub.dart'; import '../data/registry_token.dart'; import '../data/system_actions.dart'; +import '../data/theme_plugin.dart'; +import '../main.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; @@ -510,6 +512,8 @@ class _FaiSettingsDialogState extends State { onClear: _clearRegistryToken, ), const SizedBox(height: FaiSpace.lg), + const _ThemePluginPanel(), + const SizedBox(height: FaiSpace.lg), _MaintenancePanel( onResetDone: () async { // Daemon just restarted under the same channel + @@ -1456,6 +1460,122 @@ class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> { /// metadata; nothing is auto-spawned. Trust model: /// referencing public projects with their canonical install /// command is the same legal / security shape as a package +/// Theme-plugin picker. Lists installed `studio.theme.*` +/// capabilities (via list_capabilities) and lets the operator +/// pick one or fall back to Studio's built-in palette. The +/// choice is persisted in SharedPreferences and applied at +/// the MaterialApp level via `StudioApp.of(context).setThemePlugin`. +/// +/// When no theme plugin is installed the panel renders a +/// short hint pointing at the Store rather than a useless +/// empty dropdown. +class _ThemePluginPanel extends StatefulWidget { + const _ThemePluginPanel(); + + @override + State<_ThemePluginPanel> createState() => _ThemePluginPanelState(); +} + +class _ThemePluginPanelState extends State<_ThemePluginPanel> { + Future>? _capsFuture; + + @override + void initState() { + super.initState(); + _capsFuture = listThemePluginCapabilities(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + final app = StudioApp.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l.themePluginHeader, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, + fontSize: 10, + ), + ), + const SizedBox(height: 4), + FutureBuilder>( + future: _capsFuture, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: FaiSpace.sm), + child: SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + final caps = snap.data ?? const []; + if (caps.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + l.themePluginEmpty, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ); + } + // Dropdown shows "Built-in" + every installed + // studio.theme.* cap. Selection writes through + // StudioApp's notifier so the MaterialApp rebuilds + // with the new ColorScheme without a restart. + final active = app?.themePluginNotifier.value; + final items = >[ + DropdownMenuItem( + value: null, + child: Text(l.themePluginNone), + ), + for (final cap in caps) + DropdownMenuItem( + value: cap, + child: Text(cap, style: FaiTheme.mono(size: 12)), + ), + ]; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButton( + value: active, + items: items, + isDense: true, + onChanged: app == null + ? null + : (v) { + app.setThemePlugin(v); + setState(() {}); + }, + ), + const SizedBox(height: 4), + Text( + l.themePluginHint, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + }, + ), + ], + ); + } +} + /// Hub maintenance panel — currently one action: reset operator /// state via `fai reset --yes`. Lives at the bottom of Settings /// so the everyday config controls aren't crowded by a destructive