import 'package:flutter/material.dart'; import '../data/hub.dart'; class ApprovalsPage extends StatefulWidget { const ApprovalsPage({super.key}); @override State createState() => _ApprovalsPageState(); } class _ApprovalsPageState extends State { late Future> _future; // Hard-coded reviewer for the MVP; Phase 1+ wires this to an // authenticated session. final String _reviewer = 'studio-mvp'; @override void initState() { super.initState(); _future = HubService.instance.pendingApprovals(); } void _refresh() => setState(() { _future = HubService.instance.pendingApprovals(); }); Future _approve(PendingApproval a) async { try { await HubService.instance.approve(a.id, _reviewer); _refresh(); } catch (e) { _showError('approve failed: $e'); } } Future _reject(PendingApproval a) async { final reason = await _promptReason(context); if (reason == null || reason.isEmpty) return; try { await HubService.instance.reject(a.id, _reviewer, reason); _refresh(); } catch (e) { _showError('reject failed: $e'); } } void _showError(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)', ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, null), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.pop(ctx, controller.text), child: const Text('Reject'), ), ], ), ); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( appBar: AppBar( title: const Text('Pending Approvals'), centerTitle: false, actions: [ IconButton( icon: const Icon(Icons.refresh), tooltip: 'Reload', onPressed: _refresh, ), ], ), body: FutureBuilder>( future: _future, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { return Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.cloud_off_outlined, size: 48, color: theme.colorScheme.error, ), const SizedBox(height: 16), Text('Hub unreachable: ${snapshot.error}'), const SizedBox(height: 16), FilledButton.tonal( onPressed: _refresh, child: const Text('Retry'), ), ], ), ), ); } final pending = snapshot.data ?? []; if (pending.isEmpty) { return Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.check_circle_outline, size: 64, color: theme.colorScheme.primary .withValues(alpha: 0.5), ), const SizedBox(height: 16), Text( 'No pending approvals.', style: theme.textTheme.titleMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), ); } return ListView.separated( padding: const EdgeInsets.all(24), itemCount: pending.length, separatorBuilder: (_, _) => const SizedBox(height: 16), itemBuilder: (context, i) { final a = pending[i]; return _ApprovalCard( approval: a, onApprove: () => _approve(a), onReject: () => _reject(a), ); }, ); }, ), ); } } class _ApprovalCard extends StatelessWidget { final PendingApproval 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 Card( elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: BorderSide(color: theme.colorScheme.outlineVariant), ), child: Padding( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( Icons.pending_outlined, color: theme.colorScheme.tertiary, size: 20, ), const SizedBox(width: 8), Text( '${approval.flowName} → ${approval.stepId}', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const Spacer(), if (approval.expiresAt != null) Text( _expiresIn(approval.expiresAt!), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), const SizedBox(height: 16), Text( approval.prompt, style: theme.textTheme.bodyLarge, ), if (approval.showPreview != null) ...[ const SizedBox(height: 12), Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), child: Text( approval.showPreview!, style: theme.textTheme.bodySmall?.copyWith( fontFamily: 'monospace', ), ), ), ], const SizedBox(height: 16), Row( children: [ Text( 'approval id: ${approval.id}', style: theme.textTheme.bodySmall?.copyWith( fontFamily: 'monospace', color: theme.colorScheme.onSurfaceVariant, ), ), const Spacer(), OutlinedButton.icon( onPressed: onReject, icon: const Icon(Icons.close), label: const Text('Reject'), ), const SizedBox(width: 8), FilledButton.icon( onPressed: onApprove, icon: const Icon(Icons.check), label: const Text('Approve'), ), ], ), ], ), ), ); } String _expiresIn(DateTime t) { final remaining = t.difference(DateTime.now()); if (remaining.isNegative) return 'expired'; final minutes = remaining.inMinutes; if (minutes < 60) return 'expires in ${minutes}m'; final hours = remaining.inHours; return 'expires in ${hours}h'; } }