A popup menu is width-capped at ~280px, so the full-sentence German entries in the audit overflow menu ran past the edge. The clipped tail of the reset entry was exactly the "(nur local/dev)" scope that keeps it from reading as "delete evidence", and the label's centre landed outside the hit box. Guard in responsive_test: the page-level overflow sweep cannot see popup menus (their width does not follow the window), which is why this went unnoticed. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
1382 lines
46 KiB
Dart
1382 lines
46 KiB
Dart
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/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<AuditEvent> 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<AuditPage> createState() => _AuditPageState();
|
||
}
|
||
|
||
class _AuditPageState extends State<AuditPage> {
|
||
String _typeFilter = 'all';
|
||
String _search = '';
|
||
List<AuditEvent> _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<AuditEvent>? _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;
|
||
_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), () {
|
||
if (mounted && _eventSub == null) _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<AuditEvent> _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<void> _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<void> _onExportAllPressed() async {
|
||
final l = AppLocalizations.of(context)!;
|
||
final List<AuditEvent> 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<void> _saveEventsAsJsonl({
|
||
required List<AuditEvent> 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<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;
|
||
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<void> _refresh() async {
|
||
try {
|
||
final events = await HubService.instance.recentEvents(
|
||
limit: 100,
|
||
project: Workspace.instance.activeSlug,
|
||
);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_events = events;
|
||
_error = null;
|
||
_initialLoaded = true;
|
||
});
|
||
} catch (e) {
|
||
if (!mounted) 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<String>(
|
||
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 = <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('');
|
||
}
|
||
|
||
/// 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<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(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<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);
|
||
}
|
||
|
||
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<String> 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<String>(
|
||
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<String> 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<String>(
|
||
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<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 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<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(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 = <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 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.
|
||
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: ChainSpace.lg),
|
||
TextField(
|
||
controller: _reviewer,
|
||
decoration: InputDecoration(
|
||
labelText: l.auditClearReviewerLabel,
|
||
border: const OutlineInputBorder(),
|
||
isDense: true,
|
||
),
|
||
),
|
||
const SizedBox(height: ChainSpace.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),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|