import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import '../data/error_presentation.dart'; import '../data/friendly_error.dart'; import '../data/hub.dart'; import '../data/reviewer_identity.dart'; import '../data/workspace.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; import '../widgets/widgets.dart'; import 'welcome.dart' show showFaiDoc; /// Case-insensitive free-text match over every field the audit list /// displays or exports. [query] must already be lowercased/trimmed. /// Top-level so the filter behaviour is unit-testable. bool matchesAuditQuery(AuditEvent e, String query) { bool has(String? s) => s != null && s.toLowerCase().contains(query); return has(e.type) || has(e.flowName) || has(e.stepId) || has(e.moduleName) || has(e.error) || has(e.detail) || has(e.project) || has(e.eventId); } /// Serialise audit events as JSONL — one JSON object per line, in /// the given order (newest first, as the hub returns them), with a /// trailing newline. Optional fields are omitted rather than written /// as null. Top-level so the export format is unit-testable. String auditEventsToJsonl(List events) { final lines = events.map((e) => jsonEncode({ 'event_id': e.eventId, 'timestamp': e.timestamp.toIso8601String(), 'type': e.type, if (e.project.isNotEmpty) 'project': e.project, if (e.flowName != null) 'flow': e.flowName, if (e.stepId != null) 'step': e.stepId, if (e.moduleName != null) 'module': e.moduleName, if (e.moduleVersion != null) 'module_version': e.moduleVersion, if (e.invocationId != null) 'invocation_id': e.invocationId, if (e.flowExecution != null) 'flow_execution': e.flowExecution, if (e.durationMs != null) 'duration_ms': e.durationMs, if (e.error != null) 'error': e.error, if (e.detail != null) 'detail': e.detail, })); return '${lines.join('\n')}\n'; } /// Upper bound for the full-history export. The EventLog RPC has no /// offset/cursor to page with and no server-side cap, so one request /// with a bound well beyond any local log fetches everything while /// still bounding the response. const int _fullExportLimit = 100000; class AuditPage extends StatefulWidget { const AuditPage({super.key}); @override State createState() => _AuditPageState(); } class _AuditPageState extends State { String _typeFilter = 'all'; String _search = ''; List _events = const []; Object? _error; bool _initialLoaded = false; Timer? _poller; /// Live event feed. New events nudge an immediate refresh so the /// page updates instantly instead of waiting up to 2 s. The poll /// stays as the safety net: if the stream errors or the hub drops, /// the page is still fresh within the poll interval. Reusing the /// proven `_refresh()` (re-fetch latest 100) keeps list management, /// filtering, and the hash-chain ordering exactly as before. StreamSubscription? _eventSub; Timer? _nudgeDebounce; Timer? _reconnect; @override void initState() { super.initState(); Workspace.instance.addListener(_onWorkspaceChanged); Workspace.instance.ensureLoaded(); _refresh(); _poller = Timer.periodic(const Duration(seconds: 2), (_) => _refresh()); _subscribeLive(); } @override void dispose() { Workspace.instance.removeListener(_onWorkspaceChanged); _poller?.cancel(); _nudgeDebounce?.cancel(); _reconnect?.cancel(); _eventSub?.cancel(); super.dispose(); } /// The workspace filter is applied hub-side, so both the list /// query and the live stream have to be re-established. void _onWorkspaceChanged() { if (!mounted) return; // Mid-switch the client points between contexts — hold off; the // end-of-switch notify lands here again with a settled client. if (Workspace.instance.switching) return; _refresh(); _subscribeLive(); } void _subscribeLive() { _eventSub?.cancel(); _eventSub = HubService.instance .streamEvents(backfill: 0, project: Workspace.instance.activeSlug) .listen( (_) => _nudge(), // On a persistent error the 2 s poll keeps the page fresh, so we // do nothing. On a clean close (hub restart / stream end) try to // re-establish the live feed once after a short delay. onError: (_) {}, onDone: () { _eventSub = null; if (mounted) { // Held in a field so dispose() can cancel it — an // anonymous timer here outlives the page when the // stream closes right before navigation (the a11y // suite caught this as a pending-timer flake). _reconnect?.cancel(); _reconnect = Timer(const Duration(seconds: 3), () { // Not mid-switch: the end-of-switch notify resubscribes. if (mounted && _eventSub == null && !Workspace.instance.switching) { _subscribeLive(); } }); } }, cancelOnError: true, ); } /// Coalesce a burst of events into a single refresh. void _nudge() { if (_nudgeDebounce?.isActive ?? false) return; _nudgeDebounce = Timer(const Duration(milliseconds: 300), () { if (mounted) _refresh(); }); } /// The list as the user currently sees it: type chip first, then /// the free-text query over every displayed/exported field. List _visibleEvents() { final byType = _typeFilter == 'all' ? _events : _events.where((e) => e.type.startsWith(_typeFilter)).toList(); final q = _search.trim().toLowerCase(); if (q.isEmpty) return byType; return byType.where((e) => matchesAuditQuery(e, q)).toList(); } /// Export the currently visible (type- + search-filtered) events /// as JSONL — one JSON object per line, newest first, exactly what /// the list shows. For exports with WORM guarantees the CLI /// `chain admin events --json` stays the canonical tool. Future _onExportPressed() async { final l = AppLocalizations.of(context)!; final events = _visibleEvents(); if (events.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l.auditExportNothing)), ); return; } await _saveEventsAsJsonl( events: events, dialogTitle: l.auditExportAction, fileName: 'audit-export.jsonl', ); } /// Export the full event history of the active project scope — /// independent of the type chip, the search field and the page's /// 100-event window. Keeps the project filter: the page is /// project-scoped, matching the per-project compliance export the /// CLI offers via `chain admin events --project`. Future _onExportAllPressed() async { final l = AppLocalizations.of(context)!; final List events; try { events = await HubService.instance.recentEvents( limit: _fullExportLimit, project: Workspace.instance.activeSlug, ); } catch (e) { if (!mounted) return; showChainErrorSnack(context, 'audit.export', e); return; } if (!mounted) return; if (events.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l.auditExportAllNothing)), ); return; } await _saveEventsAsJsonl( events: events, dialogTitle: l.auditExportAllAction, fileName: 'audit-export-full.jsonl', ); } Future _saveEventsAsJsonl({ required List events, required String dialogTitle, required String fileName, }) async { final l = AppLocalizations.of(context)!; final path = await FilePicker.saveFile( dialogTitle: dialogTitle, fileName: fileName, ); if (path == null || !mounted) return; try { await File(path).writeAsString(auditEventsToJsonl(events), flush: true); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l.auditExportSaved(events.length, path))), ); } catch (e) { if (!mounted) return; showChainErrorSnack(context, 'audit.export', e); } } Future _onClearPressed() async { final l = AppLocalizations.of(context)!; final outcome = await _ClearAuditDialog.show(context); if (outcome == null || !mounted) return; try { final r = await HubService.instance.clearEventLog( reviewer: outcome.reviewer, reason: outcome.reason, ); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l.auditClearedToast(r.purged, r.channel))), ); _refresh(); } catch (e) { if (!mounted) return; showChainErrorSnack(context, 'audit.clear', e); } } // _friendly was an inline shadow of friendlyError() in // data/friendly_error.dart — kept only because the original // call site predated the central mapper. Removed in favour // of showChainErrorSnack, which uses friendlyError under the // hood (with proper gRPC-code mapping + selectable // copy-affordance). Future _refresh() async { final ws = Workspace.instance; // Paused during a sealed-area switch: the hub client may already // point at the other hub while this page still renders the old // context — fetching now would show data under the wrong marking. if (ws.switching) return; final epoch = ws.contextEpoch; try { final events = await HubService.instance.recentEvents( limit: 100, project: Workspace.instance.activeSlug, ); if (!mounted || epoch != ws.contextEpoch) return; setState(() { _events = events; _error = null; _initialLoaded = true; }); } catch (e) { if (!mounted || epoch != ws.contextEpoch) return; setState(() { _error = e; _initialLoaded = true; }); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final filtered = _visibleEvents(); return Scaffold( backgroundColor: theme.scaffoldBackgroundColor, appBar: AppBar( title: Text(AppLocalizations.of(context)!.auditTitle), actions: [ const ChainWorkspaceSwitcher(), const SizedBox(width: ChainSpace.md), // Narrow windows can't fit the inline chip row next to the // workspace switcher — collapse to a checkmark menu so the // app bar never overflows (responsive_test.dart pins this). Padding( padding: const EdgeInsets.only(right: ChainSpace.lg), child: MediaQuery.sizeOf(context).width < 900 ? _FilterMenu( value: _typeFilter, onChanged: (v) => setState(() => _typeFilter = v), ) : _FilterChips( value: _typeFilter, onChanged: (v) => setState(() => _typeFilter = v), ), ), IconButton( icon: const Icon(Icons.help_outline, size: 18), tooltip: AppLocalizations.of(context)!.helpTooltip, onPressed: () => showFaiDoc(context, 'audit'), ), // The dev-only reset used to sit here as a bare trash icon — // on an audit log that read as "delete evidence" (usertest // panel, security auditor). It now lives in a labeled // overflow menu next to the export action. PopupMenuButton( icon: const Icon(Icons.more_vert, size: 18), tooltip: AppLocalizations.of(context)!.auditMoreTooltip, onSelected: (v) => switch (v) { 'export' => _onExportPressed(), 'export-all' => _onExportAllPressed(), 'clear' => _onClearPressed(), _ => null, }, // Labels wrap instead of running past the menu edge: a // popup menu is at most 280px wide, and these entries // spell out what they do ("Entwicklungs-Reset: Protokoll // löschen … (nur local/dev)"). Unwrapped, the tail was // clipped — and the clipped part is exactly the // "nur local/dev" that keeps the entry from reading as // "delete evidence". itemBuilder: (ctx) => [ PopupMenuItem( value: 'export', child: _menuLabel( Icons.download_outlined, AppLocalizations.of(ctx)!.auditExportAction, ), ), PopupMenuItem( value: 'export-all', child: _menuLabel( Icons.archive_outlined, AppLocalizations.of(ctx)!.auditExportAllAction, ), ), PopupMenuItem( value: 'clear', child: _menuLabel( Icons.delete_sweep_outlined, AppLocalizations.of(ctx)!.auditDevResetAction, ), ), ], ), const SizedBox(width: ChainSpace.sm), ], ), body: Column( children: [ // The status bar shows the CLASSIFIED one-liner, never the // raw thrown object — walls of gRPC text belong behind the // error state's detail expander (state-matrix invariant). _LiveStatusBar( eventCount: filtered.length, error: _error == null ? null : friendlyError( _error!, AppLocalizations.of(context)!, ).headline, ), Padding( padding: const EdgeInsets.fromLTRB( ChainSpace.xl, ChainSpace.sm, ChainSpace.xl, 0, ), child: TextField( onChanged: (v) => setState(() => _search = v), decoration: InputDecoration( hintText: AppLocalizations.of(context)!.auditSearchHint, prefixIcon: const Icon(Icons.search, size: 16), isDense: true, border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), ), style: theme.textTheme.bodySmall, ), ), Expanded( child: !_initialLoaded ? const Center(child: CircularProgressIndicator()) : _error != null && _events.isEmpty ? HubLoadErrorView(error: _error!, onRetry: _refresh) : filtered.isEmpty ? ChainEmptyState( icon: Icons.timeline_outlined, title: AppLocalizations.of(context)!.auditNoEvents, hint: AppLocalizations.of(context)!.auditNoEventsHint, ) : _GroupedEventList( events: filtered, allEvents: _events, toneFor: (t) => _toneFor(t, theme), formatTime: _formatTime, contextLine: _contextLine, ), ), ], ), ); } /// Render the event timestamp so the operator never has to /// guess what day "21:25" was. Same-day events keep the /// compact `HH:mm:ss` form; older events get a `MM-dd` prefix /// or a full `YYYY-MM-dd` for last year's tail of the log. /// Always rendered in the operator's local time zone — the /// dialog still shows the full ISO timestamp for unambiguous /// cross-checking. String _formatTime(DateTime ts) { final local = ts.toLocal(); final now = DateTime.now(); final hh = local.hour.toString().padLeft(2, '0'); final mm = local.minute.toString().padLeft(2, '0'); final ss = local.second.toString().padLeft(2, '0'); final time = '$hh:$mm:$ss'; final sameDay = local.year == now.year && local.month == now.month && local.day == now.day; if (sameDay) return time; // Older events always carry the full ISO date. `MM-dd` // alone is locale-ambiguous (US reads it as May-04, EU as // 5 April) — operators should not have to guess. final mo = local.month.toString().padLeft(2, '0'); final dd = local.day.toString().padLeft(2, '0'); return '${local.year}-$mo-$dd $time'; } String _contextLine(AuditEvent e) { final parts = []; if (e.flowName != null) parts.add(e.flowName!); 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(''); } /// Icon + label for an overflow-menu entry. [Flexible] is the /// point: the menu is width-capped, so a long label has to wrap /// rather than run off the edge. Widget _menuLabel(IconData icon, String label) => Row( children: [ Icon(icon, size: 16), const SizedBox(width: ChainSpace.sm), Flexible(child: Text(label)), ], ); 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 ChainColors.warning; return theme.colorScheme.outline; } } /// 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(ChainSpace.xl), itemCount: items.length, itemBuilder: (context, i) { final item = items[i]; if (item is _GroupHeader) { return Padding( padding: EdgeInsets.only( top: i == 0 ? 0 : ChainSpace.lg, bottom: ChainSpace.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: ChainSpace.xs), child: ChainDataRow( 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); } List<(String, String)> _filterItems(AppLocalizations l) => [ ('all', l.auditFilterAll), ('flow.', l.auditFilterFlow), ('step.', l.auditFilterStep), ('module.', l.auditFilterModule), ]; /// Narrow-window replacement for [_FilterChips]: one icon button /// with a checkmark menu. The icon takes the accent colour while a /// filter other than "all" is active, so a narrowed window never /// hides that the list is filtered. class _FilterMenu extends StatelessWidget { final String value; final ValueChanged onChanged; const _FilterMenu({required this.value, required this.onChanged}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return PopupMenuButton( tooltip: l.auditFilterTooltip, icon: Icon( Icons.filter_list, size: 18, color: value == 'all' ? theme.colorScheme.onSurfaceVariant : theme.colorScheme.primary, ), onSelected: onChanged, itemBuilder: (_) => [ for (final (v, label) in _filterItems(l)) CheckedPopupMenuItem( value: v, checked: value == v, child: Text(label), ), ], ); } } class _FilterChips extends StatelessWidget { final String value; final ValueChanged onChanged; const _FilterChips({required this.value, required this.onChanged}); @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; final items = _filterItems(l); return ChainSegments( items: [for (final (v, label) in items) ChainSegmentItem(v, label)], value: value, onChanged: onChanged, ); } } class _LiveStatusBar extends StatelessWidget { final int eventCount; final String? error; const _LiveStatusBar({required this.eventCount, required this.error}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; final live = error == null; return Container( width: double.infinity, padding: const EdgeInsets.symmetric( horizontal: ChainSpace.xl, vertical: ChainSpace.sm, ), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, border: Border( bottom: BorderSide(color: theme.colorScheme.outlineVariant), ), ), child: Row( children: [ ChainStatusDot( color: live ? ChainColors.success : ChainColors.danger, pulsing: live, ), const SizedBox(width: ChainSpace.sm), // Disconnected → SelectableText so the raw error is copyable // (it's the only place the underlying failure is surfaced; // the empty-state below shows only a generic hint). // // The left text is Expanded (not natural-width + Spacer): // it must yield when the window narrows — otherwise this // row overflows (responsive_test.dart pins it) — and its // tight fill keeps the hash-chain trailing right-aligned. Expanded( child: live ? Text( l.auditLiveStatus(eventCount), overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ) : SelectableText( l.auditDisconnected(error!), maxLines: 2, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), if (live) ...[ const SizedBox(width: ChainSpace.md), Icon( Icons.shield_outlined, size: 12, color: theme.colorScheme.primary, ), const SizedBox(width: 4), Text( l.auditHashChainVerified, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.primary, fontWeight: FontWeight.w500, ), ), ], ], ), ); } } 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, required this.allEvents}); @override State<_EventDetailDialog> createState() => _EventDetailDialogState(); } class _EventDetailDialogState extends State<_EventDetailDialog> { AskAiResult? _explanation; bool _explaining = false; SystemAiStatus? _aiStatus; AuditEvent get event => widget.event; @override void initState() { super.initState(); _loadAiStatus(); } Future _loadAiStatus() async { try { final s = await HubService.instance.systemAiStatus(); if (!mounted) return; setState(() => _aiStatus = s); } catch (_) { // Stay null → "Explain" stays hidden. } } Future _explain({bool forceFresh = false}) async { setState(() { _explaining = true; _explanation = null; }); final prompt = _buildPrompt(event, _aiStatus?.privacyMode ?? 'off'); final result = await HubService.instance.askAi( prompt, forceFresh: forceFresh, ); if (!mounted) return; setState(() { _explaining = false; _explanation = result; }); } String _buildPrompt(AuditEvent e, String mode) { // Privacy: redacted = no detail JSON, full = include it. final buf = StringBuffer() ..writeln('A Ch∆In Platform audit event was logged. Explain what') ..writeln('happened in plain language and suggest one concrete') ..writeln('fix the operator can apply now.') ..writeln() ..writeln('event_type: ${e.type}') ..writeln('flow: ${e.flowName ?? "(none)"}') ..writeln('step: ${e.stepId ?? "(none)"}') ..writeln( 'module: ${e.moduleName ?? "(none)"}' '${e.moduleVersion != null ? "@${e.moduleVersion}" : ""}', ) ..writeln('error: ${e.error ?? "(none)"}') ..writeln('duration: ${e.durationMs ?? 0}ms'); if (mode == 'full' && e.detail != null) { buf ..writeln() ..writeln('detail:') ..writeln(e.detail); } return buf.toString(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; String prettyDetail() { final raw = event.detail; if (raw == null || raw.isEmpty) return ''; // Many detail strings are JSON; try to pretty-print and // fall back to the raw value when parsing fails. try { final dynamic parsed = const JsonDecoder().convert(raw); return const JsonEncoder.withIndent(' ').convert(parsed); } catch (_) { return raw; } } final detail = prettyDetail(); return AlertDialog( title: Text(event.type), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(ChainRadius.md), ), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 640, maxHeight: 560), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ _Field(label: 'event_id', value: event.eventId, mono: true), _Field( label: 'timestamp', value: event.timestamp.toIso8601String(), mono: true, ), if (event.flowName != null) _Field(label: 'flow', value: event.flowName!), if (event.flowExecution != null) _Field( label: 'flow_execution', value: event.flowExecution!, mono: true, ), if (event.invocationId != null) _Field( label: 'invocation_id', value: event.invocationId!, mono: true, ), if (event.stepId != null) _Field(label: 'step', value: event.stepId!), if (event.moduleName != null) _Field( label: 'module', value: event.moduleVersion != null ? '${event.moduleName} @ ${event.moduleVersion}' : event.moduleName!, ), if (event.durationMs != null) _Field(label: 'duration', value: '${event.durationMs} ms'), if (event.error != null) _Field( label: 'error', value: event.error!, valueColor: theme.colorScheme.error, ), if (detail.isNotEmpty) ...[ const SizedBox(height: ChainSpace.md), Text( l.auditDetailHeader, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, ), ), const SizedBox(height: 4), Container( width: double.infinity, padding: const EdgeInsets.all(ChainSpace.md), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(ChainRadius.sm), border: Border.all(color: theme.colorScheme.outlineVariant), ), child: SelectableText(detail, style: ChainTheme.mono(size: 11)), ), ], if (_explanation != null || _explaining) ...[ const SizedBox(height: ChainSpace.lg), _ExplanationPanel( explaining: _explaining, result: _explanation, privacyMode: _aiStatus?.privacyMode ?? 'off', onRegenerate: _explaining ? null : () => _explain(forceFresh: true), ), ], ], ), ), ), 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, icon: const Icon(Icons.auto_awesome, size: 16), label: Text( _explaining ? l.auditAsking : _explanation == null ? l.auditExplain : l.auditReask, ), ) else if (_aiStatus != null && !_aiStatus!.enabled && (event.error != null)) Tooltip( message: l.auditConfigureSystemAi, child: OutlinedButton.icon( onPressed: null, icon: const Icon(Icons.auto_awesome_outlined, size: 16), label: Text(l.auditExplain), ), ), TextButton( onPressed: () => Navigator.pop(context), child: Text(l.buttonClose), ), ], ); } } /// 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(ChainRadius.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: ChainSpace.md), Flexible( child: ListView.separated( shrinkWrap: true, itemCount: related.length, separatorBuilder: (_, _) => const SizedBox(height: ChainSpace.xs), itemBuilder: (context, i) { final e = related[i]; return ChainDataRow( 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 ChainColors.warning; return theme.colorScheme.outline; } } class _Field extends StatelessWidget { final String label; final String value; final bool mono; final Color? valueColor; const _Field({ required this.label, required this.value, this.mono = false, this.valueColor, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Padding( padding: const EdgeInsets.only(bottom: 6), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 110, child: Text( label, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), Expanded( child: SelectableText( value, style: mono ? ChainTheme.mono( size: 11, color: valueColor ?? theme.colorScheme.onSurface, ) : theme.textTheme.bodySmall?.copyWith( color: valueColor ?? theme.colorScheme.onSurface, ), ), ), ], ), ); } } class _ExplanationPanel extends StatelessWidget { final bool explaining; final AskAiResult? result; final String privacyMode; /// Triggered by the "Regenerate" affordance — passes /// `forceFresh: true` so the next askAi skips the cache and /// hits the live provider. Null while [explaining] is true. final VoidCallback? onRegenerate; const _ExplanationPanel({ required this.explaining, required this.result, required this.privacyMode, required this.onRegenerate, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; final ok = result?.isSuccess ?? false; final color = explaining ? theme.colorScheme.primary : ok ? theme.colorScheme.primary : theme.colorScheme.error; return Container( width: double.infinity, padding: const EdgeInsets.all(ChainSpace.md), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(ChainRadius.sm), border: Border.all(color: color.withValues(alpha: 0.3)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.auto_awesome, size: 14, color: color), const SizedBox(width: ChainSpace.xs), Text( l.auditSystemAi, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, ), ), const SizedBox(width: ChainSpace.xs), ChainPill( label: privacyMode, tone: privacyMode == 'full' ? ChainPillTone.warning : ChainPillTone.neutral, ), if (result != null && result!.isSuccess && result!.cached) ...[ const SizedBox(width: ChainSpace.xs), Tooltip( message: () { final hits = result!.cacheHits > 1 ? l.auditCachedTooltipHits(result!.cacheHits) : ''; return result!.cachedAt.isEmpty ? l.auditCachedTooltipUnknown(hits) : l.auditCachedTooltipKnown(result!.cachedAt, hits); }(), child: ChainPill( label: l.auditCachedPill, tone: ChainPillTone.success, icon: Icons.bolt, ), ), ], const Spacer(), if (result != null && result!.isSuccess && result!.latencyMs > 0) Text( result!.cached ? l.auditOriginalLatency(result!.latencyMs) : l.auditLatency(result!.latencyMs), style: ChainTheme.mono( size: 10, color: theme.colorScheme.onSurfaceVariant, ), ), if (result != null && result!.isSuccess && onRegenerate != null) ...[ const SizedBox(width: ChainSpace.sm), Tooltip( message: l.auditRegenerateTooltip, child: IconButton( icon: const Icon(Icons.refresh, size: 14), visualDensity: VisualDensity.compact, onPressed: onRegenerate, ), ), ], ], ), const SizedBox(height: ChainSpace.sm), if (explaining) Row( children: [ const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ), const SizedBox(width: ChainSpace.sm), Text( l.auditAskingFull, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ) else if (result != null) ...[ SelectableText( result!.text, style: theme.textTheme.bodyMedium?.copyWith( color: ok ? theme.colorScheme.onSurface : theme.colorScheme.error, ), ), if (result!.fixHint(l).isNotEmpty) ...[ const SizedBox(height: ChainSpace.sm), Text( l.auditFixLabel, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, ), ), const SizedBox(height: 2), Text( result!.fixHint(l), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface, ), ), ], ], ], ), ); } } /// Outcome of the clear-audit confirmation dialog. [reviewer] is /// already the wire value — the marked, unchecked claim that lands /// in the `chain.reset` marker (see `data/reviewer_identity.dart`). class _ClearOutcome { final String reviewer; final String reason; const _ClearOutcome({required this.reviewer, required this.reason}); } /// Two-field confirmation dialog for "clear the audit log". /// Reviewer prefills with the local handle (the OS account is a /// label, not an identity — [HubService.clearEventLog] marks it as /// an unchecked claim on the wire, and the field's helper says so); /// reason has no default so the operator has to type *something* — /// the chain.reset marker must carry context. class _ClearAuditDialog extends StatefulWidget { const _ClearAuditDialog(); static Future<_ClearOutcome?> show(BuildContext context) { return showDialog<_ClearOutcome>( context: context, builder: (_) => const _ClearAuditDialog(), ); } @override State<_ClearAuditDialog> createState() => _ClearAuditDialogState(); } class _ClearAuditDialogState extends State<_ClearAuditDialog> { late final TextEditingController _reviewer; late final TextEditingController _reason; @override void initState() { super.initState(); _reviewer = TextEditingController(text: ReviewerIdentity.localHandle); _reason = TextEditingController(); } @override void dispose() { _reviewer.dispose(); _reason.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return AlertDialog( title: Text(l.auditClearDialogTitle), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 460), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l.auditClearDialogBody, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: ChainSpace.lg), TextField( controller: _reviewer, decoration: InputDecoration( labelText: l.auditClearReviewerLabel, helperText: l.auditClearReviewerHelper, helperMaxLines: 3, border: const OutlineInputBorder(), isDense: true, ), ), const SizedBox(height: ChainSpace.md), ValueListenableBuilder( valueListenable: _reason, builder: (_, _, _) => TextField( controller: _reason, autofocus: true, decoration: InputDecoration( labelText: l.auditClearReasonLabel, helperText: _reason.text.trim().isEmpty ? l.auditClearReasonHelper : null, border: const OutlineInputBorder(), isDense: true, ), ), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context, null), child: Text(l.buttonCancel), ), ValueListenableBuilder( valueListenable: _reason, builder: (_, _, _) => FilledButton( onPressed: _reason.text.trim().isEmpty ? null : () => Navigator.pop( context, _ClearOutcome( reviewer: ReviewerIdentity.wire(_reviewer.text), reason: _reason.text.trim(), ), ), style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error, foregroundColor: Theme.of(context).colorScheme.onError, ), child: Text(l.auditClearLogButton), ), ), ], ); } }