feat(studio): rich theme picker (presets + custom colour) + editor 0.11.0
Some checks failed
Security / Security check (push) Failing after 1s
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:
parent
69864da934
commit
c1c60d5434
30 changed files with 1280 additions and 846 deletions
178
lib/main.dart
178
lib/main.dart
|
|
@ -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())),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue