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

@ -540,6 +540,7 @@ class HubService {
name: f.name, name: f.name,
path: f.path, path: f.path,
sizeBytes: f.sizeBytes.toInt(), sizeBytes: f.sizeBytes.toInt(),
requiredCapabilities: List<String>.from(f.requiredCapabilities),
), ),
) )
.toList() .toList()
@ -843,11 +844,17 @@ class SavedFlow {
final String name; final String name;
final String path; final String path;
final int sizeBytes; final int sizeBytes;
/// Verbatim `use:` strings of every step in this flow,
/// deduplicated and sorted by the hub. Lets Studio decide
/// whether the flow is currently runnable (every entry
/// resolves to an installed module).
final List<String> requiredCapabilities;
const SavedFlow({ const SavedFlow({
required this.name, required this.name,
required this.path, required this.path,
required this.sizeBytes, required this.sizeBytes,
required this.requiredCapabilities,
}); });
} }

View file

@ -406,6 +406,50 @@
"error": { "type": "String" } "error": { "type": "String" }
} }
}, },
"flowsMissingModulesLabel": "Braucht:",
"flowsRunDisabledTooltip": "Erst die fehlenden Module installieren.",
"welcomeChecklistAllSetTitle": "Du bist eingerichtet.",
"welcomeChecklistAllSetBody": "Drei Stränge, an denen du als nächstes ziehen kannst:",
"welcomeChecklistNextAuditTitle": "Audit-Log lesen",
"welcomeChecklistNextAuditBody": "Jede Installation, jeder Flow-Lauf, jede Freigabe landet im hash-verketteten Log. Im Protokoll-Tab siehst du, was dein Hub gemacht hat.",
"welcomeChecklistNextAuditButton": "Protokoll öffnen",
"welcomeChecklistNextTodayTitle": "Daily-Today-Story einrichten",
"welcomeChecklistNextTodayBody": "tools/today/propose.sh per Cron laufen lassen und die Editorial-Karte im Store füllt sich mit operator-kuratierten Stories.",
"welcomeChecklistNextTodayButton": "Today-Doku öffnen",
"welcomeChecklistNextModuleTitle": "Eigenes Modul bauen",
"welcomeChecklistNextModuleBody": "Mit `fai new module <name>` ein Rust+WASM-Scaffold erzeugen. Das module-sdk übernimmt die WIT-Plumbing; du schreibst nur ein typisiertes invoke.",
"welcomeChecklistNextModuleButton": "Doku lesen",
"auditGroupToday": "HEUTE",
"auditGroupYesterday": "GESTERN",
"auditGroupThisWeek": "DIESE WOCHE",
"auditGroupOlder": "ÄLTER",
"auditEventViewFlowRun": "Flow-Lauf ansehen",
"auditFlowRunDialogTitle": "Flow-Lauf · {flow}",
"@auditFlowRunDialogTitle": { "placeholders": { "flow": { "type": "String" } } },
"auditFlowRunDialogSubtitle": "{n} {n, plural, =1{Ereignis} other{Ereignisse}} für Lauf {execution}",
"@auditFlowRunDialogSubtitle": {
"placeholders": {
"n": { "type": "int" },
"execution": { "type": "String" }
}
},
"approvalsSelectAll": "Alle wählen",
"approvalsClearSelection": "Auswahl aufheben",
"approvalsBatchSelected": "{n} ausgewählt",
"@approvalsBatchSelected": { "placeholders": { "n": { "type": "int" } } },
"approvalsBatchApprove": "Alle freigeben",
"approvalsBatchReject": "Alle ablehnen",
"approvalsBatchApproveDoneToast": "{n} Freigaben erteilt.",
"@approvalsBatchApproveDoneToast": { "placeholders": { "n": { "type": "int" } } },
"approvalsBatchRejectDoneToast": "{n} Freigaben abgelehnt.",
"@approvalsBatchRejectDoneToast": { "placeholders": { "n": { "type": "int" } } },
"approvalsBatchPartialFailure": "{ok} erledigt, {failed} fehlgeschlagen.",
"@approvalsBatchPartialFailure": {
"placeholders": {
"ok": { "type": "int" },
"failed": { "type": "int" }
}
},
"flowsRunButton": "Starten", "flowsRunButton": "Starten",
"flowsRunningTitle": "{name} läuft", "flowsRunningTitle": "{name} läuft",
"@flowsRunningTitle": { "placeholders": { "name": { "type": "String" } } }, "@flowsRunningTitle": { "placeholders": { "name": { "type": "String" } } },

