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
463
lib/widgets/theme_picker_grid.dart
Normal file
463
lib/widgets/theme_picker_grid.dart
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
// 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',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue