chain-studio/lib/pages/audit.dart
flemming-it 28fafce7dc
Some checks failed
Security / Security check (push) Failing after 1s
feat(studio): localise Settings sidebar + add inline help docs + Approvals doc
Three operator-UX gaps closed:

  - Settings dialog's six-category sidebar (General / Appearance /
    System AI / Integrations / Security / Maintenance) was
    hardcoded English; now flows through AppLocalizations
    ("Allgemein" / "Darstellung" / "System-KI" / "Integrationen" /
    "Sicherheit" / "Wartung"). Same for the per-panel title +
    description.
  - Per-channel daemon-action labels ('enable autostart' /
    'disable autostart' / 'daemon restart' etc.) and the
    OK / Failed result line in the toast also moved to l10n,
    so the system-action feedback reads as one language.
  - New 'Approvals' doc bundle (en + de) under assets/docs/,
    registered as a fifth doc card on Welcome plus exposed via
    the new public  helper. The
    Approvals, Audit and Doctor app-bars grow a Help icon button
    next to Refresh that opens the matching doc in the existing
    bottom-sheet reader — no extra screen, no learning curve.

Studio bumped to 0.67.0; editor path-override pulls in 0.20.1
(flow-list row polish).

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-06-09 01:57:46 +02:00

1121 lines
36 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import '../data/error_presentation.dart';
import '../data/hub.dart';
import '../l10n/app_localizations.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
import '../widgets/widgets.dart';
import 'welcome.dart' show showFaiDoc;
class AuditPage extends StatefulWidget {
const AuditPage({super.key});
@override
State<AuditPage> createState() => _AuditPageState();
}
class _AuditPageState extends State<AuditPage> {
String _typeFilter = 'all';
List<AuditEvent> _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<void> _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;
showFaiErrorSnack(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 showFaiErrorSnack, which uses friendlyError under the
// hood (with proper gRPC-code mapping + selectable
// copy-affordance).
Future<void> _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.help_outline, size: 18),
tooltip: AppLocalizations.of(context)!.helpTooltip,
onPressed: () => showFaiDoc(context, 'audit'),
),
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 = <String>[];
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<AuditEvent> events;
final List<AuditEvent> allEvents;
final Color Function(String type) toneFor;
final String Function(DateTime ts) formatTime;
final String Function(AuditEvent e) contextLine;
const _GroupedEventList({
required this.events,
required this.allEvents,
required this.toneFor,
required this.formatTime,
required this.contextLine,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final items = _itemsWithHeaders(events, l);
return ListView.builder(
padding: const EdgeInsets.all(FaiSpace.xl),
itemCount: items.length,
itemBuilder: (context, i) {
final item = items[i];
if (item is _GroupHeader) {
return Padding(
padding: EdgeInsets.only(
top: i == 0 ? 0 : FaiSpace.lg,
bottom: FaiSpace.sm,
),
child: Text(
item.label,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
);
}
final e = (item as _EventItem).event;
return Padding(
padding: const EdgeInsets.only(bottom: FaiSpace.xs),
child: FaiDataRow(
accent: toneFor(e.type),
leading: formatTime(e.timestamp),
title: e.type,
subtitle: contextLine(e),
trailing: e.durationMs != null ? '${e.durationMs}ms' : null,
onTap: () => showDialog<void>(
context: context,
builder: (_) =>
_EventDetailDialog(event: e, allEvents: allEvents),
),
),
);
},
);
}
List<_ListItem> _itemsWithHeaders(
List<AuditEvent> events,
AppLocalizations l,
) {
final out = <_ListItem>[];
String? lastBucket;
final now = DateTime.now();
for (final e in events) {
final bucket = _bucketLabel(e.timestamp, now, l);
if (bucket != lastBucket) {
out.add(_GroupHeader(bucket));
lastBucket = bucket;
}
out.add(_EventItem(e));
}
return out;
}
/// Day-bucket label. Comparing in the operator's local
/// timezone so an event at 23:55 yesterday in Berlin doesn't
/// land in "today" because UTC happened to spill into a new
/// day.
static String _bucketLabel(DateTime ts, DateTime now, AppLocalizations l) {
final local = ts.toLocal();
final localNow = now.toLocal();
final today = DateTime(localNow.year, localNow.month, localNow.day);
final eventDay = DateTime(local.year, local.month, local.day);
final daysAgo = today.difference(eventDay).inDays;
if (daysAgo <= 0) return l.auditGroupToday;
if (daysAgo == 1) return l.auditGroupYesterday;
if (daysAgo <= 6) return l.auditGroupThisWeek;
return l.auditGroupOlder;
}
}
sealed class _ListItem {
const _ListItem();
}
class _GroupHeader extends _ListItem {
final String label;
const _GroupHeader(this.label);
}
class _EventItem extends _ListItem {
final AuditEvent event;
const _EventItem(this.event);
}
class _FilterChips extends StatelessWidget {
final String value;
final ValueChanged<String> onChanged;
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<AuditEvent> 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<void> _loadAiStatus() async {
try {
final s = await HubService.instance.systemAiStatus();
if (!mounted) return;
setState(() => _aiStatus = s);
} catch (_) {
// Stay null → "Explain" stays hidden.
}
}
Future<void> _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<void>(
context: context,
builder: (_) => _FlowRunDialog(
flowExecution: event.flowExecution!,
flowName: event.flowName ?? event.flowExecution!,
allEvents: widget.allEvents,
),
);
},
icon: const Icon(Icons.account_tree_outlined, size: 16),
label: Text(l.auditEventViewFlowRun),
),
if (_aiStatus?.enabled == true && (event.error != null))
OutlinedButton.icon(
onPressed: _explaining ? null : _explain,
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<AuditEvent> allEvents;
const _FlowRunDialog({
required this.flowExecution,
required this.flowName,
required this.allEvents,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final related =
allEvents.where((e) => e.flowExecution == flowExecution).toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
final maxHeight = MediaQuery.of(context).size.height * 0.75;
return AlertDialog(
title: Text(l.auditFlowRunDialogTitle(flowName)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.md),
),
content: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 640, maxHeight: maxHeight),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.auditFlowRunDialogSubtitle(related.length, flowExecution),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.md),
Flexible(
child: ListView.separated(
shrinkWrap: true,
itemCount: related.length,
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.xs),
itemBuilder: (context, i) {
final e = related[i];
return FaiDataRow(
accent: _toneFor(e.type, theme),
leading: _formatRelativeTime(e.timestamp),
title: e.type,
subtitle: _stepLine(e),
trailing: e.durationMs != null ? '${e.durationMs}ms' : null,
);
},
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l.buttonClose),
),
],
);
}
String _formatRelativeTime(DateTime ts) {
final local = ts.toLocal();
final hh = local.hour.toString().padLeft(2, '0');
final mm = local.minute.toString().padLeft(2, '0');
final ss = local.second.toString().padLeft(2, '0');
return '$hh:$mm:$ss';
}
String _stepLine(AuditEvent e) {
final parts = <String>[];
if (e.stepId != null) parts.add(e.stepId!);
if (e.moduleName != null) parts.add('via ${e.moduleName}');
if (e.error != null) parts.add('[error: ${e.error}]');
return parts.join(' · ');
}
Color _toneFor(String type, ThemeData theme) {
if (type.endsWith('.failed')) return theme.colorScheme.error;
if (type.endsWith('.completed')) return theme.colorScheme.primary;
if (type.endsWith('.started')) return FaiColors.warning;
return theme.colorScheme.outline;
}
}
class _Field extends StatelessWidget {
final String label;
final String value;
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<TextEditingValue>(
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<TextEditingValue>(
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),
),
),
],
);
}
}