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

@ -19,18 +19,49 @@ class FlowsPage extends StatefulWidget {
}
class _FlowsPageState extends State<FlowsPage> {
late Future<List<SavedFlow>> _future;
late Future<_FlowsBundle> _future;
@override
void initState() {
super.initState();
_future = HubService.instance.listFlows();
_future = _load();
}
/// Pulls the saved flows AND the installed-modules list in
/// parallel so the page can compute "is this flow runnable"
/// per row from the same data the Run button gates on.
Future<_FlowsBundle> _load() async {
final results = await Future.wait([
HubService.instance.listFlows(),
HubService.instance.listModules().catchError((_) => <ModuleSummary>[]),
]);
return _FlowsBundle(
flows: results[0] as List<SavedFlow>,
installedCapabilities: (results[1] as List<ModuleSummary>)
.expand((m) => m.capabilities)
.toSet(),
);
}
void _refresh() => setState(() {
_future = HubService.instance.listFlows();
_future = _load();
});
/// Returns the verbatim `use:` strings whose bare capability
/// name is not provided by any installed module. The bare
/// name is everything left of the first `@`; version
/// constraints are tolerated at this stage the hub does
/// the full resolution at run time.
List<String> _missing(SavedFlow flow, Set<String> installed) {
final out = <String>[];
for (final cap in flow.requiredCapabilities) {
final at = cap.indexOf('@');
final name = at >= 0 ? cap.substring(0, at) : cap;
if (!installed.contains(name)) out.add(cap);
}
return out;
}
Future<void> _runFlow(SavedFlow flow) async {
final inputs = await _FlowInputDialog.show(context, flow);
if (inputs == null) return;
@ -62,7 +93,7 @@ class _FlowsPageState extends State<FlowsPage> {
const SizedBox(width: FaiSpace.sm),
],
),
body: FutureBuilder<List<SavedFlow>>(
body: FutureBuilder<_FlowsBundle>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
@ -80,7 +111,8 @@ class _FlowsPageState extends State<FlowsPage> {
),
);
}
final flows = snapshot.data ?? [];
final bundle = snapshot.data ?? const _FlowsBundle.empty();
final flows = bundle.flows;
if (flows.isEmpty) {
return FaiEmptyState(
icon: Icons.account_tree_outlined,
@ -92,8 +124,15 @@ class _FlowsPageState extends State<FlowsPage> {
padding: const EdgeInsets.all(FaiSpace.xl),
itemCount: flows.length,
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md),
itemBuilder: (context, i) =>
_FlowCard(flow: flows[i], onRun: () => _runFlow(flows[i])),
itemBuilder: (context, i) {
final flow = flows[i];
final missing = _missing(flow, bundle.installedCapabilities);
return _FlowCard(
flow: flow,
missingCapabilities: missing,
onRun: missing.isEmpty ? () => _runFlow(flow) : null,
);
},
);
},
),
@ -101,15 +140,42 @@ class _FlowsPageState extends State<FlowsPage> {
}
}
/// Snapshot of everything the Flows page needs in one render
/// pass: the saved flows themselves and the set of installed
/// capability names. Stored as a single Future so the
/// FutureBuilder transitions atomically (no flicker between
/// "flows loaded but modules pending").
class _FlowsBundle {
final List<SavedFlow> flows;
final Set<String> installedCapabilities;
const _FlowsBundle({
required this.flows,
required this.installedCapabilities,
});
const _FlowsBundle.empty()
: flows = const [],
installedCapabilities = const {};
}
class _FlowCard extends StatelessWidget {
final SavedFlow flow;
final VoidCallback onRun;
final List<String> missingCapabilities;
/// Null when the flow can't run (missing modules); the
/// Run button greys out + a tooltip points the operator
/// at the install path. Non-null otherwise.
final VoidCallback? onRun;
const _FlowCard({required this.flow, required this.onRun});
const _FlowCard({
required this.flow,
required this.missingCapabilities,
required this.onRun,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final isRunnable = missingCapabilities.isEmpty;
return FaiCard(
child: Row(
children: [
@ -137,6 +203,29 @@ class _FlowCard extends StatelessWidget {
color: theme.colorScheme.onSurfaceVariant,
),
),
if (!isRunnable) ...[
const SizedBox(height: FaiSpace.sm),
Wrap(
spacing: FaiSpace.xs,
runSpacing: FaiSpace.xs,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
l.flowsMissingModulesLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.error,
letterSpacing: 0.4,
),
),
for (final cap in missingCapabilities)
FaiPill(
label: cap,
tone: FaiPillTone.danger,
monospace: true,
),
],
),
],
],
),
),
@ -146,10 +235,13 @@ class _FlowCard extends StatelessWidget {
monospace: true,
),
const SizedBox(width: FaiSpace.md),
FilledButton.icon(
onPressed: onRun,
icon: const Icon(Icons.play_arrow, size: 16),
label: Text(AppLocalizations.of(context)!.flowsRunButton),
Tooltip(
message: isRunnable ? '' : l.flowsRunDisabledTooltip,
child: FilledButton.icon(
onPressed: onRun,
icon: const Icon(Icons.play_arrow, size: 16),
label: Text(l.flowsRunButton),
),
),
],
),