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

@ -21,6 +21,11 @@ class _ApprovalsPageState extends State<ApprovalsPage>
late final TabController _tab;
late Future<List<ApprovalRecord>> _pendingFuture;
late Future<List<ApprovalRecord>> _historyFuture;
/// IDs the operator currently has multi-selected for a
/// batch operation. Empty set hides the batch action bar
/// and falls back to per-row Approve / Reject buttons.
final Set<String> _selectedIds = <String>{};
bool _batchInFlight = false;
// Reviewer identity recorded in the audit log. Defaults to
// the OS user (closest stable identity Studio has without an
// auth backend); operators can override it per session.
@ -82,6 +87,73 @@ class _ApprovalsPageState extends State<ApprovalsPage>
}
}
void _toggleSelection(String id) {
setState(() {
if (!_selectedIds.add(id)) _selectedIds.remove(id);
});
}
void _selectAll(Iterable<ApprovalRecord> all) {
setState(() {
_selectedIds
..clear()
..addAll(all.map((a) => a.id));
});
}
void _clearSelection() => setState(() => _selectedIds.clear());
/// Loops the picked records sequentially through the
/// per-record approve / reject calls so a partial failure
/// surfaces with a clear "X done, Y failed" toast rather
/// than a confusing all-or-nothing rollback. Selection
/// drains as items succeed.
Future<void> _batchApprove(List<ApprovalRecord> picked) async {
final l = AppLocalizations.of(context)!;
setState(() => _batchInFlight = true);
var ok = 0;
var failed = 0;
for (final a in picked) {
try {
await HubService.instance.approve(a.id, _reviewer);
ok += 1;
_selectedIds.remove(a.id);
} catch (_) {
failed += 1;
}
}
if (!mounted) return;
setState(() => _batchInFlight = false);
_toast(failed == 0
? l.approvalsBatchApproveDoneToast(ok)
: l.approvalsBatchPartialFailure(ok, failed));
_refresh();
}
Future<void> _batchReject(List<ApprovalRecord> picked) async {
final l = AppLocalizations.of(context)!;
final reason = await _promptReason(context);
if (reason == null || reason.isEmpty) return;
setState(() => _batchInFlight = true);
var ok = 0;
var failed = 0;
for (final a in picked) {
try {
await HubService.instance.reject(a.id, _reviewer, reason);
ok += 1;
_selectedIds.remove(a.id);
} catch (_) {
failed += 1;
}
}
if (!mounted) return;
setState(() => _batchInFlight = false);
_toast(failed == 0
? l.approvalsBatchRejectDoneToast(ok)
: l.approvalsBatchPartialFailure(ok, failed));
_refresh();
}
void _toast(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
@ -148,8 +220,15 @@ class _ApprovalsPageState extends State<ApprovalsPage>
children: [
_PendingList(
future: _pendingFuture,
selectedIds: _selectedIds,
batchInFlight: _batchInFlight,
onApprove: _approve,
onReject: _reject,
onToggle: _toggleSelection,
onSelectAll: _selectAll,
onClearSelection: _clearSelection,
onBatchApprove: _batchApprove,
onBatchReject: _batchReject,
onRetry: _refresh,
),
_HistoryList(
@ -164,14 +243,28 @@ class _ApprovalsPageState extends State<ApprovalsPage>
class _PendingList extends StatelessWidget {
final Future<List<ApprovalRecord>> future;
final Set<String> selectedIds;
final bool batchInFlight;
final void Function(ApprovalRecord) onApprove;
final void Function(ApprovalRecord) onReject;
final void Function(String id) onToggle;
final void Function(Iterable<ApprovalRecord>) onSelectAll;
final VoidCallback onClearSelection;
final Future<void> Function(List<ApprovalRecord>) onBatchApprove;
final Future<void> Function(List<ApprovalRecord>) onBatchReject;
final VoidCallback onRetry;
const _PendingList({
required this.future,
required this.selectedIds,
required this.batchInFlight,
required this.onApprove,
required this.onReject,
required this.onToggle,
required this.onSelectAll,
required this.onClearSelection,
required this.onBatchApprove,
required this.onBatchReject,
required this.onRetry,
});
@ -205,24 +298,149 @@ class _PendingList extends StatelessWidget {
hint: l.approvalsInboxHint,
);
}
return ListView.separated(
padding: const EdgeInsets.all(FaiSpace.xl),
itemCount: pending.length,
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md),
itemBuilder: (context, i) {
final a = pending[i];
return _ApprovalCard(
approval: a,
onApprove: () => onApprove(a),
onReject: () => onReject(a),
);
},
final selected = pending
.where((a) => selectedIds.contains(a.id))
.toList();
return Stack(
children: [
ListView.separated(
padding: EdgeInsets.fromLTRB(
FaiSpace.xl,
FaiSpace.xl,
FaiSpace.xl,
// Keep the last card clear of the floating
// batch action bar.
selected.isEmpty ? FaiSpace.xl : 96.0,
),
itemCount: pending.length,
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md),
itemBuilder: (context, i) {
final a = pending[i];
final isSelected = selectedIds.contains(a.id);
return _ApprovalCard(
approval: a,
selected: isSelected,
onToggleSelected: () => onToggle(a.id),
onApprove: () => onApprove(a),
onReject: () => onReject(a),
);
},
),
if (selected.isNotEmpty)
Positioned(
left: FaiSpace.xl,
right: FaiSpace.xl,
bottom: FaiSpace.lg,
child: _BatchActionBar(
selectedCount: selected.length,
totalCount: pending.length,
inFlight: batchInFlight,
onSelectAll: () => onSelectAll(pending),
onClear: onClearSelection,
onApprove: () => onBatchApprove(selected),
onReject: () => onBatchReject(selected),
),
),
],
);
},
);
}
}
/// Floating action bar that surfaces when the operator
/// multi-selects pending approvals. Lets them approve or
/// reject the whole picked set in one round-trip per item;
/// the parent loops sequentially so a partial failure stays
/// visible per row.
class _BatchActionBar extends StatelessWidget {
final int selectedCount;
final int totalCount;
final bool inFlight;
final VoidCallback onSelectAll;
final VoidCallback onClear;
final VoidCallback onApprove;
final VoidCallback onReject;
const _BatchActionBar({
required this.selectedCount,
required this.totalCount,
required this.inFlight,
required this.onSelectAll,
required this.onClear,
required this.onApprove,
required this.onReject,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Material(
elevation: 4,
borderRadius: BorderRadius.circular(FaiRadius.md),
color: theme.colorScheme.surfaceContainerHigh,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.lg,
vertical: FaiSpace.md,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FaiRadius.md),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Row(
children: [
Icon(Icons.check_box_outlined,
size: 18, color: theme.colorScheme.primary),
const SizedBox(width: FaiSpace.sm),
Text(
l.approvalsBatchSelected(selectedCount),
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: FaiSpace.md),
if (selectedCount < totalCount)
TextButton(
onPressed: inFlight ? null : onSelectAll,
child: Text(l.approvalsSelectAll),
),
TextButton(
onPressed: inFlight ? null : onClear,
child: Text(l.approvalsClearSelection),
),
const Spacer(),
OutlinedButton.icon(
onPressed: inFlight ? null : onReject,
icon: const Icon(Icons.close, size: 14),
label: Text(l.approvalsBatchReject),
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.error,
side: BorderSide(
color: theme.colorScheme.error.withValues(alpha: 0.5),
),
),
),
const SizedBox(width: FaiSpace.sm),
FilledButton.icon(
onPressed: inFlight ? null : onApprove,
icon: inFlight
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check, size: 14),
label: Text(l.approvalsBatchApprove),
),
],
),
),
);
}
}
class _HistoryList extends StatelessWidget {
final Future<List<ApprovalRecord>> future;
final VoidCallback onRetry;
@ -275,11 +493,15 @@ class _HistoryList extends StatelessWidget {
class _ApprovalCard extends StatelessWidget {
final ApprovalRecord approval;
final bool selected;
final VoidCallback onToggleSelected;
final VoidCallback onApprove;
final VoidCallback onReject;
const _ApprovalCard({
required this.approval,
required this.selected,
required this.onToggleSelected,
required this.onApprove,
required this.onReject,
});
@ -295,6 +517,12 @@ class _ApprovalCard extends StatelessWidget {
children: [
Row(
children: [
Checkbox(
value: selected,
onChanged: (_) => onToggleSelected(),
visualDensity: VisualDensity.compact,
),
const SizedBox(width: FaiSpace.xs),
FaiPill(
label: l.approvalsPillPending,
tone: FaiPillTone.warning,

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;

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),
),
),
],
),

View file

@ -535,28 +535,151 @@ class _OnboardingChecklistState extends State<_OnboardingChecklist> {
),
if (allDone) ...[
const SizedBox(height: FaiSpace.md),
Row(
children: [
Icon(
Icons.check_circle,
size: 16,
color: FaiColors.success,
_AllDoneCelebration(onDismiss: _dismiss),
],
],
),
);
}
}
/// Shown beneath the four-step checklist once every signal
/// flips to done. Three concrete next-threads the operator
/// can pull on, plus the dismiss button. Each suggestion
/// links somewhere meaningful inside Studio (Audit page,
/// Today doc, Flows doc) no external browser calls.
class _AllDoneCelebration extends StatelessWidget {
final VoidCallback onDismiss;
const _AllDoneCelebration({required this.onDismiss});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Container(
padding: const EdgeInsets.all(FaiSpace.lg),
decoration: BoxDecoration(
color: FaiColors.success.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(FaiRadius.md),
border: Border.all(
color: FaiColors.success.withValues(alpha: 0.35),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.celebration_outlined, color: FaiColors.success),
const SizedBox(width: FaiSpace.sm),
Expanded(
child: Text(
l.welcomeChecklistAllSetTitle,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(width: FaiSpace.xs),
),
TextButton(
onPressed: onDismiss,
child: Text(l.welcomeChecklistDismiss),
),
],
),
const SizedBox(height: 4),
Text(
l.welcomeChecklistAllSetBody,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.md),
_NextStepRow(
icon: Icons.timeline_outlined,
title: l.welcomeChecklistNextAuditTitle,
body: l.welcomeChecklistNextAuditBody,
buttonLabel: l.welcomeChecklistNextAuditButton,
doc: null,
onOpenDoc: null,
),
_NextStepRow(
icon: Icons.auto_awesome,
title: l.welcomeChecklistNextTodayTitle,
body: l.welcomeChecklistNextTodayBody,
buttonLabel: l.welcomeChecklistNextTodayButton,
doc: _kDocs.firstWhere(
(d) => d.slug == 'flows',
orElse: () => _kDocs.first,
),
onOpenDoc: (entry) => _DocReaderSheet.show(context, entry),
),
_NextStepRow(
icon: Icons.extension_outlined,
title: l.welcomeChecklistNextModuleTitle,
body: l.welcomeChecklistNextModuleBody,
buttonLabel: l.welcomeChecklistNextModuleButton,
doc: _kDocs.first,
onOpenDoc: (entry) => _DocReaderSheet.show(context, entry),
),
],
),
);
}
}
class _NextStepRow extends StatelessWidget {
final IconData icon;
final String title;
final String body;
final String buttonLabel;
final _DocEntry? doc;
final void Function(_DocEntry)? onOpenDoc;
const _NextStepRow({
required this.icon,
required this.title,
required this.body,
required this.buttonLabel,
required this.doc,
required this.onOpenDoc,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final canActivate = doc != null && onOpenDoc != null;
return Padding(
padding: const EdgeInsets.only(bottom: FaiSpace.sm),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18, color: theme.colorScheme.primary),
const SizedBox(width: FaiSpace.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.welcomeChecklistAllDone,
title,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
body,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const Spacer(),
FilledButton.tonal(
onPressed: _dismiss,
child: Text(l.welcomeChecklistDismiss),
),
],
),
],
),
const SizedBox(width: FaiSpace.md),
OutlinedButton(
onPressed: canActivate ? () => onOpenDoc!(doc!) : null,
child: Text(buttonLabel),
),
],
),
);