chain-studio/lib/pages/approvals.dart
flemming-it d9aabfd00a feat(studio): Approvals page gains History tab
Pending tab keeps the existing card layout for actionable
approvals. New History tab shows decided rows (approved /
rejected / expired) as compact one-liners, color-coded by
status with reviewer + decided-at metadata. Click expands to
the full record (prompt, payload preview, reason, ids).

Closes the visibility gap from the recent fix: rejected
approvals were correctly written to the hash-chained audit
log but invisible to operators on the Approvals page itself.
HubService gains `listApprovalsRecords(statuses:)` returning
the decided-side fields the Studio wire previously dropped.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-05-07 17:33:00 +02:00

618 lines
19 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: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<ApprovalsPage> createState() => _ApprovalsPageState();
}
class _ApprovalsPageState extends State<ApprovalsPage>
with SingleTickerProviderStateMixin {
late final TabController _tab;
late Future<List<ApprovalRecord>> _pendingFuture;
late Future<List<ApprovalRecord>> _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<void> _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<void> _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<String?> _promptReason(BuildContext context) async {
final controller = TextEditingController();
return showDialog<String>(
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<List<ApprovalRecord>> 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<List<ApprovalRecord>>(
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<List<ApprovalRecord>> future;
final VoidCallback onRetry;
const _HistoryList({required this.future, required this.onRetry});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return FutureBuilder<List<ApprovalRecord>>(
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<void>(
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';
}