// 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 createState() => _ThemePickerGridState(); } class _ThemePickerGridState extends State { late Future> _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 _previews = {}; @override void initState() { super.initState(); _capsFuture = listThemePluginCapabilities(); } Future _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>( 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 []; // 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 ? [scheme.primary, scheme.secondary, scheme.tertiary] : [ 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 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 _showColorPickerDialog( BuildContext context, { required Color initial, }) { return showDialog( 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 _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', ), ), ), ); } }