View file

@ -407,6 +407,50 @@
"error": { "type": "String" } "error": { "type": "String" }
} }
}, },
"flowsMissingModulesLabel": "Needs:",
"flowsRunDisabledTooltip": "Install the missing modules first.",
"welcomeChecklistAllSetTitle": "You're set up.",
"welcomeChecklistAllSetBody": "Three threads to pull on next:",
"welcomeChecklistNextAuditTitle": "Read the audit log",
"welcomeChecklistNextAuditBody": "Every install, flow run, and approval lives in the hash-chained log. Open the Audit tab to see what your hub has been up to.",
"welcomeChecklistNextAuditButton": "Open Audit",
"welcomeChecklistNextTodayTitle": "Set up the daily Today story",
"welcomeChecklistNextTodayBody": "Run tools/today/propose.sh on a cron and the editorial card on the store auto-fills with operator-curated narratives.",
"welcomeChecklistNextTodayButton": "Open Today docs",
"welcomeChecklistNextModuleTitle": "Build your own module",
"welcomeChecklistNextModuleBody": "Generate a Rust+WASM scaffold with `fai new module <name>`. The module-sdk handles the WIT plumbing; you write a typed invoke.",
"welcomeChecklistNextModuleButton": "Read the docs",
"auditGroupToday": "TODAY",
"auditGroupYesterday": "YESTERDAY",
"auditGroupThisWeek": "EARLIER THIS WEEK",
"auditGroupOlder": "OLDER",
"auditEventViewFlowRun": "View flow run",
"auditFlowRunDialogTitle": "Flow run · {flow}",
"@auditFlowRunDialogTitle": { "placeholders": { "flow": { "type": "String" } } },
"auditFlowRunDialogSubtitle": "{n} {n, plural, =1{event} other{events}} for execution {execution}",
"@auditFlowRunDialogSubtitle": {
"placeholders": {
"n": { "type": "int" },
"execution": { "type": "String" }
}
},
"approvalsSelectAll": "Select all",
"approvalsClearSelection": "Clear",
"approvalsBatchSelected": "{n} selected",
"@approvalsBatchSelected": { "placeholders": { "n": { "type": "int" } } },
"approvalsBatchApprove": "Approve all",
"approvalsBatchReject": "Reject all",
"approvalsBatchApproveDoneToast": "{n} approvals approved.",
"@approvalsBatchApproveDoneToast": { "placeholders": { "n": { "type": "int" } } },
"approvalsBatchRejectDoneToast": "{n} approvals rejected.",
"@approvalsBatchRejectDoneToast": { "placeholders": { "n": { "type": "int" } } },
"approvalsBatchPartialFailure": "{ok} done, {failed} failed.",
"@approvalsBatchPartialFailure": {
"placeholders": {
"ok": { "type": "int" },
"failed": { "type": "int" }
}
},
"flowsRunButton": "Run", "flowsRunButton": "Run",
"flowsRunningTitle": "Running {name}", "flowsRunningTitle": "Running {name}",
"@flowsRunningTitle": { "placeholders": { "name": { "type": "String" } } }, "@flowsRunningTitle": { "placeholders": { "name": { "type": "String" } } },

View file

