feat(studio): theme-plugin picker + translate-hook wrapper
Some checks failed
Security / Security check (push) Failing after 2s
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:
parent
a5bcde6446
commit
91706cea2b
9 changed files with 430 additions and 13 deletions
|
|
@ -420,6 +420,27 @@ class HubService {
|
||||||
return (brightness: r.brightness, tokens: r.tokens.toList());
|
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.
|
/// Snapshot of every configured MCP server.
|
||||||
Future<List<McpClientInfo>> listMcpClients() async {
|
Future<List<McpClientInfo>> listMcpClients() async {
|
||||||
final r = await _client.listMcpClients();
|
final r = await _client.listMcpClients();
|
||||||
|
|
|
||||||
152
lib/data/theme_plugin.dart
Normal file
152
lib/data/theme_plugin.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -95,6 +95,11 @@
|
||||||
"mcpSuggestionTimeDesc": "Anthropic — aktuelle Uhrzeit + Zeitzonen-Konvertierung.",
|
"mcpSuggestionTimeDesc": "Anthropic — aktuelle Uhrzeit + Zeitzonen-Konvertierung.",
|
||||||
"mcpServerEnLanguageBadge": "Server-Text in Englisch — Studio zeigt ihn wie geliefert.",
|
"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",
|
"settingsTitle": "Hub-Endpunkt",
|
||||||
"settingsHost": "Host",
|
"settingsHost": "Host",
|
||||||
"settingsPort": "Port",
|
"settingsPort": "Port",
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,11 @@
|
||||||
"mcpSuggestionTimeDesc": "Anthropic — current time + timezone conversion.",
|
"mcpSuggestionTimeDesc": "Anthropic — current time + timezone conversion.",
|
||||||
"mcpServerEnLanguageBadge": "Server text in English — Studio shows it as supplied.",
|
"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",
|
"settingsTitle": "Hub endpoint",
|
||||||
"settingsHost": "Host",
|
"settingsHost": "Host",
|
||||||
"settingsPort": "Port",
|
"settingsPort": "Port",
|
||||||
|
|
|
||||||
|
|
@ -620,6 +620,30 @@ abstract class AppLocalizations {
|
||||||
/// **'Server text in English — Studio shows it as supplied.'**
|
/// **'Server text in English — Studio shows it as supplied.'**
|
||||||
String get mcpServerEnLanguageBadge;
|
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.
|
/// No description provided for @settingsTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -302,6 +302,20 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get mcpServerEnLanguageBadge =>
|
String get mcpServerEnLanguageBadge =>
|
||||||
'Server-Text in Englisch — Studio zeigt ihn wie geliefert.';
|
'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
|
@override
|
||||||
String get settingsTitle => 'Hub-Endpunkt';
|
String get settingsTitle => 'Hub-Endpunkt';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -302,6 +302,20 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get mcpServerEnLanguageBadge =>
|
String get mcpServerEnLanguageBadge =>
|
||||||
'Server text in English — Studio shows it as supplied.';
|
'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
|
@override
|
||||||
String get settingsTitle => 'Hub endpoint';
|
String get settingsTitle => 'Hub endpoint';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import 'data/hub.dart';
|
import 'data/hub.dart';
|
||||||
import 'data/system_actions.dart';
|
import 'data/system_actions.dart';
|
||||||
|
import 'data/theme_plugin.dart';
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
import 'pages/approvals.dart';
|
import 'pages/approvals.dart';
|
||||||
import 'pages/audit.dart';
|
import 'pages/audit.dart';
|
||||||
|
|
@ -30,22 +31,33 @@ const String kStudioVersion = '0.42.0';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
// Restore the persisted endpoint, theme mode, and locale
|
// Restore the persisted endpoint, theme mode, locale, and
|
||||||
// before the first frame so none of them flicker on startup.
|
// (when installed) the operator's theme-plugin choice
|
||||||
|
// before the first frame so nothing flickers on startup.
|
||||||
await HubService.instance.loadPersistedEndpoint();
|
await HubService.instance.loadPersistedEndpoint();
|
||||||
final themeMode = await HubService.instance.loadThemeMode();
|
final themeMode = await HubService.instance.loadThemeMode();
|
||||||
final locale = await HubService.instance.loadLocale();
|
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 {
|
class StudioApp extends StatefulWidget {
|
||||||
final ThemeModeValue initialThemeMode;
|
final ThemeModeValue initialThemeMode;
|
||||||
final Locale initialLocale;
|
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({
|
const StudioApp({
|
||||||
super.key,
|
super.key,
|
||||||
required this.initialThemeMode,
|
required this.initialThemeMode,
|
||||||
required this.initialLocale,
|
required this.initialLocale,
|
||||||
|
this.initialThemePlugin,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Lookup helper so descendants can flip the theme without
|
/// 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
|
/// this, MaterialApp re-renders with the new
|
||||||
/// AppLocalizations, and every translated string updates.
|
/// AppLocalizations, and every translated string updates.
|
||||||
late final ValueNotifier<Locale> localeNotifier;
|
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 {
|
Future<void> setMode(ThemeModeValue m) async {
|
||||||
modeNotifier.value = m;
|
modeNotifier.value = m;
|
||||||
|
|
@ -79,17 +97,24 @@ class StudioAppState extends State<StudioApp> {
|
||||||
await HubService.instance.saveLocale(l);
|
await HubService.instance.saveLocale(l);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setThemePlugin(String? capability) async {
|
||||||
|
themePluginNotifier.value = capability;
|
||||||
|
await saveActiveThemePlugin(capability);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
modeNotifier = ValueNotifier(widget.initialThemeMode);
|
modeNotifier = ValueNotifier(widget.initialThemeMode);
|
||||||
localeNotifier = ValueNotifier(widget.initialLocale);
|
localeNotifier = ValueNotifier(widget.initialLocale);
|
||||||
|
themePluginNotifier = ValueNotifier(widget.initialThemePlugin);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
modeNotifier.dispose();
|
modeNotifier.dispose();
|
||||||
localeNotifier.dispose();
|
localeNotifier.dispose();
|
||||||
|
themePluginNotifier.dispose();
|
||||||
super.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ValueListenableBuilder<ThemeModeValue>(
|
return ValueListenableBuilder<ThemeModeValue>(
|
||||||
valueListenable: modeNotifier,
|
valueListenable: modeNotifier,
|
||||||
builder: (_, mode, _) => ValueListenableBuilder<Locale>(
|
builder: (_, mode, _) => ValueListenableBuilder<Locale>(
|
||||||
valueListenable: localeNotifier,
|
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',
|
title: 'F∆I Studio',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: FaiTheme.light(),
|
theme: p.light ?? FaiTheme.light(),
|
||||||
darkTheme: FaiTheme.dark(),
|
darkTheme: p.dark ?? FaiTheme.dark(),
|
||||||
themeMode: _flutterMode(mode),
|
themeMode: _flutterMode(mode),
|
||||||
locale: locale,
|
locale: locale,
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
home: const StudioShell(),
|
home: const StudioShell(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import 'package:flutter/material.dart';
|
||||||
import '../data/hub.dart';
|
import '../data/hub.dart';
|
||||||
import '../data/registry_token.dart';
|
import '../data/registry_token.dart';
|
||||||
import '../data/system_actions.dart';
|
import '../data/system_actions.dart';
|
||||||
|
import '../data/theme_plugin.dart';
|
||||||
|
import '../main.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../theme/theme.dart';
|
import '../theme/theme.dart';
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
|
|
@ -510,6 +512,8 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
onClear: _clearRegistryToken,
|
onClear: _clearRegistryToken,
|
||||||
),
|
),
|
||||||
const SizedBox(height: FaiSpace.lg),
|
const SizedBox(height: FaiSpace.lg),
|
||||||
|
const _ThemePluginPanel(),
|
||||||
|
const SizedBox(height: FaiSpace.lg),
|
||||||
_MaintenancePanel(
|
_MaintenancePanel(
|
||||||
onResetDone: () async {
|
onResetDone: () async {
|
||||||
// Daemon just restarted under the same channel +
|
// Daemon just restarted under the same channel +
|
||||||
|
|
@ -1456,6 +1460,122 @@ class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> {
|
||||||
/// metadata; nothing is auto-spawned. Trust model:
|
/// metadata; nothing is auto-spawned. Trust model:
|
||||||
/// referencing public projects with their canonical install
|
/// referencing public projects with their canonical install
|
||||||
/// command is the same legal / security shape as a package
|
/// 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
|
/// Hub maintenance panel — currently one action: reset operator
|
||||||
/// state via `fai reset --yes`. Lives at the bottom of Settings
|
/// state via `fai reset --yes`. Lives at the bottom of Settings
|
||||||
/// so the everyday config controls aren't crowded by a destructive
|
/// so the everyday config controls aren't crowded by a destructive
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue