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),
);
}