feat(studio): rich theme picker (presets + custom colour) + editor 0.11.0
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:
flemming-it 2026-06-02 01:29:34 +02:00
parent 69864da934
commit c1c60d5434
30 changed files with 1280 additions and 846 deletions

View file

@ -83,10 +83,7 @@ class _ProviderPreset {
];
static _ProviderPreset byWire(String wire) {
return all.firstWhere(
(p) => p.wire == wire,
orElse: () => all.first,
);
return all.firstWhere((p) => p.wire == wire, orElse: () => all.first);
}
}
@ -141,9 +138,7 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
);
_model = TextEditingController(text: init.model);
_apiKeyEnv = TextEditingController(
text: init.apiKeyEnv.isEmpty
? _preset.defaultApiKeyEnv
: init.apiKeyEnv,
text: init.apiKeyEnv.isEmpty ? _preset.defaultApiKeyEnv : init.apiKeyEnv,
);
_privacyMode = init.privacyMode.isEmpty ? 'redacted' : init.privacyMode;
// Pull the model list immediately so the operator sees the
@ -376,10 +371,7 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
_HardwareBanner(hw: _hw!, lastReviewed: _curated?.lastReviewed),
],
const SizedBox(height: FaiSpace.lg),
_ProviderDropdown(
value: _preset,
onChanged: _onProviderChanged,
),
_ProviderDropdown(value: _preset, onChanged: _onProviderChanged),
const SizedBox(height: 4),
Text(
_preset.description,
@ -495,10 +487,7 @@ class _ProviderDropdown extends StatelessWidget {
final _ProviderPreset value;
final void Function(_ProviderPreset) onChanged;
const _ProviderDropdown({
required this.value,
required this.onChanged,
});
const _ProviderDropdown({required this.value, required this.onChanged});
@override
Widget build(BuildContext context) {
@ -512,12 +501,7 @@ class _ProviderDropdown extends StatelessWidget {
isDense: true,
),
items: _ProviderPreset.all
.map(
(p) => DropdownMenuItem(
value: p.wire,
child: Text(p.label),
),
)
.map((p) => DropdownMenuItem(value: p.wire, child: Text(p.label)))
.toList(),
onChanged: (v) {
if (v == null) return;
@ -531,10 +515,7 @@ class _PrivacyModeChips extends StatelessWidget {
final String value;
final ValueChanged<String> onChanged;
const _PrivacyModeChips({
required this.value,
required this.onChanged,
});
const _PrivacyModeChips({required this.value, required this.onChanged});
@override
Widget build(BuildContext context) {
@ -547,11 +528,7 @@ class _PrivacyModeChips extends StatelessWidget {
l.systemAiPrivacyRedactedLabel,
l.systemAiPrivacyRedactedDesc,
),
(
'full',
l.systemAiPrivacyFullLabel,
l.systemAiPrivacyFullDesc,
),
('full', l.systemAiPrivacyFullLabel, l.systemAiPrivacyFullDesc),
];
// Material 3 deprecated per-Radio `groupValue` / `onChanged`
// after Flutter 3.32. The modern pattern wraps the radios in
@ -643,9 +620,7 @@ class _TestResultPanel extends StatelessWidget {
const SizedBox(width: FaiSpace.xs),
Text(
ok ? l.systemAiConnectionOk : l.systemAiConnectionFailed,
style: theme.textTheme.bodyMedium?.copyWith(
color: color,
),
style: theme.textTheme.bodyMedium?.copyWith(color: color),
),
const Spacer(),
if (ok && result.latencyMs > 0)
@ -659,10 +634,7 @@ class _TestResultPanel extends StatelessWidget {
const SizedBox(height: 4),
SelectableText(
ok ? l.systemAiReplyPrefix(result.text) : result.text,
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurface,
),
style: FaiTheme.mono(size: 11, color: theme.colorScheme.onSurface),
),
if (!ok && result.fixHint.isNotEmpty) ...[
const SizedBox(height: FaiSpace.xs),
@ -686,12 +658,14 @@ class _ModelPicker extends StatelessWidget {
final String? modelsError;
final bool loading;
final bool pulling;
/// Detected host hardware. Used by [_suitabilityFor] to mark
/// curated models as "recommended" when their `min_hw_tier` is
/// at or below the operator's tier. Null while the hub call is
/// in flight or unreachable chips fall back to the size-only
/// heuristic.
final HardwareSnapshot? hw;
/// Curated DB indexed by model id. Empty while the hub call is
/// in flight; chips then fall back to the size-only heuristic.
final Map<String, CuratedModelInfo> curatedById;
@ -736,8 +710,8 @@ class _ModelPicker extends StatelessWidget {
: preset.modelHint,
helperText: controller.text.trim().isEmpty
? (_isOllama
? l.systemAiModelHelperOllama
: l.systemAiModelHelperBase)
? l.systemAiModelHelperOllama
: l.systemAiModelHelperBase)
: null,
helperMaxLines: 2,
border: const OutlineInputBorder(),
@ -787,9 +761,7 @@ class _ModelPicker extends StatelessWidget {
const SizedBox(height: FaiSpace.sm),
if (models!.isEmpty)
Text(
_isOllama
? l.systemAiNoModelsOllama
: l.systemAiNoModelsGeneric,
_isOllama ? l.systemAiNoModelsOllama : l.systemAiNoModelsGeneric,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@ -970,6 +942,7 @@ List<String> _sortedBySuitability(
class _ModelChip extends StatelessWidget {
final String id;
final _Suitability suitability;
/// Curated entry for this id, when one exists. Drives the
/// tooltip operators get the editorial note ("Solid 4B
/// all-rounder") rather than just the suitability label.
@ -1010,9 +983,7 @@ class _ModelChip extends StatelessWidget {
message: _tooltipFor(context),
child: ActionChip(
avatar: Icon(
suitability == _Suitability.recommended
? Icons.star
: Icons.circle,
suitability == _Suitability.recommended ? Icons.star : Icons.circle,
size: suitability == _Suitability.recommended ? 14 : 9,
color: color,
),
@ -1041,10 +1012,7 @@ class _HardwareBanner extends StatelessWidget {
? l.systemAiHwReviewed(lastReviewed!)
: '';
return Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.sm,
vertical: 6,
),
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm, vertical: 6),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
@ -1081,23 +1049,25 @@ class _SuitabilityLegend extends StatelessWidget {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
Widget dot(Color c, String label) => Padding(
padding: const EdgeInsets.only(right: FaiSpace.md),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.circle, size: 8, color: c),
const SizedBox(width: 4),
Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
padding: const EdgeInsets.only(right: FaiSpace.md),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.circle, size: 8, color: c),
const SizedBox(width: 4),
Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
);
final tierTag = (hw == null || hw!.tier == 'unknown') ? '' : ' (${hw!.tier})';
],
),
);
final tierTag = (hw == null || hw!.tier == 'unknown')
? ''
: ' (${hw!.tier})';
return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
@ -1143,10 +1113,7 @@ class _CacheStatusRow extends StatelessWidget {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.sm,
vertical: 6,
),
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm, vertical: 6),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
@ -1154,11 +1121,7 @@ class _CacheStatusRow extends StatelessWidget {
),
child: Row(
children: [
Icon(
Icons.bolt,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
Icon(Icons.bolt, size: 14, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 6),
Expanded(
child: Text(