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,
|
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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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" } } },
|
||||||
|
|
|
||||||
|
|
@ -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" } } },
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue