feat(studio): rich theme picker (presets + custom colour) + editor 0.11.0
Some checks failed
Security / Security check (push) Failing after 1s

Settings dialog's Theme Plugin section is now a grid of
swatched tiles:

- Built-in (none) — falls back to FaiTheme.light/.dark
- One tile per installed studio.theme.* plugin, each
  showing the plugin's primary/secondary/tertiary as
  live colour dots. Tile loads its preview lazily so a
  dozen installed themes don't block the picker.
- Custom — opens a colour-picker dialog with 12 curated
  Material presets + a hex input + live preview. Selecting
  applies ColorScheme.fromSeed for both brightnesses.

main.dart's _pluginThemes parses a 'custom:#RRGGBB' sigil
in the same notifier slot as plugin capability ids, so the
existing persistence + restoration paths cover the custom
case with no new state.

Bumps editor to 0.11.0 (type-checked port connections +
dynamic card width fix + card-height border allowance) and
Studio to 0.58.0.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-06-02 01:29:34 +02:00
parent 69864da934
commit c1c60d5434
30 changed files with 1280 additions and 846 deletions

View file

@ -37,16 +37,19 @@ Future<void> main() async {
final themeMode = await HubService.instance.loadThemeMode();
final locale = await HubService.instance.loadLocale();
final themePlugin = await loadActiveThemePlugin();
runApp(StudioApp(
initialThemeMode: themeMode,
initialLocale: locale,
initialThemePlugin: themePlugin,
));
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.
@ -75,10 +78,12 @@ class StudioAppState extends State<StudioApp> {
/// `MaterialApp(home: const StudioShell())` because the
/// const child's element doesn't get a `didUpdateWidget`.
late final ValueNotifier<ThemeModeValue> modeNotifier;
/// Active app locale. The sidebar's language toggle flips
/// 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
@ -132,16 +137,45 @@ class StudioAppState extends State<StudioApp> {
/// theme plugin. Returns immediately with `null` when no
/// plugin is selected; otherwise awaits the plugin's
/// `theme_for` round-trip for both brightnesses.
///
/// The capability slot also carries the `custom:#RRGGBB`
/// sigil for free-color themes: when the value starts with
/// `custom:`, the suffix is parsed as a hex seed and the
/// ColorScheme is derived via Material 3's seed algorithm.
/// That single channel covers both "pick from installed
/// plugins" and "pick a seed colour" without a parallel
/// notifier.
Future<({ThemeData? light, ThemeData? dark})> _pluginThemes(
String? capability,
) async {
if (capability == null || capability.isEmpty) {
return (light: null, dark: null);
}
if (capability.startsWith('custom:')) {
final hex = capability.substring('custom:'.length).replaceAll('#', '');
if (hex.length != 6) return (light: null, dark: null);
final raw = int.tryParse(hex, radix: 16);
if (raw == null) return (light: null, dark: null);
final seed = Color(0xFF000000 | raw);
final light = ColorScheme.fromSeed(
seedColor: seed,
brightness: Brightness.light,
);
final dark = ColorScheme.fromSeed(
seedColor: seed,
brightness: Brightness.dark,
);
return (
light: themeDataFromScheme(light),
dark: themeDataFromScheme(dark),
);
}
try {
final schemes = await loadThemePluginSchemes(capability);
return (
light: schemes.light == null ? null : themeDataFromScheme(schemes.light!),
light: schemes.light == null
? null
: themeDataFromScheme(schemes.light!),
dark: schemes.dark == null ? null : themeDataFromScheme(schemes.dark!),
);
} catch (_) {
@ -160,27 +194,29 @@ class StudioAppState extends State<StudioApp> {
valueListenable: localeNotifier,
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: p.light ?? FaiTheme.light(),
darkTheme: p.dark ?? FaiTheme.dark(),
themeMode: _flutterMode(mode),
locale: locale,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: const StudioShell(),
);
},
),
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: p.light ?? FaiTheme.light(),
darkTheme: p.dark ?? FaiTheme.dark(),
themeMode: _flutterMode(mode),
locale: locale,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates:
AppLocalizations.localizationsDelegates,
home: const StudioShell(),
);
},
),
),
),
);
@ -328,7 +364,9 @@ class _StudioShellState extends State<StudioShell> {
SingleActivator(
LogicalKeyboardKey(LogicalKeyboardKey.digit1.keyId + i),
meta: true,
): _GoToPageIntent(i),
): _GoToPageIntent(
i,
),
// Settings dialog. macOS reserves Cmd+, for the native
// app-Preferences menu item, which Flutter Desktop
// doesn't wire up — the keystroke is captured by the OS
@ -381,25 +419,22 @@ class _StudioShellState extends State<StudioShell> {
endpointLabel: HubService.instance.endpointLabel,
activeChannel: _activeChannel,
),
Container(
width: 1,
color: theme.colorScheme.outlineVariant,
),
Container(width: 1, color: theme.colorScheme.outlineVariant),
Expanded(
child: AnimatedSwitcher(
duration: FaiMotion.base,
switchInCurve: FaiMotion.easing,
switchOutCurve: FaiMotion.easing,
transitionBuilder: (child, anim) => FadeTransition(
opacity: anim,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.02),
end: Offset.zero,
).animate(anim),
child: child,
),
),
duration: FaiMotion.base,
switchInCurve: FaiMotion.easing,
switchOutCurve: FaiMotion.easing,
transitionBuilder: (child, anim) => FadeTransition(
opacity: anim,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.02),
end: Offset.zero,
).animate(anim),
child: child,
),
),
child: KeyedSubtree(
key: ValueKey(_selectedIndex),
child: _pages[_selectedIndex].page,
@ -435,6 +470,7 @@ class _Sidebar extends StatefulWidget {
final List<_NavPage> pages;
final bool? connected;
final String endpointLabel;
/// Active channel name from the running daemon
/// (`local`/`dev`/`beta`/`production`). Null while the
/// initial fetch is in flight or the hub is unreachable.
@ -506,7 +542,9 @@ class _SidebarState extends State<_Sidebar>
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final mode = widget.connected == true ? FaiDeltaMode.live : FaiDeltaMode.idle;
final mode = widget.connected == true
? FaiDeltaMode.live
: FaiDeltaMode.idle;
final hasChannel =
widget.activeChannel != null && widget.activeChannel!.isNotEmpty;
return MouseRegion(
@ -516,7 +554,8 @@ class _SidebarState extends State<_Sidebar>
animation: _ctrl,
builder: (context, _) {
final t = Curves.easeInOutCubic.transform(_ctrl.value);
final width = _collapsedWidth + (_expandedWidth - _collapsedWidth) * t;
final width =
_collapsedWidth + (_expandedWidth - _collapsedWidth) * t;
final labelsInteractive = t > 0.5;
return Container(
width: width,
@ -551,8 +590,8 @@ class _SidebarState extends State<_Sidebar>
final caption = widget.connected == true
? l.connectionConnected
: widget.connected == false
? l.connectionTapToStart
: l.connectionConnecting;
? l.connectionTapToStart
: l.connectionConnecting;
return SizedBox(
height: _connRowH,
child: _sidebarRow(
@ -590,9 +629,7 @@ class _SidebarState extends State<_Sidebar>
channel: widget.activeChannel!,
),
),
label: _ChannelPill(
channel: widget.activeChannel!,
),
label: _ChannelPill(channel: widget.activeChannel!),
t: t,
labelsInteractive: labelsInteractive,
)
@ -789,8 +826,8 @@ class _CollapsedConnectionDot extends StatelessWidget {
final color = connected == true
? FaiColors.success
: connected == false
? theme.colorScheme.error
: FaiColors.warning;
? theme.colorScheme.error
: FaiColors.warning;
return Container(
width: 10,
height: 10,
@ -851,11 +888,7 @@ class _CollapsedChannelChip extends StatelessWidget {
alignment: Alignment.center,
child: Text(
_letter,
style: FaiTheme.mono(
size: 10,
weight: FontWeight.w700,
color: accent,
),
style: FaiTheme.mono(size: 10, weight: FontWeight.w700, color: accent),
),
);
}
@ -915,8 +948,8 @@ class _ConnectionLabel extends StatelessWidget {
final caption = connected == true
? l.connectionConnected
: connected == false
? l.connectionTapToStart
: l.connectionConnecting;
? l.connectionTapToStart
: l.connectionConnecting;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
@ -948,6 +981,7 @@ class _ConnectionLabel extends StatelessWidget {
class _SidebarItem extends StatefulWidget {
final _NavPage page;
final bool selected;
/// 0 = fully collapsed, 1 = fully expanded. Drives label
/// opacity in lockstep with the parent rail's width
/// animation there is no second timer for the label, so
@ -979,13 +1013,13 @@ class _SidebarItemState extends State<_SidebarItem> {
final color = widget.selected
? theme.colorScheme.primary
: _hovered
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant;
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant;
final bg = widget.selected
? theme.colorScheme.primary.withValues(alpha: 0.08)
: _hovered
? theme.colorScheme.surfaceContainerHigh
: Colors.transparent;
? theme.colorScheme.surfaceContainerHigh
: Colors.transparent;
final label = widget.page.labelOf(context);
final icon = Icon(
@ -1017,7 +1051,9 @@ class _SidebarItemState extends State<_SidebarItem> {
label,
overflow: TextOverflow.fade,
softWrap: false,
style: theme.textTheme.labelMedium?.copyWith(color: color),
style: theme.textTheme.labelMedium?.copyWith(
color: color,
),
),
),
),
@ -1052,11 +1088,7 @@ class _SidebarItemState extends State<_SidebarItem> {
// Tooltip only while the label is hidden once the label
// is visible inline the tooltip becomes redundant noise.
if (widget.t > 0.5) return inner;
return Tooltip(
message: label,
preferBelow: false,
child: inner,
);
return Tooltip(message: label, preferBelow: false, child: inner);
}
}
@ -1107,9 +1139,7 @@ class _Footer extends StatelessWidget {
children: [
_ThemeToggle(),
_LanguageToggle(),
const Expanded(
child: Center(child: _SidebarClock()),
),
const Expanded(child: Center(child: _SidebarClock())),
],
),
),