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

@ -137,8 +137,7 @@ class _DeltaPainter extends CustomPainter {
final pulseStrength = mode == FaiDeltaMode.live ? t : 1.0;
final glow = Paint()
..color = color.withValues(alpha: 0.30 + 0.40 * pulseStrength)
..maskFilter =
MaskFilter.blur(BlurStyle.normal, 8 + 14 * pulseStrength)
..maskFilter = MaskFilter.blur(BlurStyle.normal, 8 + 14 * pulseStrength)
..style = PaintingStyle.fill;
canvas.drawPath(path, glow);
}

View file

@ -43,9 +43,7 @@ class FaiEnBadge extends StatelessWidget {
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(
color: theme.colorScheme.outlineVariant,
),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Text(
'EN',

View file

@ -20,15 +20,18 @@ class FaiErrorBox extends StatefulWidget {
/// monospace, selectable, multi-line. Ignored when [error] is
/// supplied.
final String text;
/// When true, the box border + foreground colour come from the
/// error palette. False renders neutral chrome useful for
/// success / informational copy that still wants the copy
/// button.
final bool isError;
/// Optional max-height before the content scrolls inside the
/// box. Falls back to no constraint when null so short
/// messages don't get a useless scrollbar.
final double? maxHeight;
/// When non-null, the box renders the [friendlyError] mapping
/// of this error: a one-line headline, a recovery-hint line
/// (when one applies), and the verbatim original message
@ -45,9 +48,9 @@ class FaiErrorBox extends StatefulWidget {
this.maxHeight,
this.error,
}) : assert(
text != '' || error != null,
'FaiErrorBox needs either text or error',
);
text != '' || error != null,
'FaiErrorBox needs either text or error',
);
@override
State<FaiErrorBox> createState() => _FaiErrorBoxState();
@ -139,15 +142,15 @@ class _FaiErrorBoxState extends State<FaiErrorBox> {
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_detailExpanded
? Icons.expand_less
: Icons.expand_more,
_detailExpanded ? Icons.expand_less : Icons.expand_more,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
_detailExpanded ? l.buttonHideDetails : l.buttonShowDetails,
_detailExpanded
? l.buttonHideDetails
: l.buttonShowDetails,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),

View file

@ -126,10 +126,7 @@ class _CodeBlock extends StatelessWidget {
),
child: SelectableText(
text,
style: FaiTheme.mono(
size: 12,
color: theme.colorScheme.onSurface,
),
style: FaiTheme.mono(size: 12, color: theme.colorScheme.onSurface),
),
);
}
@ -170,14 +167,14 @@ class _BytesView extends StatelessWidget {
try {
await File(path).writeAsBytes(bytes, flush: true);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.flowsOutputSavedAt(path))),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.flowsOutputSavedAt(path))));
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.flowsOutputSaveFailed('$e'))),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.flowsOutputSaveFailed('$e'))));
}
}
@ -239,9 +236,9 @@ class _FileView extends StatelessWidget {
final r = await SystemActions.openInOs(target);
if (!context.mounted || r.ok) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.flowsOutputOpenFailed(r.stderr))),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.flowsOutputOpenFailed(r.stderr))));
}
@override
@ -261,10 +258,7 @@ class _FileView extends StatelessWidget {
children: [
SelectableText(
uri,
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.primary,
),
style: FaiTheme.mono(size: 11, color: theme.colorScheme.primary),
),
if (mimeType.isNotEmpty) ...[
const SizedBox(height: 4),

View file

@ -25,9 +25,7 @@ class FaiModuleSheet extends StatefulWidget {
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(FaiRadius.md),
),
borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)),
),
builder: (_) => FaiModuleSheet(moduleName: name),
);
@ -56,8 +54,7 @@ class _FaiModuleSheetState extends State<FaiModuleSheet> {
// "remove the highest version", which is rarely what an
// operator means when they've explicitly kept multiple
// versions around.
final versions =
await HubService.instance.installedVersions(detail.name);
final versions = await HubService.instance.installedVersions(detail.name);
if (!mounted) return;
String? targetVersion;
if (versions.length > 1) {
@ -101,9 +98,7 @@ class _FaiModuleSheetState extends State<FaiModuleSheet> {
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l.modulesUninstalledToast(r.name, r.version)),
),
SnackBar(content: Text(l.modulesUninstalledToast(r.name, r.version))),
);
Navigator.pop(context, true);
} catch (e) {
@ -138,8 +133,9 @@ class _FaiModuleSheetState extends State<FaiModuleSheet> {
Padding(
padding: const EdgeInsets.all(FaiSpace.xl),
child: Text(
AppLocalizations.of(context)!
.moduleSheetFailedToLoad(snapshot.error.toString()),
AppLocalizations.of(
context,
)!.moduleSheetFailedToLoad(snapshot.error.toString()),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.error,
),

View file

@ -6,13 +6,7 @@ import 'package:flutter/material.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
enum FaiPillTone {
neutral,
accent,
success,
warning,
danger,
}
enum FaiPillTone { neutral, accent, success, warning, danger }
class FaiPill extends StatelessWidget {
final String label;
@ -36,10 +30,7 @@ class FaiPill extends StatelessWidget {
? FaiTheme.mono(size: 11, weight: FontWeight.w500, color: fg)
: theme.textTheme.labelSmall?.copyWith(color: fg);
return Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.sm,
vertical: 3,
),
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm, vertical: 3),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(FaiRadius.sm),
@ -62,10 +53,7 @@ class FaiPill extends StatelessWidget {
case FaiPillTone.neutral:
return (scheme.surfaceContainerHighest, scheme.onSurfaceVariant);
case FaiPillTone.accent:
return (
scheme.primary.withValues(alpha: 0.15),
scheme.primary,
);
return (scheme.primary.withValues(alpha: 0.15), scheme.primary);
case FaiPillTone.success:
return (
FaiColors.success.withValues(alpha: 0.15),
@ -81,10 +69,7 @@ class FaiPill extends StatelessWidget {
: const Color(0xFFB45309),
);
case FaiPillTone.danger:
return (
scheme.errorContainer,
scheme.onErrorContainer,
);
return (scheme.errorContainer, scheme.onErrorContainer);
}
}
}

View file

@ -21,12 +21,15 @@ class FaiSearchHit {
final String label;
final String hint;
final IconData icon;
/// Free-form group used for the section header in the
/// results list ("Pages", "Modules", "Store", "Flows").
final String group;
/// Lowercase haystack name + hint + group. Filtered against
/// the lowercased query.
final String haystack;
/// Action to run when this hit is selected.
final VoidCallback onSelect;
@ -103,60 +106,67 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
final hits = <FaiSearchHit>[];
for (final m in modules) {
hits.add(FaiSearchHit(
label: m.name,
hint: l.searchInstalledModuleHint(m.version),
icon: Icons.extension,
group: l.searchGroupModules,
onSelect: () {
Navigator.pop(context);
FaiModuleSheet.show(context, m.name);
},
));
hits.add(
FaiSearchHit(
label: m.name,
hint: l.searchInstalledModuleHint(m.version),
icon: Icons.extension,
group: l.searchGroupModules,
onSelect: () {
Navigator.pop(context);
FaiModuleSheet.show(context, m.name);
},
),
);
}
final storeGroup = l.searchGroupStore;
final pagesGroup = l.searchGroupPages;
for (final s in store) {
hits.add(FaiSearchHit(
label: s.name,
hint: s.taglineEn.isEmpty
? l.searchStoreHintWithCategory(
s.category.isEmpty ? l.searchStoreUncategorized : s.category)
: l.searchStoreHintWithTagline(s.taglineEn),
icon: Icons.storefront_outlined,
group: storeGroup,
// Selecting a store hit just closes the palette and
// focuses the Store tab; deep-linking to the detail
// sheet would require thread-through plumbing we don't
// have yet.
onSelect: () {
Navigator.pop(context);
for (final h in widget.staticHits) {
if (h.label == storeGroup && h.group == pagesGroup) {
h.onSelect();
return;
hits.add(
FaiSearchHit(
label: s.name,
hint: s.taglineEn.isEmpty
? l.searchStoreHintWithCategory(
s.category.isEmpty ? l.searchStoreUncategorized : s.category,
)
: l.searchStoreHintWithTagline(s.taglineEn),
icon: Icons.storefront_outlined,
group: storeGroup,
// Selecting a store hit just closes the palette and
// focuses the Store tab; deep-linking to the detail
// sheet would require thread-through plumbing we don't
// have yet.
onSelect: () {
Navigator.pop(context);
for (final h in widget.staticHits) {
if (h.label == storeGroup && h.group == pagesGroup) {
h.onSelect();
return;
}
}
}
},
));
},
),
);
}
final flowsGroup = l.searchGroupFlows;
for (final f in flows) {
hits.add(FaiSearchHit(
label: f.name,
hint: l.searchSavedFlowHint,
icon: Icons.account_tree_outlined,
group: flowsGroup,
onSelect: () {
Navigator.pop(context);
for (final h in widget.staticHits) {
if (h.label == flowsGroup && h.group == pagesGroup) {
h.onSelect();
return;
hits.add(
FaiSearchHit(
label: f.name,
hint: l.searchSavedFlowHint,
icon: Icons.account_tree_outlined,
group: flowsGroup,
onSelect: () {
Navigator.pop(context);
for (final h in widget.staticHits) {
if (h.label == flowsGroup && h.group == pagesGroup) {
h.onSelect();
return;
}
}
}
},
));
},
),
);
}
return hits;
}
@ -211,9 +221,7 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
return Dialog(
alignment: Alignment.topCenter,
insetPadding: const EdgeInsets.only(top: 80, left: 24, right: 24),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640, maxHeight: 540),
child: FutureBuilder<List<FaiSearchHit>>(
@ -240,17 +248,17 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
controller: _query,
autofocus: true,
decoration: InputDecoration(
prefixIcon:
const Icon(Icons.search, size: 20),
prefixIcon: const Icon(Icons.search, size: 20),
hintText: l.searchHint,
border: InputBorder.none,
suffixIcon: snap.connectionState ==
ConnectionState.waiting
suffixIcon:
snap.connectionState == ConnectionState.waiting
? const SizedBox(
width: 14,
height: 14,
child:
CircularProgressIndicator(strokeWidth: 2),
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: null,
),
@ -283,11 +291,10 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
itemBuilder: (context, i) {
final h = hits[i];
final highlighted = i == _highlight;
final showHeader = i == 0 ||
hits[i - 1].group != h.group;
final showHeader =
i == 0 || hits[i - 1].group != h.group;
return Column(
crossAxisAlignment:
CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showHeader)
Padding(
@ -301,11 +308,12 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
h.group.toUpperCase(),
style: theme.textTheme.labelSmall
?.copyWith(
color: theme
.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
color: theme
.colorScheme
.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
),
InkWell(
@ -317,8 +325,9 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
},
child: Container(
color: highlighted
? theme.colorScheme
.surfaceContainerHigh
? theme
.colorScheme
.surfaceContainerHigh
: null,
padding: const EdgeInsets.symmetric(
horizontal: 16,
@ -340,21 +349,24 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
Text(
h.label,
style: theme
.textTheme.bodyMedium
.textTheme
.bodyMedium
?.copyWith(
fontWeight:
FontWeight.w500,
),
fontWeight:
FontWeight.w500,
),
),
if (h.hint.isNotEmpty)
Text(
h.hint,
style: theme.textTheme
style: theme
.textTheme
.bodySmall
?.copyWith(
color: theme.colorScheme
.onSurfaceVariant,
),
color: theme
.colorScheme
.onSurfaceVariant,
),
maxLines: 1,
overflow:
TextOverflow.ellipsis,
@ -367,7 +379,8 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
Icons.keyboard_return,
size: 14,
color: theme
.colorScheme.onSurfaceVariant,
.colorScheme
.onSurfaceVariant,
),
],
),

View file

@ -9,14 +9,13 @@ import '../data/hub.dart';
import '../data/hub_auth_token.dart';
import '../data/registry_token.dart';
import '../data/system_actions.dart';
import '../data/theme_plugin.dart';
import '../main.dart';
import '../l10n/app_localizations.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
import 'fai_error_box.dart';
import 'fai_pill.dart';
import 'fai_system_ai_editor.dart';
import 'theme_picker_grid.dart';
class FaiSettingsDialog extends StatefulWidget {
const FaiSettingsDialog({super.key});
@ -45,10 +44,12 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
SystemAiStatus? _aiStatus;
List<McpClientInfo>? _mcpClients;
List<N8nEndpointInfo>? _n8nEndpoints;
/// Length of the configured registry token, or null when the
/// `~/.fai/registry-token` file is missing / empty. Reloaded
/// after save / clear.
int? _registryTokenChars;
/// Length of the configured hub gRPC bearer token, or null
/// when `~/.fai/hub-auth-token` is missing / empty. Reloaded
/// after save / clear; UI only ever sees the trimmed length.
@ -74,7 +75,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
final n = await HubAuthToken.charCount();
if (!mounted) return;
setState(() => _hubAuthTokenChars = n);
} catch (_) {/* fail-quiet */}
} catch (_) {
/* fail-quiet */
}
}
Future<void> _saveHubAuthToken(String token) async {
@ -84,9 +87,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
await _loadHubAuthToken();
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.hubAuthTokenSavedToast)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.hubAuthTokenSavedToast)));
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
@ -103,9 +106,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
await _loadHubAuthToken();
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.hubAuthTokenClearedToast)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.hubAuthTokenClearedToast)));
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
@ -120,7 +123,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
final n = await RegistryToken.charCount();
if (!mounted) return;
setState(() => _registryTokenChars = n);
} catch (_) {/* fail-quiet */}
} catch (_) {
/* fail-quiet */
}
}
Future<void> _saveRegistryToken(String token) async {
@ -129,9 +134,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
await _loadRegistryToken();
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.registryTokenSavedToast)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.registryTokenSavedToast)));
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
@ -147,9 +152,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
await _loadRegistryToken();
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.registryTokenClearedToast)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.registryTokenClearedToast)));
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
@ -164,7 +169,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
final list = await HubService.instance.listN8nEndpoints();
if (!mounted) return;
setState(() => _n8nEndpoints = list);
} catch (_) {/* fail-quiet */}
} catch (_) {
/* fail-quiet */
}
}
Future<void> _addN8nEndpoint(N8nEndpointDraft draft) async {
@ -178,15 +185,15 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
if (!mounted) return;
setState(() => _n8nEndpoints = list);
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.addedN8nToast(draft.name))),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.addedN8nToast(draft.name))));
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.addFailedToast(e.toString()))),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.addFailedToast(e.toString()))));
}
}
@ -239,15 +246,15 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
if (!mounted) return;
setState(() => _mcpClients = list);
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.addedMcpToast(draft.name))),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.addedMcpToast(draft.name))));
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.addFailedToast(e.toString()))),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.addFailedToast(e.toString()))));
}
}
@ -402,197 +409,201 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.settingsHubEndpointHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.lg),
TextField(
controller: _host,
decoration: InputDecoration(
labelText: l.settingsHost,
border: const OutlineInputBorder(),
isDense: true,
),
autofocus: true,
),
const SizedBox(height: FaiSpace.md),
Row(
children: [
Expanded(
flex: 2,
child: TextField(
controller: _port,
decoration: InputDecoration(
labelText: l.settingsPort,
border: const OutlineInputBorder(),
isDense: true,
),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: FaiSpace.md),
Expanded(
flex: 3,
child: SwitchListTile(
contentPadding: EdgeInsets.zero,
dense: true,
title: Text(l.settingsTls),
subtitle: Text(
l.settingsTlsSubtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
value: _secure,
onChanged: (v) => setState(() => _secure = v),
),
),
],
),
if (_error != null) ...[
const SizedBox(height: FaiSpace.md),
Text(
_error!,
l.settingsHubEndpointHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
const SizedBox(height: FaiSpace.md),
Container(
padding: const EdgeInsets.all(FaiSpace.sm),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
const SizedBox(height: FaiSpace.lg),
TextField(
controller: _host,
decoration: InputDecoration(
labelText: l.settingsHost,
border: const OutlineInputBorder(),
isDense: true,
),
autofocus: true,
),
child: Row(
const SizedBox(height: FaiSpace.md),
Row(
children: [
Icon(
Icons.link,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
Expanded(
flex: 2,
child: TextField(
controller: _port,
decoration: InputDecoration(
labelText: l.settingsPort,
border: const OutlineInputBorder(),
isDense: true,
),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: FaiSpace.sm),
Text(
_previewUrl(),
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurface,
const SizedBox(width: FaiSpace.md),
Expanded(
flex: 3,
child: SwitchListTile(
contentPadding: EdgeInsets.zero,
dense: true,
title: Text(l.settingsTls),
subtitle: Text(
l.settingsTlsSubtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
value: _secure,
onChanged: (v) => setState(() => _secure = v),
),
),
],
),
),
if (_channels != null) ...[
const SizedBox(height: FaiSpace.lg),
Text(
l.channelsHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
if (_error != null) ...[
const SizedBox(height: FaiSpace.md),
Text(
_error!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
const SizedBox(height: FaiSpace.md),
Container(
padding: const EdgeInsets.all(FaiSpace.sm),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
),
child: Row(
children: [
Icon(
Icons.link,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: FaiSpace.sm),
Text(
_previewUrl(),
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurface,
),
),
],
),
),
if (_channels != null) ...[
const SizedBox(height: FaiSpace.lg),
Text(
l.channelsHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(height: 2),
Text(
l.channelsBlurb,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.sm),
for (final ch in _channels!.channels)
_ChannelRow(
channel: ch,
active: ch.name == _channels!.active,
onConnect: _saving ? null : () => _connectToChannel(ch),
onSwitch: _saving ? null : () => _switchChannel(ch.name),
onEnableAutostart: _saving
? null
: () => _runDaemon(
'enable autostart',
() => SystemActions.faiDaemonEnable(ch.name),
),
onDisableAutostart: _saving
? null
: () => _runDaemon(
'disable autostart',
() => SystemActions.faiDaemonDisable(ch.name),
),
),
if (_channelToast != null) ...[
const SizedBox(height: FaiSpace.sm),
FaiErrorBox(text: _channelToast!, maxHeight: 200),
],
],
if (_aiStatus != null) ...[
const SizedBox(height: FaiSpace.lg),
_SystemAiPanel(
status: _aiStatus!,
onEdit: () async {
final updated = await FaiSystemAiEditor.show(
context,
_aiStatus!,
);
if (updated != null && mounted) {
setState(() => _aiStatus = updated);
}
},
),
],
if (_mcpClients != null) ...[
const SizedBox(height: FaiSpace.lg),
_McpClientsPanel(
clients: _mcpClients!,
onAdd: _addMcpClient,
onRemove: _removeMcpClient,
onRefresh: _refreshMcpClients,
),
],
if (_n8nEndpoints != null) ...[
const SizedBox(height: FaiSpace.lg),
_N8nEndpointsPanel(
endpoints: _n8nEndpoints!,
onAdd: _addN8nEndpoint,
onRemove: _removeN8nEndpoint,
onRefresh: _refreshN8nEndpoints,
),
),
const SizedBox(height: 2),
Text(
l.channelsBlurb,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.sm),
for (final ch in _channels!.channels)
_ChannelRow(
channel: ch,
active: ch.name == _channels!.active,
onConnect: _saving ? null : () => _connectToChannel(ch),
onSwitch: _saving ? null : () => _switchChannel(ch.name),
onEnableAutostart: _saving ? null : () => _runDaemon(
'enable autostart',
() => SystemActions.faiDaemonEnable(ch.name),
),
onDisableAutostart: _saving ? null : () => _runDaemon(
'disable autostart',
() => SystemActions.faiDaemonDisable(ch.name),
),
),
if (_channelToast != null) ...[
const SizedBox(height: FaiSpace.sm),
FaiErrorBox(text: _channelToast!, maxHeight: 200),
],
],
if (_aiStatus != null) ...[
const SizedBox(height: FaiSpace.lg),
_SystemAiPanel(
status: _aiStatus!,
onEdit: () async {
final updated = await FaiSystemAiEditor.show(
context,
_aiStatus!,
_RegistryCredentialsPanel(
configuredChars: _registryTokenChars,
onSave: _saveRegistryToken,
onClear: _clearRegistryToken,
),
const SizedBox(height: FaiSpace.lg),
_HubAuthTokenPanel(
configuredChars: _hubAuthTokenChars,
onSave: _saveHubAuthToken,
onClear: _clearHubAuthToken,
),
const SizedBox(height: FaiSpace.lg),
const _DefaultScopePanel(),
const SizedBox(height: FaiSpace.lg),
const _ThemePluginPanel(),
const SizedBox(height: FaiSpace.lg),
_MaintenancePanel(
onResetDone: () async {
// Daemon just restarted under the same channel +
// port. Bouncing the gRPC connection picks up the
// fresh state without the operator having to
// close+reopen Settings.
await HubService.instance.reconnect(
HubService.instance.currentEndpoint,
);
if (updated != null && mounted) {
setState(() => _aiStatus = updated);
}
if (!mounted) return;
await _loadChannels();
await _loadAiStatus();
await _loadMcpClients();
await _loadN8nEndpoints();
await _loadRegistryToken();
},
),
],
if (_mcpClients != null) ...[
const SizedBox(height: FaiSpace.lg),
_McpClientsPanel(
clients: _mcpClients!,
onAdd: _addMcpClient,
onRemove: _removeMcpClient,
onRefresh: _refreshMcpClients,
),
],
if (_n8nEndpoints != null) ...[
const SizedBox(height: FaiSpace.lg),
_N8nEndpointsPanel(
endpoints: _n8nEndpoints!,
onAdd: _addN8nEndpoint,
onRemove: _removeN8nEndpoint,
onRefresh: _refreshN8nEndpoints,
),
],
const SizedBox(height: FaiSpace.lg),
_RegistryCredentialsPanel(
configuredChars: _registryTokenChars,
onSave: _saveRegistryToken,
onClear: _clearRegistryToken,
),
const SizedBox(height: FaiSpace.lg),
_HubAuthTokenPanel(
configuredChars: _hubAuthTokenChars,
onSave: _saveHubAuthToken,
onClear: _clearHubAuthToken,
),
const SizedBox(height: FaiSpace.lg),
const _DefaultScopePanel(),
const SizedBox(height: FaiSpace.lg),
const _ThemePluginPanel(),
const SizedBox(height: FaiSpace.lg),
_MaintenancePanel(
onResetDone: () async {
// Daemon just restarted under the same channel +
// port. Bouncing the gRPC connection picks up the
// fresh state without the operator having to
// close+reopen Settings.
await HubService.instance.reconnect(
HubService.instance.currentEndpoint,
);
if (!mounted) return;
await _loadChannels();
await _loadAiStatus();
await _loadMcpClients();
await _loadN8nEndpoints();
await _loadRegistryToken();
},
),
],
),
),
),
),
actions: [
@ -616,8 +627,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
String _previewUrl() {
final scheme = _secure ? 'https' : 'http';
final host =
_host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim();
final host = _host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim();
final port = _port.text.trim().isEmpty ? '50051' : _port.text.trim();
return '$scheme://$host:$port';
}
@ -627,6 +637,7 @@ class _ChannelRow extends StatelessWidget {
final ChannelInfo channel;
final bool active;
final VoidCallback? onConnect;
/// Switches the active channel pointer (`fai channel switch`).
/// Distinct from [onConnect], which only re-points Studio's
/// gRPC wire at a different running daemon.
@ -691,7 +702,10 @@ class _ChannelRow extends StatelessWidget {
if (active)
Padding(
padding: const EdgeInsets.only(right: FaiSpace.sm),
child: FaiPill(label: l.channelsActive, tone: FaiPillTone.success),
child: FaiPill(
label: l.channelsActive,
tone: FaiPillTone.success,
),
),
PopupMenuButton<String>(
tooltip: l.channelsActionsTooltip,
@ -716,18 +730,12 @@ class _ChannelRow extends StatelessWidget {
PopupMenuItem(
value: 'connect',
enabled: channel.running && onConnect != null,
child: _MenuRow(
icon: Icons.link,
text: l.channelsConnect,
),
child: _MenuRow(icon: Icons.link, text: l.channelsConnect),
),
PopupMenuItem(
value: 'switch',
enabled: !active && onSwitch != null,
child: _MenuRow(
icon: Icons.swap_horiz,
text: l.channelsSwitch,
),
child: _MenuRow(icon: Icons.swap_horiz, text: l.channelsSwitch),
),
const PopupMenuDivider(),
PopupMenuItem(
@ -812,7 +820,9 @@ class _SystemAiPanel extends StatelessWidget {
TextButton.icon(
onPressed: onEdit,
icon: const Icon(Icons.edit_outlined, size: 14),
label: Text(status.enabled ? l.systemAiEdit : l.systemAiConfigure),
label: Text(
status.enabled ? l.systemAiEdit : l.systemAiConfigure,
),
),
],
),
@ -822,7 +832,11 @@ class _SystemAiPanel extends StatelessWidget {
_StatRow(label: 'endpoint', value: status.endpoint, mono: true),
_StatRow(label: 'model', value: status.model, mono: true),
if (status.apiKeyEnv.isNotEmpty)
_StatRow(label: 'api_key_env', value: '\$${status.apiKeyEnv}', mono: true),
_StatRow(
label: 'api_key_env',
value: '\$${status.apiKeyEnv}',
mono: true,
),
] else ...[
Padding(
padding: const EdgeInsets.only(top: 2),
@ -844,11 +858,7 @@ class _StatRow extends StatelessWidget {
final String value;
final bool mono;
const _StatRow({
required this.label,
required this.value,
this.mono = false,
});
const _StatRow({required this.label, required this.value, this.mono = false});
@override
Widget build(BuildContext context) {
@ -974,10 +984,7 @@ class _McpClientsPanel extends StatelessWidget {
)
else
for (final c in clients)
_McpClientRow(
client: c,
onRemove: () => onRemove(c.name),
),
_McpClientRow(client: c, onRemove: () => onRemove(c.name)),
],
);
}
@ -1309,10 +1316,7 @@ class _N8nEndpointsPanel extends StatelessWidget {
)
else
for (final e in endpoints)
_N8nEndpointRow(
endpoint: e,
onRemove: () => onRemove(e.name),
),
_N8nEndpointRow(endpoint: e, onRemove: () => onRemove(e.name)),
],
);
}
@ -1497,7 +1501,8 @@ class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> {
),
FilledButton(
onPressed: () {
if (_name.text.trim().isEmpty || _baseUrl.text.trim().isEmpty) return;
if (_name.text.trim().isEmpty || _baseUrl.text.trim().isEmpty)
return;
Navigator.pop(
context,
N8nEndpointDraft(
@ -1529,27 +1534,13 @@ class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> {
/// When no theme plugin is installed the panel renders a
/// short hint pointing at the Store rather than a useless
/// empty dropdown.
class _ThemePluginPanel extends StatefulWidget {
class _ThemePluginPanel extends StatelessWidget {
const _ThemePluginPanel();
@override
State<_ThemePluginPanel> createState() => _ThemePluginPanelState();
}
class _ThemePluginPanelState extends State<_ThemePluginPanel> {
Future<List<String>>? _capsFuture;
@override
void initState() {
super.initState();
_capsFuture = listThemePluginCapabilities();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final app = StudioApp.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -1561,76 +1552,12 @@ class _ThemePluginPanelState extends State<_ThemePluginPanel> {
fontSize: 10,
),
),
const SizedBox(height: 4),
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>[];
if (caps.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
l.themePluginEmpty,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
);
}
// Dropdown shows "Built-in" + every installed
// studio.theme.* cap. Selection writes through
// StudioApp's notifier so the MaterialApp rebuilds
// with the new ColorScheme without a restart.
final active = app?.themePluginNotifier.value;
final items = <DropdownMenuItem<String?>>[
DropdownMenuItem<String?>(
value: null,
child: Text(l.themePluginNone),
),
for (final cap in caps)
DropdownMenuItem<String?>(
value: cap,
child: Text(cap, style: FaiTheme.mono(size: 12)),
),
];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButton<String?>(
value: active,
items: items,
isDense: true,
onChanged: app == null
? null
: (v) {
app.setThemePlugin(v);
setState(() {});
},
),
const SizedBox(height: 4),
Text(
l.themePluginHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
},
),
const SizedBox(height: 8),
// Rich grid of theme tiles. Each shows live swatches
// from the plugin's own palette so the operator can
// preview without applying. The "Custom" tile opens
// a seed-colour dialog.
const ThemePickerGrid(),
],
);
}
@ -1782,11 +1709,7 @@ class _MaintenancePanelState extends State<_MaintenancePanel> {
),
if (_resultText != null) ...[
const SizedBox(height: FaiSpace.sm),
FaiErrorBox(
text: _resultText!,
isError: !_resultOk,
maxHeight: 240,
),
FaiErrorBox(text: _resultText!, isError: !_resultOk, maxHeight: 240),
],
],
);
@ -1822,8 +1745,7 @@ class _RegistryCredentialsPanel extends StatefulWidget {
_RegistryCredentialsPanelState();
}
class _RegistryCredentialsPanelState
extends State<_RegistryCredentialsPanel> {
class _RegistryCredentialsPanelState extends State<_RegistryCredentialsPanel> {
final _controller = TextEditingController();
bool _obscured = true;
@ -2017,9 +1939,7 @@ class _HubAuthTokenPanelState extends State<_HubAuthTokenPanel> {
Row(
children: [
Icon(
isConfigured
? Icons.check_circle
: Icons.radio_button_unchecked,
isConfigured ? Icons.check_circle : Icons.radio_button_unchecked,
size: 14,
color: isConfigured
? theme.colorScheme.primary
@ -2066,8 +1986,7 @@ class _HubAuthTokenPanelState extends State<_HubAuthTokenPanel> {
_obscured ? Icons.visibility : Icons.visibility_off,
size: 16,
),
onPressed: () =>
setState(() => _obscured = !_obscured),
onPressed: () => setState(() => _obscured = !_obscured),
),
),
),
@ -2103,6 +2022,7 @@ class _McpSuggestion {
final IconData icon;
final String endpoint;
final String apiKeyEnv;
/// Localizable description. Use [resolveDescription] to fetch
/// the actual text in the active locale the const list
/// can't hold a closure that takes [AppLocalizations], and
@ -2305,9 +2225,9 @@ class _DefaultScopePanelState extends State<_DefaultScopePanel> {
_saving = false;
});
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.defaultScopeSavedToast)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.defaultScopeSavedToast)));
} catch (e) {
if (!mounted) return;
setState(() {
@ -2333,8 +2253,9 @@ class _DefaultScopePanelState extends State<_DefaultScopePanel> {
if (current.length == 1) {
// Hub rejects empty; surface the constraint inline rather
// than letting the RPC fail.
setState(() => _error = AppLocalizations.of(context)!
.defaultScopeNonEmptyError);
setState(
() => _error = AppLocalizations.of(context)!.defaultScopeNonEmptyError,
);
return;
}
current.removeAt(i);
@ -2513,10 +2434,7 @@ class _ScopeRow extends StatelessWidget {
),
),
Expanded(
child: Text(
entry,
style: const TextStyle(fontFamily: 'monospace'),
),
child: Text(entry, style: const TextStyle(fontFamily: 'monospace')),
),
IconButton(
icon: const Icon(Icons.arrow_upward, size: 16),

View file

@ -88,14 +88,8 @@ class _FaiStatusDotState extends State<FaiStatusDot>
/// Convenience presets.
class FaiStatusDots {
FaiStatusDots._();
static Widget live() => const FaiStatusDot(
color: FaiColors.success,
pulsing: true,
);
static Widget idle() => const FaiStatusDot(
color: FaiColors.muted,
);
static Widget down() => const FaiStatusDot(
color: FaiColors.danger,
);
static Widget live() =>
const FaiStatusDot(color: FaiColors.success, pulsing: true);
static Widget idle() => const FaiStatusDot(color: FaiColors.muted);
static Widget down() => const FaiStatusDot(color: FaiColors.danger);
}

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(

View 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',
),
),
),
);
}
}