@ -1880,6 +1880,174 @@ abstract class AppLocalizations {
/// **'Could not read \"{path}\": {error}'** /// **'Could not read \"{path}\": {error}'**
String flowsFileReadFailed(String path, String error); String flowsFileReadFailed(String path, String error);
/// No description provided for @flowsMissingModulesLabel.
///
/// In en, this message translates to:
/// **'Needs:'**
String get flowsMissingModulesLabel;
/// No description provided for @flowsRunDisabledTooltip.
///
/// In en, this message translates to:
/// **'Install the missing modules first.'**
String get flowsRunDisabledTooltip;
/// No description provided for @welcomeChecklistAllSetTitle.
///
/// In en, this message translates to:
/// **'You\'re set up.'**
String get welcomeChecklistAllSetTitle;
/// No description provided for @welcomeChecklistAllSetBody.
///
/// In en, this message translates to:
/// **'Three threads to pull on next:'**
String get welcomeChecklistAllSetBody;
/// No description provided for @welcomeChecklistNextAuditTitle.
///
/// In en, this message translates to:
/// **'Read the audit log'**
String get welcomeChecklistNextAuditTitle;
/// No description provided for @welcomeChecklistNextAuditBody.
///
/// In en, this message translates to:
/// **'Every install, flow run, and approval lives in the hash-chained log. Open the Audit tab to see what your hub has been up to.'**
String get welcomeChecklistNextAuditBody;
/// No description provided for @welcomeChecklistNextAuditButton.
///
/// In en, this message translates to:
/// **'Open Audit'**
String get welcomeChecklistNextAuditButton;
/// No description provided for @welcomeChecklistNextTodayTitle.
///
/// In en, this message translates to:
/// **'Set up the daily Today story'**
String get welcomeChecklistNextTodayTitle;
/// No description provided for @welcomeChecklistNextTodayBody.
///
/// In en, this message translates to:
/// **'Run tools/today/propose.sh on a cron and the editorial card on the store auto-fills with operator-curated narratives.'**
String get welcomeChecklistNextTodayBody;
/// No description provided for @welcomeChecklistNextTodayButton.
///
/// In en, this message translates to:
/// **'Open Today docs'**
String get welcomeChecklistNextTodayButton;
/// No description provided for @welcomeChecklistNextModuleTitle.
///
/// In en, this message translates to:
/// **'Build your own module'**
String get welcomeChecklistNextModuleTitle;
/// No description provided for @welcomeChecklistNextModuleBody.
///
/// In en, this message translates to:
/// **'Generate a Rust+WASM scaffold with `fai new module <name>`. The module-sdk handles the WIT plumbing; you write a typed invoke.'**
String get welcomeChecklistNextModuleBody;
/// No description provided for @welcomeChecklistNextModuleButton.
///
/// In en, this message translates to:
/// **'Read the docs'**
String get welcomeChecklistNextModuleButton;
/// No description provided for @auditGroupToday.
///
/// In en, this message translates to:
/// **'TODAY'**
String get auditGroupToday;
/// No description provided for @auditGroupYesterday.
///
/// In en, this message translates to:
/// **'YESTERDAY'**
String get auditGroupYesterday;
/// No description provided for @auditGroupThisWeek.
///
/// In en, this message translates to:
/// **'EARLIER THIS WEEK'**
String get auditGroupThisWeek;
/// No description provided for @auditGroupOlder.
///
/// In en, this message translates to:
/// **'OLDER'**
String get auditGroupOlder;
/// No description provided for @auditEventViewFlowRun.
///
/// In en, this message translates to:
/// **'View flow run'**
String get auditEventViewFlowRun;
/// No description provided for @auditFlowRunDialogTitle.
///
/// In en, this message translates to:
/// **'Flow run · {flow}'**
String auditFlowRunDialogTitle(String flow);
/// No description provided for @auditFlowRunDialogSubtitle.
///
/// In en, this message translates to:
/// **'{n} {n, plural, =1{event} other{events}} for execution {execution}'**
String auditFlowRunDialogSubtitle(int n, String execution);
/// No description provided for @approvalsSelectAll.
///
/// In en, this message translates to:
/// **'Select all'**
String get approvalsSelectAll;
/// No description provided for @approvalsClearSelection.
///
/// In en, this message translates to:
/// **'Clear'**
String get approvalsClearSelection;
/// No description provided for @approvalsBatchSelected.
///
/// In en, this message translates to:
/// **'{n} selected'**
String approvalsBatchSelected(int n);
/// No description provided for @approvalsBatchApprove.
///
/// In en, this message translates to:
/// **'Approve all'**
String get approvalsBatchApprove;
/// No description provided for @approvalsBatchReject.
///
/// In en, this message translates to:
/// **'Reject all'**
String get approvalsBatchReject;
/// No description provided for @approvalsBatchApproveDoneToast.
///
/// In en, this message translates to:
/// **'{n} approvals approved.'**
String approvalsBatchApproveDoneToast(int n);
/// No description provided for @approvalsBatchRejectDoneToast.
///
/// In en, this message translates to:
/// **'{n} approvals rejected.'**
String approvalsBatchRejectDoneToast(int n);
/// No description provided for @approvalsBatchPartialFailure.
///
/// In en, this message translates to:
/// **'{ok} done, {failed} failed.'**
String approvalsBatchPartialFailure(int ok, int failed);
/// No description provided for @flowsRunButton. /// No description provided for @flowsRunButton.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -1060,6 +1060,113 @@ class AppLocalizationsDe extends AppLocalizations {
return '$path\" konnte nicht gelesen werden: $error'; return '$path\" konnte nicht gelesen werden: $error';
} }
@override
String get flowsMissingModulesLabel => 'Braucht:';
@override
String get flowsRunDisabledTooltip =>
'Erst die fehlenden Module installieren.';
@override
String get welcomeChecklistAllSetTitle => 'Du bist eingerichtet.';
@override
String get welcomeChecklistAllSetBody =>
'Drei Stränge, an denen du als nächstes ziehen kannst:';
@override
String get welcomeChecklistNextAuditTitle => 'Audit-Log lesen';
@override
String get welcomeChecklistNextAuditBody =>
'Jede Installation, jeder Flow-Lauf, jede Freigabe landet im hash-verketteten Log. Im Protokoll-Tab siehst du, was dein Hub gemacht hat.';
@override
String get welcomeChecklistNextAuditButton => 'Protokoll öffnen';
@override
String get welcomeChecklistNextTodayTitle => 'Daily-Today-Story einrichten';
@override
String get welcomeChecklistNextTodayBody =>
'tools/today/propose.sh per Cron laufen lassen und die Editorial-Karte im Store füllt sich mit operator-kuratierten Stories.';
@override
String get welcomeChecklistNextTodayButton => 'Today-Doku öffnen';
@override
String get welcomeChecklistNextModuleTitle => 'Eigenes Modul bauen';
@override
String get welcomeChecklistNextModuleBody =>
'Mit `fai new module <name>` ein Rust+WASM-Scaffold erzeugen. Das module-sdk übernimmt die WIT-Plumbing; du schreibst nur ein typisiertes invoke.';
@override
String get welcomeChecklistNextModuleButton => 'Doku lesen';
@override
String get auditGroupToday => 'HEUTE';
@override
String get auditGroupYesterday => 'GESTERN';
@override
String get auditGroupThisWeek => 'DIESE WOCHE';
@override
String get auditGroupOlder => 'ÄLTER';
@override
String get auditEventViewFlowRun => 'Flow-Lauf ansehen';
@override
String auditFlowRunDialogTitle(String flow) {
return 'Flow-Lauf · $flow';
}
@override
String auditFlowRunDialogSubtitle(int n, String execution) {
String _temp0 = intl.Intl.pluralLogic(
n,
locale: localeName,
other: 'Ereignisse',
one: 'Ereignis',
);
return '$n $_temp0 für Lauf $execution';
}
@override
String get approvalsSelectAll => 'Alle wählen';
@override
String get approvalsClearSelection => 'Auswahl aufheben';
@override
String approvalsBatchSelected(int n) {
return '$n ausgewählt';
}
@override
String get approvalsBatchApprove => 'Alle freigeben';
@override
String get approvalsBatchReject => 'Alle ablehnen';
@override
String approvalsBatchApproveDoneToast(int n) {
return '$n Freigaben erteilt.';
}
@override
String approvalsBatchRejectDoneToast(int n) {
return '$n Freigaben abgelehnt.';
}
@override
String approvalsBatchPartialFailure(int ok, int failed) {
return '$ok erledigt, $failed fehlgeschlagen.';
}
@override @override
String get flowsRunButton => 'Starten'; String get flowsRunButton => 'Starten';

