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