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>
152 lines
5.3 KiB
Dart
152 lines
5.3 KiB
Dart
// 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),
|
|
);
|
|
}
|