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

@ -28,10 +28,7 @@ class _AuditPageState extends State<AuditPage> {
void initState() {
super.initState();
_refresh();
_poller = Timer.periodic(
const Duration(seconds: 2),
(_) => _refresh(),
);
_poller = Timer.periodic(const Duration(seconds: 2), (_) => _refresh());
}
@override
@ -56,9 +53,9 @@ class _AuditPageState extends State<AuditPage> {
_refresh();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.auditClearFailed(_friendly(e)))),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.auditClearFailed(_friendly(e)))));
}
}
@ -124,26 +121,25 @@ class _AuditPageState extends State<AuditPage> {
child: !_initialLoaded
? const Center(child: CircularProgressIndicator())
: _error != null && _events.isEmpty
? FaiEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: theme.colorScheme.error,
title: AppLocalizations.of(context)!.hubUnreachable,
hint: AppLocalizations.of(context)!.hubUnreachableHint,
)
: filtered.isEmpty
? FaiEmptyState(
icon: Icons.timeline_outlined,
title: AppLocalizations.of(context)!.auditNoEvents,
hint:
AppLocalizations.of(context)!.auditNoEventsHint,
)
: _GroupedEventList(
events: filtered,
allEvents: _events,
toneFor: (t) => _toneFor(t, theme),
formatTime: _formatTime,
contextLine: _contextLine,
),
? FaiEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: theme.colorScheme.error,
title: AppLocalizations.of(context)!.hubUnreachable,
hint: AppLocalizations.of(context)!.hubUnreachableHint,
)
: filtered.isEmpty
? FaiEmptyState(
icon: Icons.timeline_outlined,
title: AppLocalizations.of(context)!.auditNoEvents,
hint: AppLocalizations.of(context)!.auditNoEventsHint,
)
: _GroupedEventList(
events: filtered,
allEvents: _events,
toneFor: (t) => _toneFor(t, theme),
formatTime: _formatTime,
contextLine: _contextLine,
),
),
],
),
@ -164,7 +160,8 @@ class _AuditPageState extends State<AuditPage> {
final mm = local.minute.toString().padLeft(2, '0');
final ss = local.second.toString().padLeft(2, '0');
final time = '$hh:$mm:$ss';
final sameDay = local.year == now.year &&
final sameDay =
local.year == now.year &&
local.month == now.month &&
local.day == now.day;
if (sameDay) return time;
@ -252,10 +249,8 @@ class _GroupedEventList extends StatelessWidget {
trailing: e.durationMs != null ? '${e.durationMs}ms' : null,
onTap: () => showDialog<void>(
context: context,
builder: (_) => _EventDetailDialog(
event: e,
allEvents: allEvents,
),
builder: (_) =>
_EventDetailDialog(event: e, allEvents: allEvents),
),
),
);
@ -285,11 +280,7 @@ class _GroupedEventList extends StatelessWidget {
/// timezone so an event at 23:55 yesterday in Berlin doesn't
/// land in "today" because UTC happened to spill into a new
/// day.
static String _bucketLabel(
DateTime ts,
DateTime now,
AppLocalizations l,
) {
static String _bucketLabel(DateTime ts, DateTime now, AppLocalizations l) {
final local = ts.toLocal();
final localNow = now.toLocal();
final today = DateTime(localNow.year, localNow.month, localNow.day);
@ -371,13 +362,13 @@ class _ChipButtonState extends State<_ChipButton> {
final fg = widget.selected
? theme.colorScheme.primary
: _hovered
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant;
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant;
final bg = widget.selected
? theme.colorScheme.primary.withValues(alpha: 0.12)
: _hovered
? theme.colorScheme.surfaceContainerHigh
: Colors.transparent;
? theme.colorScheme.surfaceContainerHigh
: Colors.transparent;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
@ -444,9 +435,7 @@ class _LiveStatusBar extends StatelessWidget {
),
const SizedBox(width: FaiSpace.sm),
Text(
live
? l.auditLiveStatus(eventCount)
: l.auditDisconnected(error!),
live ? l.auditLiveStatus(eventCount) : l.auditDisconnected(error!),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@ -478,6 +467,7 @@ class _LiveStatusBar extends StatelessWidget {
class _EventDetailDialog extends StatefulWidget {
final AuditEvent event;
/// Full event window the audit page already fetched. Lets
/// the dialog surface every event sharing this event's
/// `flow_execution` without a fresh round-trip flow runs
@ -485,10 +475,7 @@ class _EventDetailDialog extends StatefulWidget {
/// page polls on.
final List<AuditEvent> allEvents;
const _EventDetailDialog({
required this.event,
required this.allEvents,
});
const _EventDetailDialog({required this.event, required this.allEvents});
@override
State<_EventDetailDialog> createState() => _EventDetailDialogState();
@ -544,8 +531,10 @@ class _EventDetailDialogState extends State<_EventDetailDialog> {
..writeln('event_type: ${e.type}')
..writeln('flow: ${e.flowName ?? "(none)"}')
..writeln('step: ${e.stepId ?? "(none)"}')
..writeln('module: ${e.moduleName ?? "(none)"}'
'${e.moduleVersion != null ? "@${e.moduleVersion}" : ""}')
..writeln(
'module: ${e.moduleName ?? "(none)"}'
'${e.moduleVersion != null ? "@${e.moduleVersion}" : ""}',
)
..writeln('error: ${e.error ?? "(none)"}')
..writeln('duration: ${e.durationMs ?? 0}ms');
if (mode == 'full' && e.detail != null) {
@ -640,14 +629,9 @@ class _EventDetailDialogState extends State<_EventDetailDialog> {
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(
color: theme.colorScheme.outlineVariant,
),
),
child: SelectableText(
detail,
style: FaiTheme.mono(size: 11),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: SelectableText(detail, style: FaiTheme.mono(size: 11)),
),
],
if (_explanation != null || _explaining) ...[
@ -690,11 +674,13 @@ class _EventDetailDialogState extends State<_EventDetailDialog> {
_explaining
? l.auditAsking
: _explanation == null
? l.auditExplain
: l.auditReask,
? l.auditExplain
: l.auditReask,
),
)
else if (_aiStatus != null && !_aiStatus!.enabled && (event.error != null))
else if (_aiStatus != null &&
!_aiStatus!.enabled &&
(event.error != null))
Tooltip(
message: l.auditConfigureSystemAi,
child: OutlinedButton.icon(
@ -734,10 +720,9 @@ class _FlowRunDialog extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final related = allEvents
.where((e) => e.flowExecution == flowExecution)
.toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
final related =
allEvents.where((e) => e.flowExecution == flowExecution).toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
final maxHeight = MediaQuery.of(context).size.height * 0.75;
return AlertDialog(
title: Text(l.auditFlowRunDialogTitle(flowName)),
@ -761,8 +746,7 @@ class _FlowRunDialog extends StatelessWidget {
child: ListView.separated(
shrinkWrap: true,
itemCount: related.length,
separatorBuilder: (_, _) =>
const SizedBox(height: FaiSpace.xs),
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.xs),
itemBuilder: (context, i) {
final e = related[i];
return FaiDataRow(
@ -770,8 +754,7 @@ class _FlowRunDialog extends StatelessWidget {
leading: _formatRelativeTime(e.timestamp),
title: e.type,
subtitle: _stepLine(e),
trailing:
e.durationMs != null ? '${e.durationMs}ms' : null,
trailing: e.durationMs != null ? '${e.durationMs}ms' : null,
);
},
),
@ -865,6 +848,7 @@ class _ExplanationPanel extends StatelessWidget {
final bool explaining;
final AskAiResult? result;
final String privacyMode;
/// Triggered by the "Regenerate" affordance passes
/// `forceFresh: true` so the next askAi skips the cache and
/// hits the live provider. Null while [explaining] is true.
@ -885,8 +869,8 @@ class _ExplanationPanel extends StatelessWidget {
final color = explaining
? theme.colorScheme.primary
: ok
? theme.colorScheme.primary
: theme.colorScheme.error;
? theme.colorScheme.primary
: theme.colorScheme.error;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(FaiSpace.md),
@ -945,7 +929,9 @@ class _ExplanationPanel extends StatelessWidget {
color: theme.colorScheme.onSurfaceVariant,
),
),
if (result != null && result!.isSuccess && onRegenerate != null) ...[
if (result != null &&
result!.isSuccess &&
onRegenerate != null) ...[
const SizedBox(width: FaiSpace.sm),
Tooltip(
message: l.auditRegenerateTooltip,
@ -980,7 +966,9 @@ class _ExplanationPanel extends StatelessWidget {
SelectableText(
result!.text,
style: theme.textTheme.bodyMedium?.copyWith(
color: ok ? theme.colorScheme.onSurface : theme.colorScheme.error,
color: ok
? theme.colorScheme.onSurface
: theme.colorScheme.error,
),
),
if (result!.fixHint.isNotEmpty) ...[
@ -1040,7 +1028,8 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> {
@override
void initState() {
super.initState();
final user = Platform.environment['USER'] ??
final user =
Platform.environment['USER'] ??
Platform.environment['USERNAME'] ??
'operator';
_reviewer = TextEditingController(text: '$user@studio');
@ -1111,12 +1100,12 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> {
onPressed: _reason.text.trim().isEmpty
? null
: () => Navigator.pop(
context,
_ClearOutcome(
reviewer: _reviewer.text.trim(),
reason: _reason.text.trim(),
),
context,
_ClearOutcome(
reviewer: _reviewer.text.trim(),
reason: _reason.text.trim(),
),
),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,