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:
parent
f90d8cc7a7
commit
2002486828
13 changed files with 1215 additions and 64 deletions
|
|
@ -540,6 +540,7 @@ class HubService {
|
|||
name: f.name,
|
||||
path: f.path,
|
||||
sizeBytes: f.sizeBytes.toInt(),
|
||||
requiredCapabilities: List<String>.from(f.requiredCapabilities),
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
|
|
@ -843,11 +844,17 @@ class SavedFlow {
|
|||
final String name;
|
||||
final String path;
|
||||
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({
|
||||
required this.name,
|
||||
required this.path,
|
||||
required this.sizeBytes,
|
||||
required this.requiredCapabilities,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -406,6 +406,50 @@
|
|||
"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",
|
||||
"flowsRunningTitle": "{name} läuft",
|
||||
"@flowsRunningTitle": { "placeholders": { "name": { "type": "String" } } },
|
||||
|
|
|
|||
|
|
@ -407,6 +407,50 @@
|
|||
"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",
|
||||
"flowsRunningTitle": "Running {name}",
|
||||
"@flowsRunningTitle": { "placeholders": { "name": { "type": "String" } } },
|
||||
|
|
|
|||
|
|
@ -1880,6 +1880,174 @@ abstract class AppLocalizations {
|
|||
/// **'Could not read \"{path}\": {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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -1060,6 +1060,113 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
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
|
||||
String get flowsRunButton => 'Starten';
|
||||
|
||||
|
|
|
|||
|
|
@ -1074,6 +1074,111 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
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
|
||||
String get flowsRunButton => 'Run';
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import 'widgets/widgets.dart';
|
|||
/// Studio's own build version. Bump on every UI commit so the
|
||||
/// running app self-identifies — visible in the sidebar header
|
||||
/// 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 {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue