Some checks failed
Security / Security check (push) Failing after 2s
Two operator-visible fixes prompted by 'I picked a theme but
nothing changed':
- Theme picker tile taps now show a brief "Theme applied:
<name>" snackbar — the picker applies themes instantly, so
the operator needs a confirming cue. Slow `_pluginThemes`
loads no longer read as "click did nothing".
- `_pluginThemes` failures (plugin unreachable, manifest
drift, etc.) write to `FaiLog` so `fai admin doctor` and
Studio's inline log viewer can surface them. Previously
swallowed silently, which is what made debugging this so
miserable.
- Settings dialog's primary button relabelled to
"Connect to endpoint" (was "Save & connect") so operators
don't mistake it for "save my theme choice". The theme
selection persists at tile-tap time; the Settings dialog
has nothing left to "save".
- Theme section header gains an explicit "(applies instantly)"
cue for the same reason.
Also fixes a pre-existing curly-braces lint in the n8n add-
endpoint dialog. Studio bumped to 0.62.1, editor pinned via
path override at 0.15.1.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
505 lines
16 KiB
Dart
505 lines
16 KiB
Dart
// Rich theme picker — grid of swatched theme tiles, plus a
|
|
// custom-seed-color tile that opens a small color picker
|
|
// dialog. Replaces the dropdown that used to live inside the
|
|
// Settings dialog's Theme section.
|
|
//
|
|
// The picker writes through StudioApp.setThemePlugin. Built-in
|
|
// = null. Installed plugin = its capability identifier
|
|
// (e.g. studio.theme.solarized). Custom = `custom:#RRGGBB`.
|
|
// See main.dart's _pluginThemes for the parsing side.
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../data/theme_plugin.dart';
|
|
import '../l10n/app_localizations.dart';
|
|
import '../main.dart';
|
|
import '../theme/tokens.dart';
|
|
|
|
class ThemePickerGrid extends StatefulWidget {
|
|
const ThemePickerGrid({super.key});
|
|
|
|
@override
|
|
State<ThemePickerGrid> createState() => _ThemePickerGridState();
|
|
}
|
|
|
|
class _ThemePickerGridState extends State<ThemePickerGrid> {
|
|
late Future<List<String>> _capsFuture;
|
|
// Per-capability cached schemes for the tile previews. We
|
|
// load them lazily once the list arrives so the grid
|
|
// doesn't block while every plugin's theme_for() round-
|
|
// trips. A null entry means "loaded, no scheme" — the
|
|
// tile falls back to a generic palette.
|
|
final Map<String, ThemePluginSchemes?> _previews = {};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_capsFuture = listThemePluginCapabilities();
|
|
}
|
|
|
|
Future<void> _ensurePreview(String cap) async {
|
|
if (_previews.containsKey(cap)) return;
|
|
_previews[cap] = null;
|
|
final schemes = await loadThemePluginSchemes(cap);
|
|
if (!mounted) return;
|
|
setState(() => _previews[cap] = schemes);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
final app = StudioApp.of(context);
|
|
return 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>[];
|
|
// Trigger preview loads for any plugin we haven't
|
|
// fetched yet — best-effort, fire-and-forget.
|
|
for (final cap in caps) {
|
|
if (!_previews.containsKey(cap)) {
|
|
// ignore: discarded_futures
|
|
_ensurePreview(cap);
|
|
}
|
|
}
|
|
final active = app?.themePluginNotifier.value;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Wrap(
|
|
spacing: 10,
|
|
runSpacing: 10,
|
|
children: [
|
|
_builtInTile(theme, l, active),
|
|
for (final cap in caps) _pluginTile(theme, cap, active),
|
|
_customTile(theme, l, active),
|
|
],
|
|
),
|
|
const SizedBox(height: FaiSpace.sm),
|
|
Text(
|
|
l.themePluginHint,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _builtInTile(ThemeData theme, AppLocalizations l, String? active) {
|
|
return _Tile(
|
|
label: l.themePluginNone,
|
|
swatches: [
|
|
theme.colorScheme.primary,
|
|
theme.colorScheme.secondary,
|
|
theme.colorScheme.tertiary,
|
|
],
|
|
selected: active == null,
|
|
onTap: () {
|
|
StudioApp.of(context)?.setThemePlugin(null);
|
|
_confirmApplied(l, l.themePluginNone);
|
|
setState(() {});
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Brief snackbar acknowledging the theme switch. The picker
|
|
/// applies themes instantly (no Save), so the operator needs
|
|
/// a visible cue that the click was registered — without it,
|
|
/// a slow theme load looks like "the click did nothing".
|
|
void _confirmApplied(AppLocalizations l, String displayName) {
|
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
|
if (messenger == null) return;
|
|
messenger.removeCurrentSnackBar();
|
|
messenger.showSnackBar(
|
|
SnackBar(
|
|
content: Text(l.themeApplied(displayName)),
|
|
duration: const Duration(seconds: 2),
|
|
behavior: SnackBarBehavior.floating,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _pluginTile(ThemeData theme, String cap, String? active) {
|
|
// Pretty name: studio.theme.sunflower → "Sunflower".
|
|
final display = _displayName(cap);
|
|
final preview = _previews[cap];
|
|
// Swatches come from the plugin's matching-brightness
|
|
// scheme so the tile literally previews what selecting
|
|
// it does. Fall back to the active theme's accents
|
|
// while the plugin's preview hasn't loaded yet.
|
|
final isDark = theme.brightness == Brightness.dark;
|
|
final scheme = isDark ? preview?.dark : preview?.light;
|
|
final swatches = scheme != null
|
|
? <Color>[scheme.primary, scheme.secondary, scheme.tertiary]
|
|
: <Color>[
|
|
theme.colorScheme.primary,
|
|
theme.colorScheme.secondary,
|
|
theme.colorScheme.tertiary,
|
|
];
|
|
return _Tile(
|
|
label: display,
|
|
swatches: swatches,
|
|
selected: active == cap,
|
|
loading: preview == null,
|
|
onTap: () {
|
|
StudioApp.of(context)?.setThemePlugin(cap);
|
|
_confirmApplied(AppLocalizations.of(context)!, display);
|
|
setState(() {});
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _customTile(ThemeData theme, AppLocalizations l, String? active) {
|
|
final isCustom = active != null && active.startsWith('custom:');
|
|
final seedHex = isCustom ? active.substring('custom:'.length) : null;
|
|
final seed = seedHex != null
|
|
? _parseHexColor(seedHex)
|
|
: theme.colorScheme.primary;
|
|
// Material 3 derives a full ColorScheme from one seed —
|
|
// primary, secondary, tertiary, surface, outline, on-*.
|
|
// The custom tile previews that full palette so it reads
|
|
// visually as a complete theme, NOT a single colour.
|
|
final scheme = ColorScheme.fromSeed(
|
|
seedColor: seed ?? theme.colorScheme.primary,
|
|
brightness: theme.brightness,
|
|
);
|
|
return _Tile(
|
|
label: l.themePluginCustom,
|
|
swatches: [scheme.primary, scheme.secondary, scheme.tertiary],
|
|
selected: isCustom,
|
|
// Slight visual cue that this tile is a different
|
|
// *source* of theme (seed-derived) compared to the
|
|
// plugin tiles (hand-curated palette). Same widget, the
|
|
// dashed border separates the concept.
|
|
editable: true,
|
|
onTap: () async {
|
|
final picked = await _showColorPickerDialog(
|
|
context,
|
|
initial: seed ?? theme.colorScheme.primary,
|
|
);
|
|
if (picked == null || !mounted) return;
|
|
final hex = picked.toARGB32().toRadixString(16).padLeft(8, '0');
|
|
// Strip alpha component — we only persist the RGB.
|
|
final rgb = hex.substring(2).toUpperCase();
|
|
StudioApp.of(context)?.setThemePlugin('custom:#$rgb');
|
|
_confirmApplied(l, '#$rgb');
|
|
setState(() {});
|
|
},
|
|
);
|
|
}
|
|
|
|
String _displayName(String cap) {
|
|
// studio.theme.glass-apple → "Glass Apple"
|
|
final last = cap.split('.').last;
|
|
return last
|
|
.split(RegExp(r'[-_]'))
|
|
.map((w) => w.isEmpty ? w : '${w[0].toUpperCase()}${w.substring(1)}')
|
|
.join(' ');
|
|
}
|
|
}
|
|
|
|
Color? _parseHexColor(String s) {
|
|
final cleaned = s.replaceAll('#', '');
|
|
if (cleaned.length != 6) return null;
|
|
final raw = int.tryParse(cleaned, radix: 16);
|
|
if (raw == null) return null;
|
|
return Color(0xFF000000 | raw);
|
|
}
|
|
|
|
class _Tile extends StatelessWidget {
|
|
final String label;
|
|
final List<Color> swatches;
|
|
final bool selected;
|
|
final bool loading;
|
|
|
|
/// Adds a small tune icon at the top-right, telegraphing
|
|
/// "this tile opens an editor instead of applying
|
|
/// immediately". Used for the Custom tile so the operator
|
|
/// knows clicking it will open the colour-picker dialog.
|
|
final bool editable;
|
|
final VoidCallback onTap;
|
|
const _Tile({
|
|
required this.label,
|
|
required this.swatches,
|
|
required this.selected,
|
|
required this.onTap,
|
|
this.loading = false,
|
|
this.editable = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return SizedBox(
|
|
width: 132,
|
|
height: 76,
|
|
child: Material(
|
|
color: theme.colorScheme.surfaceContainerHigh,
|
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
|
border: Border.all(
|
|
color: selected
|
|
? theme.colorScheme.primary
|
|
: theme.colorScheme.outlineVariant,
|
|
width: selected ? 2 : 1,
|
|
),
|
|
),
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
for (final c in swatches) ...[
|
|
Container(
|
|
width: 16,
|
|
height: 16,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: c,
|
|
border: Border.all(
|
|
color: theme.colorScheme.outline.withValues(
|
|
alpha: 0.4,
|
|
),
|
|
width: 0.5,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
],
|
|
if (loading)
|
|
const SizedBox(
|
|
width: 10,
|
|
height: 10,
|
|
child: CircularProgressIndicator(strokeWidth: 1.5),
|
|
),
|
|
],
|
|
),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
label,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
fontWeight: selected
|
|
? FontWeight.w600
|
|
: FontWeight.w500,
|
|
color: theme.colorScheme.onSurface,
|
|
),
|
|
),
|
|
),
|
|
if (editable && !selected)
|
|
Icon(
|
|
Icons.tune,
|
|
size: 14,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
if (selected)
|
|
Icon(
|
|
Icons.check_circle,
|
|
size: 14,
|
|
color: theme.colorScheme.primary,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<Color?> _showColorPickerDialog(
|
|
BuildContext context, {
|
|
required Color initial,
|
|
}) {
|
|
return showDialog<Color>(
|
|
context: context,
|
|
builder: (ctx) => _ColorPickerDialog(initial: initial),
|
|
);
|
|
}
|
|
|
|
class _ColorPickerDialog extends StatefulWidget {
|
|
final Color initial;
|
|
const _ColorPickerDialog({required this.initial});
|
|
@override
|
|
State<_ColorPickerDialog> createState() => _ColorPickerDialogState();
|
|
}
|
|
|
|
class _ColorPickerDialogState extends State<_ColorPickerDialog> {
|
|
late Color _color = widget.initial;
|
|
late final TextEditingController _hex = TextEditingController(
|
|
text: _hexOf(widget.initial),
|
|
);
|
|
|
|
/// Curated 12-colour palette — primary Material 3 hues at
|
|
/// the 500 step, picked so each is visually distinct + each
|
|
/// yields a usable ColorScheme.fromSeed. The operator can
|
|
/// override via the hex field below.
|
|
static const List<int> _presets = [
|
|
0xFF1976D2, // Blue
|
|
0xFF00ACC1, // Cyan
|
|
0xFF43A047, // Green
|
|
0xFFFFB300, // Amber
|
|
0xFFF4511E, // Deep Orange
|
|
0xFFE53935, // Red
|
|
0xFFD81B60, // Pink
|
|
0xFF8E24AA, // Purple
|
|
0xFF5E35B1, // Deep Purple
|
|
0xFF3949AB, // Indigo
|
|
0xFF00897B, // Teal
|
|
0xFF6D4C41, // Brown
|
|
];
|
|
|
|
String _hexOf(Color c) =>
|
|
'#${c.toARGB32().toRadixString(16).padLeft(8, '0').substring(2).toUpperCase()}';
|
|
|
|
void _pickPreset(int argb) {
|
|
setState(() {
|
|
_color = Color(argb);
|
|
_hex.text = _hexOf(_color);
|
|
});
|
|
}
|
|
|
|
void _commitHex(String input) {
|
|
final cleaned = input.replaceAll('#', '').trim();
|
|
if (cleaned.length != 6) return;
|
|
final raw = int.tryParse(cleaned, radix: 16);
|
|
if (raw == null) return;
|
|
setState(() => _color = Color(0xFF000000 | raw));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
final preview = ColorScheme.fromSeed(
|
|
seedColor: _color,
|
|
brightness: theme.brightness,
|
|
);
|
|
return AlertDialog(
|
|
title: Text(l.themePluginCustomDialogTitle),
|
|
content: SizedBox(
|
|
width: 360,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
for (final argb in _presets)
|
|
GestureDetector(
|
|
onTap: () => _pickPreset(argb),
|
|
child: Container(
|
|
width: 28,
|
|
height: 28,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: Color(argb),
|
|
border: Border.all(
|
|
color: _color.toARGB32() == argb
|
|
? theme.colorScheme.primary
|
|
: theme.colorScheme.outlineVariant,
|
|
width: _color.toARGB32() == argb ? 2.5 : 1,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: FaiSpace.md),
|
|
TextField(
|
|
controller: _hex,
|
|
decoration: InputDecoration(
|
|
labelText: l.themePluginCustomHexLabel,
|
|
hintText: '#1976D2',
|
|
isDense: true,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
onChanged: _commitHex,
|
|
onSubmitted: _commitHex,
|
|
),
|
|
const SizedBox(height: FaiSpace.md),
|
|
Text(l.themePluginCustomPreview, style: theme.textTheme.labelSmall),
|
|
const SizedBox(height: 6),
|
|
Row(
|
|
children: [
|
|
_swatch(preview.primary, 'P'),
|
|
_swatch(preview.secondary, 'S'),
|
|
_swatch(preview.tertiary, 'T'),
|
|
_swatch(preview.surface, '∎'),
|
|
_swatch(preview.error, '!'),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text(l.themePluginCustomCancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(context, _color),
|
|
child: Text(l.themePluginCustomApply),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _swatch(Color c, String letter) {
|
|
final theme = Theme.of(context);
|
|
return Padding(
|
|
padding: const EdgeInsets.only(right: 6),
|
|
child: Container(
|
|
width: 28,
|
|
height: 28,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: c,
|
|
border: Border.all(
|
|
color: theme.colorScheme.outlineVariant,
|
|
width: 0.5,
|
|
),
|
|
),
|
|
child: Text(
|
|
letter,
|
|
style: TextStyle(
|
|
color: ThemeData.estimateBrightnessForColor(c) == Brightness.dark
|
|
? Colors.white
|
|
: Colors.black,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w700,
|
|
fontFamily: 'monospace',
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|