feat(studio): theme-plugin picker + translate-hook wrapper
Some checks failed
Security / Security check (push) Failing after 2s

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 <sf@flemming.it>
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-25 23:02:21 +02:00
parent a5bcde6446
commit 91706cea2b
9 changed files with 430 additions and 13 deletions

View file

@ -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<String> 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<List<McpClientInfo>> listMcpClients() async {
final r = await _client.listMcpClients();

152
lib/data/theme_plugin.dart Normal file
View file

@ -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<List<String>> 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<String?> 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<void> 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<ThemePluginSchemes> 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<int> 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),
);
}

View file

@ -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",

View file

@ -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",

View file

@ -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:

View file

@ -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';

View file

@ -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';

View file

@ -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<void> 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<StudioApp> {
/// this, MaterialApp re-renders with the new
/// AppLocalizations, and every translated string updates.
late final ValueNotifier<Locale> 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<String?> themePluginNotifier;
Future<void> setMode(ThemeModeValue m) async {
modeNotifier.value = m;
@ -79,17 +97,24 @@ class StudioAppState extends State<StudioApp> {
await HubService.instance.saveLocale(l);
}
Future<void> 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<StudioApp> {
}
}
/// 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<ThemeModeValue>(
valueListenable: modeNotifier,
builder: (_, mode, _) => ValueListenableBuilder<Locale>(
valueListenable: localeNotifier,
builder: (_, locale, _) => MaterialApp(
builder: (_, locale, _) => ValueListenableBuilder<String?>(
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: FaiTheme.light(),
darkTheme: FaiTheme.dark(),
theme: p.light ?? FaiTheme.light(),
darkTheme: p.dark ?? FaiTheme.dark(),
themeMode: _flutterMode(mode),
locale: locale,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: const StudioShell(),
);
},
),
),
),
);

View file

@ -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<FaiSettingsDialog> {
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<List<String>>? _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<List<String>>(
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 <String>[];
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<String?>>[
DropdownMenuItem<String?>(
value: null,
child: Text(l.themePluginNone),
),
for (final cap in caps)
DropdownMenuItem<String?>(
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<String?>(
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