View file

@ -1074,6 +1074,111 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Could not read \"$path\": $error'; return 'Could not read \"$path\": $error';
} }
@override
String get flowsMissingModulesLabel => 'Needs:';
@override
String get flowsRunDisabledTooltip => 'Install the missing modules first.';
@override
String get welcomeChecklistAllSetTitle => 'You\'re set up.';
@override
String get welcomeChecklistAllSetBody => 'Three threads to pull on next:';
@override
String get welcomeChecklistNextAuditTitle => 'Read the audit log';
@override
String get welcomeChecklistNextAuditBody =>
'Every install, flow run, and approval lives in the hash-chained log. Open the Audit tab to see what your hub has been up to.';
@override
String get welcomeChecklistNextAuditButton => 'Open Audit';
@override
String get welcomeChecklistNextTodayTitle => 'Set up the daily Today story';
@override
String get welcomeChecklistNextTodayBody =>
'Run tools/today/propose.sh on a cron and the editorial card on the store auto-fills with operator-curated narratives.';
@override
String get welcomeChecklistNextTodayButton => 'Open Today docs';
@override
String get welcomeChecklistNextModuleTitle => 'Build your own module';
@override
String get welcomeChecklistNextModuleBody =>
'Generate a Rust+WASM scaffold with `fai new module <name>`. The module-sdk handles the WIT plumbing; you write a typed invoke.';
@override
String get welcomeChecklistNextModuleButton => 'Read the docs';
@override
String get auditGroupToday => 'TODAY';
@override
String get auditGroupYesterday => 'YESTERDAY';
@override
String get auditGroupThisWeek => 'EARLIER THIS WEEK';
@override
String get auditGroupOlder => 'OLDER';
@override
String get auditEventViewFlowRun => 'View flow run';
@override
String auditFlowRunDialogTitle(String flow) {
return 'Flow run · $flow';
}
@override
String auditFlowRunDialogSubtitle(int n, String execution) {
String _temp0 = intl.Intl.pluralLogic(
n,
locale: localeName,
other: 'events',
one: 'event',
);
return '$n $_temp0 for execution $execution';
}
@override
String get approvalsSelectAll => 'Select all';
@override
String get approvalsClearSelection => 'Clear';
@override
String approvalsBatchSelected(int n) {
return '$n selected';
}
@override
String get approvalsBatchApprove => 'Approve all';
@override
String get approvalsBatchReject => 'Reject all';
@override
String approvalsBatchApproveDoneToast(int n) {
return '$n approvals approved.';
}
@override
String approvalsBatchRejectDoneToast(int n) {
return '$n approvals rejected.';
}
@override
String approvalsBatchPartialFailure(int ok, int failed) {
return '$ok done, $failed failed.';
}
@override @override
String get flowsRunButton => 'Run'; String get flowsRunButton => 'Run';

