feat(studio): operator QoL — flow runnability, audit grouping, batch approvals, welcome celebration (v0.42.0)

Four UX threads stitched into one commit. Each pulls Studio
toward Stefan's "zero-learning-curve" goal — the feedback
that earned its own memory entry.

1. Flow-Runnability-Indikator
   ─────────────────────────
   The Flows tab now fetches `listFlows` and `listModules`
   in parallel. Each card compares the flow's
   `requiredCapabilities` against the installed-modules'
   capability set; rows with missing modules show a "Needs:
   text.extract@^0" red pill row beneath the path and have
   their Run button greyed out + tooltip
   "Install the missing modules first." Operators stop
   hitting Run → cryptic hub error → frustration.

2. Welcome-Checklist Celebration
   ─────────────────────────────
   Once all four checklist signals flip to done, an
   `_AllDoneCelebration` card replaces the bare
   "All four steps complete" + Hide button. Three concrete
   next-threads with action buttons: "Read the audit log",
   "Set up the daily Today story" (opens the Flows / Today
   doc inline via `_DocReaderSheet`), and "Build your own
   module" (opens the architecture doc). Operator who just
   got set up sees what to do next instead of an empty
   "what now?" feeling.

3. Audit-Page Time-Bucket Headers + Flow-Run Detail
   ────────────────────────────────────────────────
   The flat event list grows tiny "TODAY / YESTERDAY /
   EARLIER THIS WEEK / OLDER" section headers — bucket is
   computed in the operator's local timezone so an event at
   23:55 yesterday in Berlin doesn't end up in "today"
   because UTC happened to spill into a new day.
   Plus: the event-detail dialog gains a "View flow run"
   action when the picked event has a `flow_execution`. It
   opens a drill-down that lists every event in the
   already-fetched 100-event window sharing the same
   execution id, sorted ascending — the operator reads the
   run from step.started top to flow.completed bottom.

4. Approvals-Batch-Aktionen
   ────────────────────────
   Each pending approval card grows a checkbox. When ≥1
   selected, a floating action bar appears at the bottom
   with "N selected · Select all · Clear · Reject all ·
   Approve all". The parent loops sequentially through the
   per-record SDK calls so a partial failure produces
   "X done, Y failed" instead of a confusing all-or-nothing
   rollback. Reject prompts for a reason once and applies
   to the whole picked set.

13 new ARB keys cover the strings the four features
needed. Studio's tests stay green.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-09 11:24:57 +02:00
parent f90d8cc7a7
commit 2002486828
13 changed files with 1215 additions and 64 deletions

View file

