diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 4b9aa88..003a2ad 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -540,6 +540,7 @@ class HubService { name: f.name, path: f.path, sizeBytes: f.sizeBytes.toInt(), + requiredCapabilities: List.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 requiredCapabilities; const SavedFlow({ required this.name, required this.path, required this.sizeBytes, + required this.requiredCapabilities, }); } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 820c473..8153459 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -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 ` 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" } } }, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0d64634..eb06d30 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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 `. 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" } } }, diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 43a8970..e509158 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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 `. 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: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 34594e7..a396de7 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -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 ` 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'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 7f42dab..3f7407c 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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 `. 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'; diff --git a/lib/main.dart b/lib/main.dart index eb67ec6..90ea0a5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/pages/approvals.dart b/lib/pages/approvals.dart index 75ed42d..5a0ad15 100644 --- a/lib/pages/approvals.dart +++ b/lib/pages/approvals.dart @@ -21,6 +21,11 @@ class _ApprovalsPageState extends State late final TabController _tab; late Future> _pendingFuture; late Future> _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 _selectedIds = {}; + 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 } } + void _toggleSelection(String id) { + setState(() { + if (!_selectedIds.add(id)) _selectedIds.remove(id); + }); + } + + void _selectAll(Iterable 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 _batchApprove(List 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 _batchReject(List 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 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 class _PendingList extends StatelessWidget { final Future> future; + final Set selectedIds; + final bool batchInFlight; final void Function(ApprovalRecord) onApprove; final void Function(ApprovalRecord) onReject; + final void Function(String id) onToggle; + final void Function(Iterable) onSelectAll; + final VoidCallback onClearSelection; + final Future Function(List) onBatchApprove; + final Future Function(List) 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> 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, diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index 9394c27..6477d13 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -137,27 +137,12 @@ class _AuditPageState extends State { 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( - 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 { } } +/// 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 events; + final List 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( + context: context, + builder: (_) => _EventDetailDialog( + event: e, + allEvents: allEvents, + ), + ), + ), + ); + }, + ); + } + + List<_ListItem> _itemsWithHeaders( + List 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 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 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( + 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 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 = []; + 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; diff --git a/lib/pages/flows.dart b/lib/pages/flows.dart index 77e2ce7..271739c 100644 --- a/lib/pages/flows.dart +++ b/lib/pages/flows.dart @@ -19,18 +19,49 @@ class FlowsPage extends StatefulWidget { } class _FlowsPageState extends State { - late Future> _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((_) => []), + ]); + return _FlowsBundle( + flows: results[0] as List, + installedCapabilities: (results[1] as List) + .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 _missing(SavedFlow flow, Set installed) { + final out = []; + 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 _runFlow(SavedFlow flow) async { final inputs = await _FlowInputDialog.show(context, flow); if (inputs == null) return; @@ -62,7 +93,7 @@ class _FlowsPageState extends State { const SizedBox(width: FaiSpace.sm), ], ), - body: FutureBuilder>( + body: FutureBuilder<_FlowsBundle>( future: _future, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { @@ -80,7 +111,8 @@ class _FlowsPageState extends State { ), ); } - 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 { 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 { } } +/// 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 flows; + final Set 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 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), + ), ), ], ), diff --git a/lib/pages/welcome.dart b/lib/pages/welcome.dart index 43d91f7..cf1e85b 100644 --- a/lib/pages/welcome.dart +++ b/lib/pages/welcome.dart @@ -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), + ), ], ), ); diff --git a/pubspec.lock b/pubspec.lock index 842d845..53b5424 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -87,7 +87,7 @@ packages: path: "../fai_dart_sdk" relative: true source: path - version: "0.15.0" + version: "0.16.0" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6526799..73ec444 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.41.0 +version: 0.42.0 environment: sdk: ^3.11.0-200.1.beta