View file

@ -26,7 +26,7 @@ import 'widgets/widgets.dart';
/// Studio's own build version. Bump on every UI commit so the /// Studio's own build version. Bump on every UI commit so the
/// running app self-identifies visible in the sidebar header /// running app self-identifies visible in the sidebar header
/// and quick-glance proof that you're seeing the current build. /// and quick-glance proof that you're seeing the current build.
const String kStudioVersion = '0.41.0'; const String kStudioVersion = '0.42.0';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();

View file

@ -21,6 +21,11 @@ class _ApprovalsPageState extends State<ApprovalsPage>
late final TabController _tab; late final TabController _tab;
late Future<List<ApprovalRecord>> _pendingFuture; late Future<List<ApprovalRecord>> _pendingFuture;
late Future<List<ApprovalRecord>> _historyFuture; 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 // Reviewer identity recorded in the audit log. Defaults to
// the OS user (closest stable identity Studio has without an // the OS user (closest stable identity Studio has without an
// auth backend); operators can override it per session. // 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) { void _toast(String msg) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
@ -148,8 +220,15 @@ class _ApprovalsPageState extends State<ApprovalsPage>
children: [ children: [
_PendingList( _PendingList(
future: _pendingFuture, future: _pendingFuture,
selectedIds: _selectedIds,
batchInFlight: _batchInFlight,
onApprove: _approve, onApprove: _approve,
onReject: _reject, onReject: _reject,
onToggle: _toggleSelection,
onSelectAll: _selectAll,
onClearSelection: _clearSelection,
onBatchApprove: _batchApprove,
onBatchReject: _batchReject,
onRetry: _refresh, onRetry: _refresh,
), ),
_HistoryList( _HistoryList(
@ -164,14 +243,28 @@ class _ApprovalsPageState extends State<ApprovalsPage>
class _PendingList extends StatelessWidget { class _PendingList extends StatelessWidget {
final Future<List<ApprovalRecord>> future; final Future<List<ApprovalRecord>> future;
final Set<String> selectedIds;
final bool batchInFlight;
final void Function(ApprovalRecord) onApprove; final void Function(ApprovalRecord) onApprove;
final void Function(ApprovalRecord) onReject; 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; final VoidCallback onRetry;
const _PendingList({ const _PendingList({
required this.future, required this.future,
required this.selectedIds,
required this.batchInFlight,
required this.onApprove, required this.onApprove,
required this.onReject, required this.onReject,
required this.onToggle,
required this.onSelectAll,
required this.onClearSelection,
required this.onBatchApprove,
required this.onBatchReject,
required this.onRetry, required this.onRetry,
}); });
@ -205,24 +298,149 @@ class _PendingList extends StatelessWidget {
hint: l.approvalsInboxHint, hint: l.approvalsInboxHint,
); );
} }
return ListView.separated( final selected = pending
padding: const EdgeInsets.all(FaiSpace.xl), .where((a) => selectedIds.contains(a.id))
itemCount: pending.length, .toList();
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md), return Stack(
itemBuilder: (context, i) { children: [
final a = pending[i]; ListView.separated(
return _ApprovalCard( padding: EdgeInsets.fromLTRB(
approval: a, FaiSpace.xl,
onApprove: () => onApprove(a), FaiSpace.xl,
onReject: () => onReject(a), 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 { class _HistoryList extends StatelessWidget {
final Future<List<ApprovalRecord>> future; final Future<List<ApprovalRecord>> future;
final VoidCallback onRetry; final VoidCallback onRetry;
@ -275,11 +493,15 @@ class _HistoryList extends StatelessWidget {
class _ApprovalCard extends StatelessWidget { class _ApprovalCard extends StatelessWidget {
final ApprovalRecord approval; final ApprovalRecord approval;
final bool selected;
final VoidCallback onToggleSelected;
final VoidCallback onApprove; final VoidCallback onApprove;
final VoidCallback onReject; final VoidCallback onReject;
const _ApprovalCard({ const _ApprovalCard({
required this.approval, required this.approval,
required this.selected,
required this.onToggleSelected,
required this.onApprove, required this.onApprove,
required this.onReject, required this.onReject,
}); });
@ -295,6 +517,12 @@ class _ApprovalCard extends StatelessWidget {
children: [ children: [
Row( Row(
children: [ children: [
Checkbox(
value: selected,
onChanged: (_) => onToggleSelected(),
visualDensity: VisualDensity.compact,
),
const SizedBox(width: FaiSpace.xs),
FaiPill( FaiPill(
label: l.approvalsPillPending, label: l.approvalsPillPending,
tone: FaiPillTone.warning, tone: FaiPillTone.warning,

View file

@ -137,27 +137,12 @@ class _AuditPageState extends State<AuditPage> {
hint: hint:
AppLocalizations.of(context)!.auditNoEventsHint, AppLocalizations.of(context)!.auditNoEventsHint,
) )
: ListView.separated( : _GroupedEventList(
padding: const EdgeInsets.all(FaiSpace.xl), events: filtered,
itemCount: filtered.length, allEvents: _events,
separatorBuilder: (_, _) => toneFor: (t) => _toneFor(t, theme),
const SizedBox(height: FaiSpace.xs), formatTime: _formatTime,
itemBuilder: (context, i) { contextLine: _contextLine,
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),
),
);
},
), ),
), ),
], ],
@ -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 { class _FilterChips extends StatelessWidget {
final String value; final String value;
final ValueChanged<String> onChanged; final ValueChanged<String> onChanged;
@ -370,8 +478,17 @@ class _LiveStatusBar extends StatelessWidget {
class _EventDetailDialog extends StatefulWidget { class _EventDetailDialog extends StatefulWidget {
final AuditEvent event; 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 @override
State<_EventDetailDialog> createState() => _EventDetailDialogState(); State<_EventDetailDialog> createState() => _EventDetailDialogState();
@ -549,6 +666,22 @@ class _EventDetailDialogState extends State<_EventDetailDialog> {
), ),
), ),
actions: [ 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)) if (_aiStatus?.enabled == true && (event.error != null))
OutlinedButton.icon( OutlinedButton.icon(
onPressed: _explaining ? null : _explain, 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 { class _Field extends StatelessWidget {
final String label; final String label;
final String value; final String value;

View file

@ -19,18 +19,49 @@ class FlowsPage extends StatefulWidget {
} }
class _FlowsPageState extends State<FlowsPage> { class _FlowsPageState extends State<FlowsPage> {
late Future<List<SavedFlow>> _future; late Future<_FlowsBundle> _future;
@override @override
void initState() { void initState() {
super.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(() { 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 { Future<void> _runFlow(SavedFlow flow) async {
final inputs = await _FlowInputDialog.show(context, flow); final inputs = await _FlowInputDialog.show(context, flow);
if (inputs == null) return; if (inputs == null) return;
@ -62,7 +93,7 @@ class _FlowsPageState extends State<FlowsPage> {
const SizedBox(width: FaiSpace.sm), const SizedBox(width: FaiSpace.sm),
], ],
), ),
body: FutureBuilder<List<SavedFlow>>( body: FutureBuilder<_FlowsBundle>(
future: _future, future: _future,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { 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) { if (flows.isEmpty) {
return FaiEmptyState( return FaiEmptyState(
icon: Icons.account_tree_outlined, icon: Icons.account_tree_outlined,
@ -92,8 +124,15 @@ class _FlowsPageState extends State<FlowsPage> {
padding: const EdgeInsets.all(FaiSpace.xl), padding: const EdgeInsets.all(FaiSpace.xl),
itemCount: flows.length, itemCount: flows.length,
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md), separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md),
itemBuilder: (context, i) => itemBuilder: (context, i) {
_FlowCard(flow: flows[i], onRun: () => _runFlow(flows[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 { class _FlowCard extends StatelessWidget {
final SavedFlow flow; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final isRunnable = missingCapabilities.isEmpty;
return FaiCard( return FaiCard(
child: Row( child: Row(
children: [ children: [
@ -137,6 +203,29 @@ class _FlowCard extends StatelessWidget {
color: theme.colorScheme.onSurfaceVariant, 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, monospace: true,
), ),
const SizedBox(width: FaiSpace.md), const SizedBox(width: FaiSpace.md),
FilledButton.icon( Tooltip(
onPressed: onRun, message: isRunnable ? '' : l.flowsRunDisabledTooltip,
icon: const Icon(Icons.play_arrow, size: 16), child: FilledButton.icon(
label: Text(AppLocalizations.of(context)!.flowsRunButton), 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) ...[ if (allDone) ...[
const SizedBox(height: FaiSpace.md), const SizedBox(height: FaiSpace.md),
Row( _AllDoneCelebration(onDismiss: _dismiss),
children: [ ],
Icon( ],
Icons.check_circle, ),
size: 16, );
color: FaiColors.success, }
}
/// 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( Text(
l.welcomeChecklistAllDone, title,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
body,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, 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),
),
], ],
), ),
); );

View file

@ -87,7 +87,7 @@ packages:
path: "../fai_dart_sdk" path: "../fai_dart_sdk"
relative: true relative: true
source: path source: path
version: "0.15.0" version: "0.16.0"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:

View file

@ -1,7 +1,7 @@
name: fai_studio name: fai_studio
description: "F∆I Studio — desktop GUI for the F∆I hub" description: "F∆I Studio — desktop GUI for the F∆I hub"
publish_to: 'none' publish_to: 'none'
version: 0.41.0 version: 0.42.0
environment: environment:
sdk: ^3.11.0-200.1.beta sdk: ^3.11.0-200.1.beta