import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.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; /// The fixed English sentence pre-0.21 hubs baked into stored /// approvals when the flow gave no `prompt:`. Newer hubs store the /// empty prompt verbatim. const _legacyHubPromptDefault = 'Please review and approve this step before continuing.'; /// Reviewer-facing prompt with fallbacks: an empty prompt (the flow /// gave none) renders the localized default, and the legacy English /// default from old hub rows is mapped onto the same localized /// default so it stops showing English inside a German UI. String displayApprovalPrompt(AppLocalizations l, String prompt) { final trimmed = prompt.trim(); if (trimmed.isEmpty || trimmed == _legacyHubPromptDefault) { return l.approvalsRequestFallback; } return prompt; } class ApprovalsPage extends StatefulWidget { const ApprovalsPage({super.key}); @override State createState() => _ApprovalsPageState(); } class _ApprovalsPageState extends State { /// 0 = pending inbox, 1 = history. Plain index instead of a /// TabController: the page uses the canonical pill segment /// (ChainSegments), and an IndexedStack keeps both lists alive /// so switching does not refetch. int _tabIndex = 0; late Future> _pendingFuture; late Future> _historyFuture; /// IDs the operator currently has multi-selected for a /// batch operation. Empty set hides the batch action bar /// and falls back to per-row Approve / Reject buttons. final Set _selectedIds = {}; bool _batchInFlight = false; // Reviewer identity recorded in the audit log. Defaults to // the OS user (closest stable identity Studio has without an // auth backend); operators can override it per session. late final String _reviewer = _defaultReviewer(); static String _defaultReviewer() { final user = Platform.environment['USER'] ?? Platform.environment['USERNAME'] ?? 'studio'; return '$user@studio'; } @override void initState() { super.initState(); Workspace.instance.addListener(_refresh); Workspace.instance.ensureLoaded(); _refresh(); } @override void dispose() { Workspace.instance.removeListener(_refresh); super.dispose(); } void _refresh() { if (!mounted) return; final project = Workspace.instance.activeSlug; setState(() { _pendingFuture = HubService.instance.listApprovalsRecords( statuses: const ['pending'], project: project, ); _historyFuture = HubService.instance.listApprovalsRecords( statuses: const ['approved', 'rejected', 'expired'], limit: 200, project: project, ); // Only the visible tab has a FutureBuilder listening; give the // hidden tab's future a silent listener so a load failure there // never surfaces as an uncaught async error (the FutureBuilder // that attaches on tab switch still receives the error). _pendingFuture.then((_) {}, onError: (_) {}); _historyFuture.then((_) {}, onError: (_) {}); }); } Future _approve(ApprovalRecord a) async { final l = AppLocalizations.of(context)!; // No `show:` data attached → never approve on a reflex. Calmly // explain and ask for a conscious confirmation first. if (a.payloadPreview == null) { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(l.approvalsNoDataConfirmTitle), content: Text(l.approvalsNoDataConfirmBody), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: Text(l.buttonCancel), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), child: Text(l.approvalsNoDataConfirmAction), ), ], ), ); if (confirmed != true) return; } if (!mounted) return; try { await HubService.instance.approve(a.id, _reviewer); _toast(l.approvalsApprovedToast(a.flowName, a.stepId)); _refresh(); } catch (e) { _toast(l.approvalsApproveFailed(e.toString())); } } Future _reject(ApprovalRecord a) async { final l = AppLocalizations.of(context)!; final reason = await _promptReason(context); if (reason == null || reason.isEmpty) return; try { await HubService.instance.reject(a.id, _reviewer, reason); _toast(l.approvalsRejectedToast(a.flowName, a.stepId)); _refresh(); } catch (e) { _toast(l.approvalsRejectFailed(e.toString())); } } void _toggleSelection(String id) { setState(() { if (!_selectedIds.add(id)) _selectedIds.remove(id); }); } void _selectAll(Iterable all) { setState(() { _selectedIds ..clear() ..addAll(all.map((a) => a.id)); }); } void _clearSelection() => setState(() => _selectedIds.clear()); /// Loops the picked records sequentially through the /// per-record approve / reject calls so a partial failure /// surfaces with a clear "X done, Y failed" toast rather /// than a confusing all-or-nothing rollback. Selection /// drains as items succeed. Future _batchApprove(List picked) async { final l = AppLocalizations.of(context)!; setState(() => _batchInFlight = true); var ok = 0; var failed = 0; for (final a in picked) { try { await HubService.instance.approve(a.id, _reviewer); ok += 1; _selectedIds.remove(a.id); } catch (_) { failed += 1; } } if (!mounted) return; setState(() => _batchInFlight = false); _toast( failed == 0 ? l.approvalsBatchApproveDoneToast(ok) : l.approvalsBatchPartialFailure(ok, failed), ); _refresh(); } Future _batchReject(List picked) async { final l = AppLocalizations.of(context)!; final reason = await _promptReason(context); if (reason == null || reason.isEmpty) return; setState(() => _batchInFlight = true); var ok = 0; var failed = 0; for (final a in picked) { try { await HubService.instance.reject(a.id, _reviewer, reason); ok += 1; _selectedIds.remove(a.id); } catch (_) { failed += 1; } } if (!mounted) return; setState(() => _batchInFlight = false); _toast( failed == 0 ? l.approvalsBatchRejectDoneToast(ok) : l.approvalsBatchPartialFailure(ok, failed), ); _refresh(); } void _toast(String msg) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); } Future _promptReason(BuildContext context) async { final controller = TextEditingController(); final l = AppLocalizations.of(context)!; return showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(l.approvalsRejectDialogTitle), content: TextField( controller: controller, autofocus: true, decoration: InputDecoration( labelText: l.approvalsRejectReasonLabel, border: const OutlineInputBorder(), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, null), child: Text(l.buttonCancel), ), FilledButton( onPressed: () => Navigator.pop(ctx, controller.text), style: FilledButton.styleFrom( backgroundColor: Theme.of(ctx).colorScheme.error, foregroundColor: Theme.of(ctx).colorScheme.onError, ), child: Text(l.approvalsRejectButton), ), ], ), ); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( backgroundColor: theme.scaffoldBackgroundColor, appBar: AppBar( title: Text(AppLocalizations.of(context)!.approvalsTitle), bottom: PreferredSize( preferredSize: const Size.fromHeight(44), child: Align( alignment: Alignment.centerLeft, child: Padding( padding: const EdgeInsets.fromLTRB( ChainSpace.xl, 0, ChainSpace.xl, ChainSpace.sm, ), child: ChainSegments( items: [ ChainSegmentItem( 0, AppLocalizations.of(context)!.approvalsTabPending, ), ChainSegmentItem( 1, AppLocalizations.of(context)!.approvalsTabHistory, ), ], value: _tabIndex, onChanged: (i) => setState(() => _tabIndex = i), ), ), ), ), actions: [ const ChainWorkspaceSwitcher(), const SizedBox(width: ChainSpace.md), IconButton( icon: const Icon(Icons.help_outline, size: 18), tooltip: AppLocalizations.of(context)!.helpTooltip, onPressed: () => showFaiDoc(context, 'approvals'), ), IconButton( icon: const Icon(Icons.refresh, size: 18), tooltip: AppLocalizations.of(context)!.approvalsReloadTooltip, onPressed: _refresh, ), const SizedBox(width: ChainSpace.sm), ], ), body: IndexedStack( index: _tabIndex, children: [ _PendingList( future: _pendingFuture, selectedIds: _selectedIds, batchInFlight: _batchInFlight, onApprove: _approve, onReject: _reject, onToggle: _toggleSelection, onSelectAll: _selectAll, onClearSelection: _clearSelection, onBatchApprove: _batchApprove, onBatchReject: _batchReject, onRetry: _refresh, ), _HistoryList(future: _historyFuture, onRetry: _refresh), ], ), ); } } class _PendingList extends StatelessWidget { final Future> future; final Set selectedIds; final bool batchInFlight; final void Function(ApprovalRecord) onApprove; final void Function(ApprovalRecord) onReject; final void Function(String id) onToggle; final void Function(Iterable) onSelectAll; final VoidCallback onClearSelection; final Future Function(List) onBatchApprove; final Future Function(List) onBatchReject; final VoidCallback onRetry; const _PendingList({ required this.future, required this.selectedIds, required this.batchInFlight, required this.onApprove, required this.onReject, required this.onToggle, required this.onSelectAll, required this.onClearSelection, required this.onBatchApprove, required this.onBatchReject, required this.onRetry, }); @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; return FutureBuilder>( future: future, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { return HubLoadErrorView(error: snapshot.error!, onRetry: onRetry); } final pending = snapshot.data ?? []; if (pending.isEmpty) { return ChainEmptyState( icon: Icons.task_alt_outlined, title: l.approvalsInboxZero, hint: l.approvalsInboxHint, ); } final selected = pending .where((a) => selectedIds.contains(a.id)) .toList(); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // A calm one-line explainer so a first-time reviewer knows // what this inbox is and what Approve / Reject actually do, // without opening the doc sheet. Padding( padding: const EdgeInsets.fromLTRB( ChainSpace.xl, ChainSpace.lg, ChainSpace.xl, 0, ), child: ChainInlineHelp(text: l.approvalsIntroHelp), ), Expanded( child: Stack( children: [ ListView.separated( padding: EdgeInsets.fromLTRB( ChainSpace.xl, ChainSpace.md, ChainSpace.xl, // Keep the last card clear of the floating // batch action bar. selected.isEmpty ? ChainSpace.xl : 96.0, ), itemCount: pending.length, separatorBuilder: (_, _) => const SizedBox(height: ChainSpace.md), itemBuilder: (context, i) { final a = pending[i]; final isSelected = selectedIds.contains(a.id); return _ApprovalCard( approval: a, selected: isSelected, onToggleSelected: () => onToggle(a.id), onApprove: () => onApprove(a), onReject: () => onReject(a), ); }, ), if (selected.isNotEmpty) Positioned( left: ChainSpace.xl, right: ChainSpace.xl, bottom: ChainSpace.lg, child: _BatchActionBar( selectedCount: selected.length, totalCount: pending.length, inFlight: batchInFlight, onSelectAll: () => onSelectAll(pending), onClear: onClearSelection, onApprove: () => onBatchApprove(selected), onReject: () => onBatchReject(selected), ), ), ], ), ), ], ); }, ); } } /// Floating action bar that surfaces when the operator /// multi-selects pending approvals. Lets them approve or /// reject the whole picked set in one round-trip per item; /// the parent loops sequentially so a partial failure stays /// visible per row. class _BatchActionBar extends StatelessWidget { final int selectedCount; final int totalCount; final bool inFlight; final VoidCallback onSelectAll; final VoidCallback onClear; final VoidCallback onApprove; final VoidCallback onReject; const _BatchActionBar({ required this.selectedCount, required this.totalCount, required this.inFlight, required this.onSelectAll, required this.onClear, required this.onApprove, required this.onReject, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return Material( elevation: 4, borderRadius: BorderRadius.circular(ChainRadius.md), color: theme.colorScheme.surfaceContainerHigh, child: Container( padding: const EdgeInsets.symmetric( horizontal: ChainSpace.lg, vertical: ChainSpace.md, ), decoration: BoxDecoration( borderRadius: BorderRadius.circular(ChainRadius.md), border: Border.all(color: theme.colorScheme.outlineVariant), ), child: Row( children: [ Icon( Icons.check_box_outlined, size: 18, color: theme.colorScheme.primary, ), const SizedBox(width: ChainSpace.sm), Text( l.approvalsBatchSelected(selectedCount), style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(width: ChainSpace.md), if (selectedCount < totalCount) TextButton( onPressed: inFlight ? null : onSelectAll, child: Text(l.approvalsSelectAll), ), TextButton( onPressed: inFlight ? null : onClear, child: Text(l.approvalsClearSelection), ), const Spacer(), OutlinedButton.icon( onPressed: inFlight ? null : onReject, icon: const Icon(Icons.close, size: 14), label: Text(l.approvalsBatchReject), style: OutlinedButton.styleFrom( foregroundColor: theme.colorScheme.error, side: BorderSide( color: theme.colorScheme.error.withValues(alpha: 0.5), ), ), ), const SizedBox(width: ChainSpace.sm), FilledButton.icon( onPressed: inFlight ? null : onApprove, icon: inFlight ? const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.check, size: 14), label: Text(l.approvalsBatchApprove), ), ], ), ), ); } } class _HistoryList extends StatelessWidget { final Future> future; final VoidCallback onRetry; const _HistoryList({required this.future, required this.onRetry}); @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; return FutureBuilder>( future: future, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { return HubLoadErrorView(error: snapshot.error!, onRetry: onRetry); } final decided = snapshot.data ?? []; if (decided.isEmpty) { return ChainEmptyState( icon: Icons.history, title: l.approvalsHistoryEmpty, hint: l.approvalsHistoryEmptyHint, ); } return ListView.separated( padding: const EdgeInsets.all(ChainSpace.xl), itemCount: decided.length, separatorBuilder: (_, _) => const SizedBox(height: ChainSpace.xs), itemBuilder: (context, i) { final a = decided[i]; return _HistoryRow(record: a); }, ); }, ); } } class _ApprovalCard extends StatelessWidget { final ApprovalRecord approval; final bool selected; final VoidCallback onToggleSelected; final VoidCallback onApprove; final VoidCallback onReject; const _ApprovalCard({ required this.approval, required this.selected, required this.onToggleSelected, required this.onApprove, required this.onReject, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; final metaStyle = theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ); // One origin fact per line: small leading icon, the value beside // it. Keeps the "where does this come from" block scannable. Widget originLine(IconData icon, Widget child) => Padding( padding: const EdgeInsets.only(top: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(icon, size: 13, color: theme.colorScheme.onSurfaceVariant), const SizedBox(width: 6), Expanded(child: child), ], ), ); return ChainCard( accentTop: true, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Status row: selection + state + expiry. No flow id here — // the headline below leads with WHAT is being asked, not the // technical flow name. Row( children: [ Checkbox( value: selected, onChanged: (_) => onToggleSelected(), visualDensity: VisualDensity.compact, ), const SizedBox(width: ChainSpace.xs), ChainPill( label: l.approvalsPillPending, tone: ChainPillTone.warning, icon: Icons.pending_outlined, ), const Spacer(), if (approval.expiresAt != null) ChainPill( label: _expiresInLabel(context, approval.expiresAt!), tone: ChainPillTone.neutral, icon: Icons.schedule, ), ], ), const SizedBox(height: ChainSpace.lg), // Headline = the human question the flow author wrote ("what // am I approving"). Falls back to a clear sentence when the // step left the prompt empty, so the card is never reduced to // a cryptic flow id. Text( displayApprovalPrompt(l, approval.prompt), style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: ChainSpace.md), // Origin block — the "which flow, which step, which run, in // which project, requested when" a reviewer needs to place // the request in context. The run id is copyable so it can // be cross-referenced against the event timeline on the // Audit page. Text( l.approvalsOriginLabel, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, fontSize: 10, ), ), originLine( Icons.account_tree_outlined, Text( l.approvalsFlowStepMeta(approval.flowName, approval.stepId), style: metaStyle, ), ), if (approval.project.isNotEmpty) originLine( Icons.folder_outlined, Text( '${l.approvalsOriginProject}: ${approval.project}', style: metaStyle, ), ), originLine( Icons.schedule, Text( '${l.approvalsOriginRequested}: ' '${_formatTimestamp(approval.createdAt.toLocal())}', style: metaStyle, ), ), if (approval.flowExecution != null) originLine( Icons.tag_outlined, SelectableText( '${l.approvalsOriginRun}: ${approval.flowExecution}', style: ChainTheme.mono( size: 11, color: theme.colorScheme.onSurfaceVariant, ), ), ), const SizedBox(height: ChainSpace.md), Padding( padding: const EdgeInsets.only(bottom: 4), child: Text( l.approvalsPayloadPreview, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, fontSize: 10, ), ), ), if (approval.payloadPreview != null) Container( width: double.infinity, // Cap the height so a large payload scrolls inside the // card instead of stretching it off-screen; still fully // readable + copyable (SelectableText). constraints: const BoxConstraints(maxHeight: 280), 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: SingleChildScrollView( child: SelectableText( _prettyPreview(approval.payloadPreview!), style: ChainTheme.mono( size: 11, color: theme.colorScheme.onSurface, ), ), ), ) else // No `show:` on the approval step → explain where the data // would come from instead of rendering nothing. 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: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( Icons.info_outline, size: 14, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: ChainSpace.sm), Expanded( child: Text( l.approvalsNoPayload, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), ], ), ), const SizedBox(height: ChainSpace.lg), Row( children: [ Text( approval.id, style: ChainTheme.mono( size: 10, color: theme.colorScheme.onSurfaceVariant, ), ), const Spacer(), Tooltip( message: l.approvalsRejectTooltip, child: OutlinedButton.icon( onPressed: onReject, icon: const Icon(Icons.close, size: 16), label: Text(l.approvalsRejectButton), style: OutlinedButton.styleFrom( foregroundColor: theme.colorScheme.error, side: BorderSide( color: theme.colorScheme.error.withValues(alpha: 0.5), ), ), ), ), const SizedBox(width: ChainSpace.sm), Tooltip( message: l.approvalsApproveTooltip, child: FilledButton.icon( onPressed: onApprove, icon: const Icon(Icons.check, size: 16), label: Text(l.approvalsApproveButton), ), ), ], ), ], ), ); } String _prettyPreview(String raw) { if (raw.isEmpty) return raw; try { final dynamic parsed = const JsonDecoder().convert(raw); return const JsonEncoder.withIndent(' ').convert(parsed); } catch (_) { return raw; } } String _expiresInLabel(BuildContext context, DateTime t) { final l = AppLocalizations.of(context)!; final remaining = t.difference(DateTime.now()); if (remaining.isNegative) return l.approvalsPillExpired; final minutes = remaining.inMinutes; if (minutes < 60) return l.approvalsExpiresIn(minutes); return l.approvalsExpiresInHours(remaining.inHours); } } /// Compact one-line entry for a decided approval. Click expands /// the prompt, decision metadata, and payload preview in a /// dialog. Keeps the History tab scannable when there are many /// decisions. class _HistoryRow extends StatelessWidget { final ApprovalRecord record; const _HistoryRow({required this.record}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final (tone, icon, label) = _statusPresentation(context, record.status); final decided = record.decidedAt; final timeStr = decided == null ? '' : _formatTimestamp(decided.toLocal()); return InkWell( onTap: () => showDialog( context: context, builder: (_) => _HistoryDialog(record: record), ), borderRadius: BorderRadius.circular(ChainRadius.sm), child: Padding( padding: const EdgeInsets.symmetric( horizontal: ChainSpace.sm, vertical: ChainSpace.sm, ), child: Row( children: [ ChainPill(label: label, tone: tone, icon: icon), const SizedBox(width: ChainSpace.md), SizedBox( width: 150, child: Text( timeStr, style: ChainTheme.mono( size: 11, color: theme.colorScheme.onSurfaceVariant, ), overflow: TextOverflow.ellipsis, ), ), Expanded( child: Text( '${record.flowName} › ${record.stepId}', style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w500, ), overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: ChainSpace.md), if (record.decidedBy.isNotEmpty) Text( record.decidedBy, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), ); } static (ChainPillTone, IconData, String) _statusPresentation( BuildContext context, String status, ) { final l = AppLocalizations.of(context)!; switch (status) { case 'approved': return (ChainPillTone.success, Icons.check, l.approvalsPillApproved); case 'rejected': return (ChainPillTone.danger, Icons.close, l.approvalsPillRejected); case 'expired': return ( ChainPillTone.neutral, Icons.timer_off_outlined, l.approvalsPillExpired, ); default: return (ChainPillTone.neutral, Icons.help_outline, status); } } } class _HistoryDialog extends StatelessWidget { final ApprovalRecord record; const _HistoryDialog({required this.record}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final (tone, icon, label) = _HistoryRow._statusPresentation( context, record.status, ); return AlertDialog( title: Row( children: [ ChainPill(label: label, tone: tone, icon: icon), const SizedBox(width: ChainSpace.sm), Expanded( child: Text( '${record.flowName} › ${record.stepId}', style: theme.textTheme.titleMedium, overflow: TextOverflow.ellipsis, ), ), ], ), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 540, maxHeight: 480), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (record.decidedAt != null) _kv( theme, AppLocalizations.of(context)!.approvalsDialogDecided, '${_formatTimestamp(record.decidedAt!.toLocal())} ' '· ${record.decidedBy.isEmpty ? "(unknown)" : record.decidedBy}', ), _kv( theme, AppLocalizations.of(context)!.approvalsDialogCreated, _formatTimestamp(record.createdAt.toLocal()), ), if (record.project.isNotEmpty) _kv( theme, AppLocalizations.of(context)!.approvalsOriginProject, record.project, ), if (record.flowExecution != null) _kv( theme, AppLocalizations.of(context)!.approvalsOriginRun, record.flowExecution!, ), if (record.reason.isNotEmpty) _kv( theme, AppLocalizations.of(context)!.approvalsDialogReason, record.reason, ), const SizedBox(height: ChainSpace.md), Text( AppLocalizations.of(context)!.approvalsDialogPrompt, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, ), ), const SizedBox(height: 4), SelectableText( displayApprovalPrompt( AppLocalizations.of(context)!, record.prompt, ), style: theme.textTheme.bodyMedium, ), if (record.payloadPreview != null) ...[ const SizedBox(height: ChainSpace.md), Text( AppLocalizations.of(context)!.approvalsPayloadPreview, 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.sm), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(ChainRadius.sm), border: Border.all(color: theme.colorScheme.outlineVariant), ), child: SelectableText( record.payloadPreview!, style: ChainTheme.mono(size: 11), ), ), ], const SizedBox(height: ChainSpace.md), Text( record.id, style: ChainTheme.mono( size: 10, color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(AppLocalizations.of(context)!.buttonClose), ), ], ); } Widget _kv(ThemeData theme, String k, String v) { return Padding( padding: const EdgeInsets.only(bottom: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 70, child: Text( k, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, ), ), ), Expanded(child: SelectableText(v, style: theme.textTheme.bodySmall)), ], ), ); } } /// Locale-unambiguous YYYY-MM-DD HH:mm:ss formatter shared by /// the history row + dialog. Always renders in the operator's /// local time zone — ISO timestamps from the wire still appear /// raw in the per-record dialog for cross-checking. String _formatTimestamp(DateTime local) { final yyyy = local.year.toString().padLeft(4, '0'); final mo = local.month.toString().padLeft(2, '0'); final dd = local.day.toString().padLeft(2, '0'); 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 '$yyyy-$mo-$dd $hh:$mm:$ss'; }