@ -137,27 +137,12 @@ class _AuditPageState extends State<AuditPage> {
hint:
AppLocalizations.of(context)!.auditNoEventsHint,
)
: ListView.separated(
padding: const EdgeInsets.all(FaiSpace.xl),
itemCount: filtered.length,
separatorBuilder: (_, _) =>
const SizedBox(height: FaiSpace.xs),
itemBuilder: (context, i) {
final e = filtered[i];
return FaiDataRow(
accent: _toneFor(e.type, theme),
leading: _formatTime(e.timestamp),
title: e.type,
subtitle: _contextLine(e),
trailing: e.durationMs != null
? '${e.durationMs}ms'
: null,
onTap: () => showDialog<void>(
context: context,
builder: (_) => _EventDetailDialog(event: e),
),
);
},
: _GroupedEventList(
events: filtered,
allEvents: _events,
toneFor: (t) => _toneFor(t, theme),
formatTime: _formatTime,
contextLine: _contextLine,
),
),
],
@ -208,6 +193,129 @@ class _AuditPageState extends State<AuditPage> {
}
}
/// Audit list with time-bucket section headers. Walks the
/// already-filtered events once and inserts a tiny header row
/// whenever the relative-day bucket changes (Today / Yesterday
/// / Earlier this week / Older). Reads the same events twice
/// one filtered slice for the visible rows, the full set
/// for the per-flow-execution detail view triggered from the
/// event-detail dialog.
class _GroupedEventList extends StatelessWidget {
final List<AuditEvent> events;
final List<AuditEvent> allEvents;
final Color Function(String type) toneFor;
final String Function(DateTime ts) formatTime;
final String Function(AuditEvent e) contextLine;
const _GroupedEventList({
required this.events,
required this.allEvents,
required this.toneFor,
required this.formatTime,
required this.contextLine,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final items = _itemsWithHeaders(events, l);
return ListView.builder(
padding: const EdgeInsets.all(FaiSpace.xl),
itemCount: items.length,
itemBuilder: (context, i) {
final item = items[i];
if (item is _GroupHeader) {
return Padding(
padding: EdgeInsets.only(
top: i == 0 ? 0 : FaiSpace.lg,
bottom: FaiSpace.sm,
),
child: Text(
item.label,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
);
}
final e = (item as _EventItem).event;
return Padding(
padding: const EdgeInsets.only(bottom: FaiSpace.xs),
child: FaiDataRow(
accent: toneFor(e.type),
leading: formatTime(e.timestamp),
title: e.type,
subtitle: contextLine(e),
trailing: e.durationMs != null ? '${e.durationMs}ms' : null,
onTap: () => showDialog<void>(
context: context,
builder: (_) => _EventDetailDialog(
event: e,
allEvents: allEvents,
),
),
),
);
},
);
}
List<_ListItem> _itemsWithHeaders(
List<AuditEvent> events,
AppLocalizations l,
) {
final out = <_ListItem>[];
String? lastBucket;
final now = DateTime.now();
for (final e in events) {
final bucket = _bucketLabel(e.timestamp, now, l);
if (bucket != lastBucket) {
out.add(_GroupHeader(bucket));
lastBucket = bucket;
}
out.add(_EventItem(e));
}
return out;
}
/// Day-bucket label. Comparing in the operator's local
/// 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,
) {
final local = ts.toLocal();
final localNow = now.toLocal();
final today = DateTime(localNow.year, localNow.month, localNow.day);
final eventDay = DateTime(local.year, local.month, local.day);
final daysAgo = today.difference(eventDay).inDays;
if (daysAgo <= 0) return l.auditGroupToday;
if (daysAgo == 1) return l.auditGroupYesterday;
if (daysAgo <= 6) return l.auditGroupThisWeek;
return l.auditGroupOlder;
}
}
sealed class _ListItem {
const _ListItem();
}
class _GroupHeader extends _ListItem {
final String label;
const _GroupHeader(this.label);
}
class _EventItem extends _ListItem {
final AuditEvent event;
const _EventItem(this.event);
}
class _FilterChips extends StatelessWidget {
final String value;
final ValueChanged<String> onChanged;
@ -370,8 +478,17 @@ 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
/// fit comfortably inside the 100-event window the audit
/// page polls on.
final List<AuditEvent> allEvents;
const _EventDetailDialog({required this.event});
const _EventDetailDialog({
required this.event,
required this.allEvents,
});
@override
State<_EventDetailDialog> createState() => _EventDetailDialogState();
@ -549,6 +666,22 @@ class _EventDetailDialogState extends State<_EventDetailDialog> {
),
),
actions: [
if (event.flowExecution != null)
OutlinedButton.icon(
onPressed: () {
Navigator.pop(context);
showDialog<void>(
context: context,
builder: (_) => _FlowRunDialog(
flowExecution: event.flowExecution!,
flowName: event.flowName ?? event.flowExecution!,
allEvents: widget.allEvents,
),
);
},
icon: const Icon(Icons.account_tree_outlined, size: 16),
label: Text(l.auditEventViewFlowRun),
),
if (_aiStatus?.enabled == true && (event.error != null))
OutlinedButton.icon(
onPressed: _explaining ? null : _explain,
@ -579,6 +712,106 @@ class _EventDetailDialogState extends State<_EventDetailDialog> {
}
}
/// Filtered drill-down: shows every event in `allEvents`
/// whose `flowExecution` matches the picked one. Sorted by
/// timestamp ascending so the operator reads the run from
/// step.started top flow.completed bottom. Read-only the
/// individual event-detail dialog is not re-opened from here
/// to avoid recursive flow-run lookups; selecting a row in a
/// future iteration could surface the same Explain UI.
class _FlowRunDialog extends StatelessWidget {
final String flowExecution;
final String flowName;
final List<AuditEvent> allEvents;
const _FlowRunDialog({
required this.flowExecution,
required this.flowName,
required this.allEvents,
});
@override
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 maxHeight = MediaQuery.of(context).size.height * 0.75;
return AlertDialog(
title: Text(l.auditFlowRunDialogTitle(flowName)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.md),
),
content: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 640, maxHeight: maxHeight),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.auditFlowRunDialogSubtitle(related.length, flowExecution),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.md),
Flexible(
child: ListView.separated(
shrinkWrap: true,
itemCount: related.length,
separatorBuilder: (_, _) =>
const SizedBox(height: FaiSpace.xs),
itemBuilder: (context, i) {
final e = related[i];
return FaiDataRow(
accent: _toneFor(e.type, theme),
leading: _formatRelativeTime(e.timestamp),
title: e.type,
subtitle: _stepLine(e),
trailing:
e.durationMs != null ? '${e.durationMs}ms' : null,
);
},
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l.buttonClose),
),
],
);
}
String _formatRelativeTime(DateTime ts) {
final local = ts.toLocal();
final hh = local.hour.toString().padLeft(2, '0');
final mm = local.minute.toString().padLeft(2, '0');
final ss = local.second.toString().padLeft(2, '0');
return '$hh:$mm:$ss';
}
String _stepLine(AuditEvent e) {
final parts = <String>[];
if (e.stepId != null) parts.add(e.stepId!);
if (e.moduleName != null) parts.add('via ${e.moduleName}');
if (e.error != null) parts.add('[error: ${e.error}]');
return parts.join(' · ');
}
Color _toneFor(String type, ThemeData theme) {
if (type.endsWith('.failed')) return theme.colorScheme.error;
if (type.endsWith('.completed')) return theme.colorScheme.primary;
if (type.endsWith('.started')) return FaiColors.warning;
return theme.colorScheme.outline;
}
}
class _Field extends StatelessWidget {
final String label;
final String value;