Replaces the Material-default look with a deliberate visual
language. Single accent (sky-cyan), Inter + JetBrains Mono via
google_fonts, dense rows over puffy cards, micro-interactions
under 250ms. Dark-first, light reaches parity.
New foundation under lib/theme/:
- tokens.dart FaiColors / FaiSpace / FaiRadius / FaiMotion
- theme.dart ColorScheme + typography + component themes for
light and dark, plus FaiTheme.mono helper for
technical strings (IDs, paths, capability refs).
Six primitives under lib/widgets/:
- FaiCard flat card, optional accent stripe (top or
left). No shadows.
- FaiPill small inline label with five tones
(neutral / accent / success / warning /
danger), optional mono and leading icon.
- FaiStatusDot breathing dot, used as a "live" indicator.
- FaiDataRow Linear-style dense row for the audit
stream — hover-elevation, mono leading,
coloured leading stripe.
- FaiEmptyState gracious icon + title + hint + action,
replaces "(no data)" everywhere.
- FaiDeltaMark the ∆ signature element. Three modes —
idle (still), live (gentle pulse), busy
(slow rotation). Drawn from primitives,
not a font glyph. Lives in the sidebar
header so the brand is always visible.
Page-level changes:
- main.dart custom 220px sidebar replaces the Material
NavigationRail. ∆ on top, hub-connection
pill below it, hover-animated destinations,
page transitions are 200ms slide+fade.
- modules.dart FaiCard rows with capability pills.
- audit.dart FaiDataRow stream, segmented filter chips,
live status bar with hash-chain badge.
- approvals.dart FaiCard with accent-top stripe, structured
action footer, toast on approve/reject,
themed reject-reason dialog.
google_fonts added as dep. flutter analyze clean. flutter test
2/2. flutter build macos --debug succeeds.
Bumps fai_studio 0.2.0 -> 0.3.0.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
262 lines
7.9 KiB
Dart
262 lines
7.9 KiB
Dart
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> {
|
||
late Future<List<PendingApproval>> _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<void> _approve(PendingApproval 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(PendingApproval 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'),
|
||
actions: [
|
||
IconButton(
|
||
icon: const Icon(Icons.refresh, size: 18),
|
||
tooltip: 'Reload',
|
||
onPressed: _refresh,
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
],
|
||
),
|
||
body: FutureBuilder<List<PendingApproval>>(
|
||
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: _refresh,
|
||
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: () => _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 FaiCard(
|
||
accentTop: true,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
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.showPreview != null) ...[
|
||
const SizedBox(height: FaiSpace.md),
|
||
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: Text(
|
||
approval.showPreview!,
|
||
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 _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';
|
||
}
|
||
}
|