import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import '../data/hub.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; import '../widgets/widgets.dart'; class AuditPage extends StatefulWidget { const AuditPage({super.key}); @override State createState() => _AuditPageState(); } class _AuditPageState extends State { String _typeFilter = 'all'; List _events = const []; String? _error; bool _initialLoaded = false; Timer? _poller; @override void initState() { super.initState(); _refresh(); _poller = Timer.periodic(const Duration(seconds: 2), (_) => _refresh()); } @override void dispose() { _poller?.cancel(); super.dispose(); } 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; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l.auditClearFailed(_friendly(e))))); } } String _friendly(Object e) { final s = e.toString(); // The hub returns FailedPrecondition for compliance-locked // channels; tonic stringifies that as "failed_precondition: // …". Strip the noise so the operator sees the real reason. final marker = 'failed_precondition: '; final idx = s.toLowerCase().indexOf(marker); return idx >= 0 ? s.substring(idx + marker.length) : s; } Future _refresh() async { try { final events = await HubService.instance.recentEvents(limit: 100); if (!mounted) return; setState(() { _events = events; _error = null; _initialLoaded = true; }); } catch (e) { if (!mounted) return; setState(() { _error = e.toString(); _initialLoaded = true; }); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final filtered = _typeFilter == 'all' ? _events : _events.where((e) => e.type.startsWith(_typeFilter)).toList(); return Scaffold( backgroundColor: theme.scaffoldBackgroundColor, appBar: AppBar( title: Text(AppLocalizations.of(context)!.auditTitle), actions: [ Padding( padding: const EdgeInsets.only(right: FaiSpace.lg), child: _FilterChips( value: _typeFilter, onChanged: (v) => setState(() => _typeFilter = v), ), ), IconButton( icon: const Icon(Icons.delete_sweep_outlined, size: 18), tooltip: AppLocalizations.of(context)!.auditClearLogTooltip, onPressed: _onClearPressed, ), const SizedBox(width: FaiSpace.sm), ], ), body: Column( children: [ _LiveStatusBar(eventCount: filtered.length, error: _error), Expanded( child: !_initialLoaded ? const Center(child: CircularProgressIndicator()) : _error != null && _events.isEmpty ? FaiEmptyState( icon: Icons.cloud_off_outlined, iconColor: theme.colorScheme.error, title: AppLocalizations.of(context)!.hubUnreachable, hint: AppLocalizations.of(context)!.hubUnreachableHint, ) : filtered.isEmpty ? FaiEmptyState( 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(''); } 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; } } /// 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; const _FilterChips({required this.value, required this.onChanged}); @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; final items = [ ('all', l.auditFilterAll), ('flow.', l.auditFilterFlow), ('step.', l.auditFilterStep), ('module.', l.auditFilterModule), ]; return Row( children: [ for (final (v, label) in items) Padding( padding: const EdgeInsets.only(left: FaiSpace.xs), child: _ChipButton( label: label, selected: value == v, onTap: () => onChanged(v), ), ), ], ); } } class _ChipButton extends StatefulWidget { final String label; final bool selected; final VoidCallback onTap; const _ChipButton({ required this.label, required this.selected, required this.onTap, }); @override State<_ChipButton> createState() => _ChipButtonState(); } class _ChipButtonState extends State<_ChipButton> { bool _hovered = false; @override Widget build(BuildContext context) { final theme = Theme.of(context); final fg = widget.selected ? theme.colorScheme.primary : _hovered ? theme.colorScheme.onSurface : theme.colorScheme.onSurfaceVariant; final bg = widget.selected ? theme.colorScheme.primary.withValues(alpha: 0.12) : _hovered ? theme.colorScheme.surfaceContainerHigh : Colors.transparent; return MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), cursor: SystemMouseCursors.click, child: GestureDetector( onTap: widget.onTap, child: AnimatedContainer( duration: FaiMotion.fast, padding: const EdgeInsets.symmetric( horizontal: FaiSpace.md, vertical: 6, ), decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(FaiRadius.sm), border: Border.all( color: widget.selected ? theme.colorScheme.primary.withValues(alpha: 0.3) : Colors.transparent, ), ), child: Text( widget.label, style: FaiTheme.mono( size: 11, weight: widget.selected ? FontWeight.w500 : FontWeight.w400, color: fg, ), ), ), ), ); } } 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: FaiSpace.xl, vertical: FaiSpace.sm, ), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, border: Border( bottom: BorderSide(color: theme.colorScheme.outlineVariant), ), ), child: Row( children: [ FaiStatusDot( color: live ? FaiColors.success : FaiColors.danger, pulsing: live, ), const SizedBox(width: FaiSpace.sm), Text( live ? l.auditLiveStatus(eventCount) : l.auditDisconnected(error!), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const Spacer(), if (live) Row( children: [ 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 F∆I 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(FaiRadius.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: FaiSpace.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(FaiSpace.md), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(FaiRadius.sm), border: Border.all(color: theme.colorScheme.outlineVariant), ), child: SelectableText(detail, style: FaiTheme.mono(size: 11)), ), ], if (_explanation != null || _explaining) ...[ const SizedBox(height: FaiSpace.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(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; 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 ? FaiTheme.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(FaiSpace.md), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(FaiRadius.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: FaiSpace.xs), Text( l.auditSystemAi, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, ), ), const SizedBox(width: FaiSpace.xs), FaiPill( label: privacyMode, tone: privacyMode == 'full' ? FaiPillTone.warning : FaiPillTone.neutral, ), if (result != null && result!.isSuccess && result!.cached) ...[ const SizedBox(width: FaiSpace.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: FaiPill( label: l.auditCachedPill, tone: FaiPillTone.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: FaiTheme.mono( size: 10, color: theme.colorScheme.onSurfaceVariant, ), ), if (result != null && result!.isSuccess && onRegenerate != null) ...[ const SizedBox(width: FaiSpace.sm), Tooltip( message: l.auditRegenerateTooltip, child: IconButton( icon: const Icon(Icons.refresh, size: 14), visualDensity: VisualDensity.compact, onPressed: onRegenerate, ), ), ], ], ), const SizedBox(height: FaiSpace.sm), if (explaining) Row( children: [ const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ), const SizedBox(width: FaiSpace.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.isNotEmpty) ...[ const SizedBox(height: FaiSpace.sm), Text( l.auditFixLabel, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, ), ), const SizedBox(height: 2), Text( result!.fixHint, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface, ), ), ], ], ], ), ); } } /// Outcome of the clear-audit confirmation dialog. 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 defaults to the OS user (closest stable identity /// without an auth backend); 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(); final user = Platform.environment['USER'] ?? Platform.environment['USERNAME'] ?? 'operator'; _reviewer = TextEditingController(text: '$user@studio'); _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: FaiSpace.lg), TextField( controller: _reviewer, decoration: InputDecoration( labelText: l.auditClearReviewerLabel, border: const OutlineInputBorder(), isDense: true, ), ), const SizedBox(height: FaiSpace.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: _reviewer.text.trim(), reason: _reason.text.trim(), ), ), style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error, foregroundColor: Theme.of(context).colorScheme.onError, ), child: Text(l.auditClearLogButton), ), ), ], ); } }