chain-studio/lib/widgets/theme_picker_grid.dart
flemming-it c1c60d5434
Some checks failed
Security / Security check (push) Failing after 1s
feat(studio): rich theme picker (presets + custom colour) + editor 0.11.0
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>
2026-06-02 01:29:34 +02:00

463 lines
14 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);
setState(() {});
},
);
}
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);
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;
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,
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');
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;
final VoidCallback onTap;
const _Tile({
required this.label,
required this.swatches,
required this.selected,
required this.onTap,
this.loading = 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 (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',
),
),
),
);
}
}