import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import '../data/hub.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; import '../widgets/widgets.dart'; class ApprovalsPage extends StatefulWidget { const ApprovalsPage({super.key}); @override State createState() => _ApprovalsPageState(); } class _ApprovalsPageState extends State with SingleTickerProviderStateMixin { late final TabController _tab; late Future> _pendingFuture; late Future> _historyFuture; // 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(); _tab = TabController(length: 2, vsync: this); _refresh(); } @override void dispose() { _tab.dispose(); super.dispose(); } void _refresh() { setState(() { _pendingFuture = HubService.instance.listApprovalsRecords( statuses: const ['pending'], ); _historyFuture = HubService.instance.listApprovalsRecords( statuses: const ['approved', 'rejected', 'expired'], limit: 200, ); }); } Future _approve(ApprovalRecord a) async { try { await HubService.instance.approve(a.id, _reviewer); _toast('approved · ${a.flowName} › ${a.stepId}'); _refresh(); } catch (e) { _toast('approve failed: $e'); } } Future _reject(ApprovalRecord a) async { final reason = await _promptReason(context); if (reason == null || reason.isEmpty) return; try { await HubService.instance.reject(a.id, _reviewer, reason); _toast('rejected · ${a.flowName} › ${a.stepId}'); _refresh(); } catch (e) { _toast('reject failed: $e'); } } void _toast(String msg) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); } Future _promptReason(BuildContext context) async { final controller = TextEditingController(); return showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Reject approval'), content: TextField( controller: controller, autofocus: true, decoration: const InputDecoration( labelText: 'Reason (recorded in audit log)', border: OutlineInputBorder(), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, null), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.pop(ctx, controller.text), style: FilledButton.styleFrom( backgroundColor: Theme.of(ctx).colorScheme.error, foregroundColor: Theme.of(ctx).colorScheme.onError, ), child: const Text('Reject'), ), ], ), ); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( backgroundColor: theme.scaffoldBackgroundColor, appBar: AppBar( title: const Text('Approvals'), bottom: TabBar( controller: _tab, tabs: const [ Tab(text: 'Pending'), Tab(text: 'History'), ], ), actions: [ IconButton( icon: const Icon(Icons.refresh, size: 18), tooltip: 'Reload', onPressed: _refresh, ), const SizedBox(width: FaiSpace.sm), ], ), body: TabBarView( controller: _tab, children: [ _PendingList( future: _pendingFuture, onApprove: _approve, onReject: _reject, onRetry: _refresh, ), _HistoryList( future: _historyFuture, onRetry: _refresh, ), ], ), ); } } class _PendingList extends StatelessWidget { final Future> future; final void Function(ApprovalRecord) onApprove; final void Function(ApprovalRecord) onReject; final VoidCallback onRetry; const _PendingList({ required this.future, required this.onApprove, required this.onReject, required this.onRetry, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); return FutureBuilder>( future: future, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { return FaiEmptyState( icon: Icons.cloud_off_outlined, iconColor: theme.colorScheme.error, title: 'Hub unreachable', hint: 'Start the hub with `fai serve`.', action: FilledButton.tonal( onPressed: onRetry, child: const Text('Retry'), ), ); } final pending = snapshot.data ?? []; if (pending.isEmpty) { return const FaiEmptyState( icon: Icons.task_alt_outlined, title: 'Inbox zero', hint: 'All caught up. New `system.approval@^0` steps land here automatically.', ); } return ListView.separated( padding: const EdgeInsets.all(FaiSpace.xl), itemCount: pending.length, separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md), itemBuilder: (context, i) { final a = pending[i]; return _ApprovalCard( approval: a, onApprove: () => onApprove(a), onReject: () => onReject(a), ); }, ); }, ); } } class _HistoryList extends StatelessWidget { final Future> future; final VoidCallback onRetry; const _HistoryList({required this.future, required this.onRetry}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return FutureBuilder>( future: future, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { return FaiEmptyState( icon: Icons.cloud_off_outlined, iconColor: theme.colorScheme.error, title: 'Hub unreachable', hint: 'Start the hub with `fai serve`.', action: FilledButton.tonal( onPressed: onRetry, child: const Text('Retry'), ), ); } final decided = snapshot.data ?? []; if (decided.isEmpty) { return const FaiEmptyState( icon: Icons.history, title: 'No history yet', hint: 'Decided approvals (approved / rejected / expired) appear here.\nThe audit log keeps the full hash-chained record.', ); } return ListView.separated( padding: const EdgeInsets.all(FaiSpace.xl), itemCount: decided.length, separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.xs), itemBuilder: (context, i) { final a = decided[i]; return _HistoryRow(record: a); }, ); }, ); } } class _ApprovalCard extends StatelessWidget { final ApprovalRecord approval; final VoidCallback onApprove; final VoidCallback onReject; const _ApprovalCard({ required this.approval, required this.onApprove, required this.onReject, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); return FaiCard( accentTop: true, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const FaiPill( label: 'pending', tone: FaiPillTone.warning, icon: Icons.pending_outlined, ), const SizedBox(width: FaiSpace.sm), Expanded( child: Text( '${approval.flowName} › ${approval.stepId}', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), ), if (approval.expiresAt != null) FaiPill( label: _expiresIn(approval.expiresAt!), tone: FaiPillTone.neutral, icon: Icons.schedule, ), ], ), const SizedBox(height: FaiSpace.lg), Text( approval.prompt, style: theme.textTheme.bodyLarge, ), if (approval.payloadPreview != null) ...[ const SizedBox(height: FaiSpace.md), Padding( padding: const EdgeInsets.only(bottom: 4), child: Text( 'PAYLOAD PREVIEW', style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, fontSize: 10, ), ), ), 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( _prettyPreview(approval.payloadPreview!), style: FaiTheme.mono( size: 11, color: theme.colorScheme.onSurface, ), ), ), ], const SizedBox(height: FaiSpace.lg), Row( children: [ Text( approval.id, style: FaiTheme.mono( size: 10, color: theme.colorScheme.onSurfaceVariant, ), ), const Spacer(), OutlinedButton.icon( onPressed: onReject, icon: const Icon(Icons.close, size: 16), label: const Text('Reject'), style: OutlinedButton.styleFrom( foregroundColor: theme.colorScheme.error, side: BorderSide( color: theme.colorScheme.error.withValues(alpha: 0.5), ), ), ), const SizedBox(width: FaiSpace.sm), FilledButton.icon( onPressed: onApprove, icon: const Icon(Icons.check, size: 16), label: const Text('Approve'), ), ], ), ], ), ); } 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 _expiresIn(DateTime t) { final remaining = t.difference(DateTime.now()); if (remaining.isNegative) return 'expired'; final minutes = remaining.inMinutes; if (minutes < 60) return '${minutes}m'; final hours = remaining.inHours; return '${hours}h'; } } /// 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(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(FaiRadius.sm), child: Padding( padding: const EdgeInsets.symmetric( horizontal: FaiSpace.sm, vertical: FaiSpace.sm, ), child: Row( children: [ FaiPill(label: label, tone: tone, icon: icon), const SizedBox(width: FaiSpace.md), SizedBox( width: 150, child: Text( timeStr, style: FaiTheme.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: FaiSpace.md), if (record.decidedBy.isNotEmpty) Text( record.decidedBy, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), ); } static (FaiPillTone, IconData, String) _statusPresentation(String status) { switch (status) { case 'approved': return (FaiPillTone.success, Icons.check, 'approved'); case 'rejected': return (FaiPillTone.danger, Icons.close, 'rejected'); case 'expired': return (FaiPillTone.neutral, Icons.timer_off_outlined, 'expired'); default: return (FaiPillTone.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(record.status); return AlertDialog( title: Row( children: [ FaiPill(label: label, tone: tone, icon: icon), const SizedBox(width: FaiSpace.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, 'decided', '${_formatTimestamp(record.decidedAt!.toLocal())} ' '· by ${record.decidedBy.isEmpty ? "(unknown)" : record.decidedBy}'), _kv(theme, 'created', _formatTimestamp(record.createdAt.toLocal())), if (record.reason.isNotEmpty) _kv(theme, 'reason', record.reason), const SizedBox(height: FaiSpace.md), Text( 'PROMPT', style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, ), ), const SizedBox(height: 4), SelectableText( record.prompt, style: theme.textTheme.bodyMedium, ), if (record.payloadPreview != null) ...[ const SizedBox(height: FaiSpace.md), Text( 'PAYLOAD PREVIEW', 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.sm), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(FaiRadius.sm), border: Border.all(color: theme.colorScheme.outlineVariant), ), child: SelectableText( record.payloadPreview!, style: FaiTheme.mono(size: 11), ), ), ], const SizedBox(height: FaiSpace.md), Text( record.id, style: FaiTheme.mono( size: 10, color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Close'), ), ], ); } 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'; }