chain-studio/lib/pages/audit.dart
flemming-it c94504247f feat(ui): warm-minimalism redesign — design tokens + 6 primitives + animated ∆
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>
2026-05-05 21:49:37 +02:00

299 lines
8.7 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:async';
import 'package:flutter/material.dart';
import '../data/hub.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
import '../widgets/widgets.dart';
class AuditPage extends StatefulWidget {
const AuditPage({super.key});
@override
State<AuditPage> createState() => _AuditPageState();
}
class _AuditPageState extends State<AuditPage> {
String _typeFilter = 'all';
List<AuditEvent> _events = const [];
String? _error;
bool _initialLoaded = false;
Timer? _poller;
@override
void initState() {
super.initState();
_refresh();
_poller = Timer.periodic(
const Duration(seconds: 2),
(_) => _refresh(),
);
}
@override
void dispose() {
_poller?.cancel();
super.dispose();
}
Future<void> _refresh() async {
try {
final events = await HubService.instance.recentEvents(limit: 100);
if (!mounted) return;
setState(() {
_events = events;
_error = null;
_initialLoaded = true;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_initialLoaded = true;
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final filtered = _typeFilter == 'all'
? _events
: _events.where((e) => e.type.startsWith(_typeFilter)).toList();
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
appBar: AppBar(
title: const Text('Audit'),
actions: [
Padding(
padding: const EdgeInsets.only(right: FaiSpace.lg),
child: _FilterChips(
value: _typeFilter,
onChanged: (v) => setState(() => _typeFilter = v),
),
),
],
),
body: Column(
children: [
_LiveStatusBar(eventCount: filtered.length, error: _error),
Expanded(
child: !_initialLoaded
? const Center(child: CircularProgressIndicator())
: _error != null && _events.isEmpty
? FaiEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: theme.colorScheme.error,
title: 'Hub unreachable',
hint: 'Start the hub with `fai serve`.',
)
: filtered.isEmpty
? const FaiEmptyState(
icon: Icons.timeline_outlined,
title: 'No events yet',
hint:
'Run a flow to populate the audit stream.\nEvents appear here within seconds.',
)
: ListView.separated(
padding: const EdgeInsets.all(FaiSpace.xl),
itemCount: filtered.length,
separatorBuilder: (_, _) =>
const SizedBox(height: FaiSpace.xs),
itemBuilder: (context, i) {
final e = filtered[i];
return FaiDataRow(
accent: _toneFor(e.type, theme),
leading: _formatTime(e.timestamp),
title: e.type,
subtitle: _contextLine(e),
trailing: e.durationMs != null
? '${e.durationMs}ms'
: null,
);
},
),
),
],
),
);
}
String _formatTime(DateTime ts) =>
'${ts.hour.toString().padLeft(2, '0')}:${ts.minute.toString().padLeft(2, '0')}:${ts.second.toString().padLeft(2, '0')}';
String _contextLine(AuditEvent e) {
final parts = <String>[];
if (e.flowName != null) parts.add(e.flowName!);
if (e.stepId != null) parts.add(' ${e.stepId}');
if (e.moduleName != null) parts.add(' via ${e.moduleName}');
if (e.error != null) parts.add(' [error: ${e.error}]');
return parts.join('');
}
Color _toneFor(String type, ThemeData theme) {
if (type.endsWith('.failed')) return theme.colorScheme.error;
if (type.endsWith('.completed')) return theme.colorScheme.primary;
if (type.endsWith('.started')) return FaiColors.warning;
return theme.colorScheme.outline;
}
}
class _FilterChips extends StatelessWidget {
final String value;
final ValueChanged<String> onChanged;
const _FilterChips({required this.value, required this.onChanged});
@override
Widget build(BuildContext context) {
const items = [
('all', 'all'),
('flow.', 'flow'),
('step.', 'step'),
('module.', 'module'),
];
return Row(
children: [
for (final (v, label) in items)
Padding(
padding: const EdgeInsets.only(left: FaiSpace.xs),
child: _ChipButton(
label: label,
selected: value == v,
onTap: () => onChanged(v),
),
),
],
);
}
}
class _ChipButton extends StatefulWidget {
final String label;
final bool selected;
final VoidCallback onTap;
const _ChipButton({
required this.label,
required this.selected,
required this.onTap,
});
@override
State<_ChipButton> createState() => _ChipButtonState();
}
class _ChipButtonState extends State<_ChipButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final fg = widget.selected
? theme.colorScheme.primary
: _hovered
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant;
final bg = widget.selected
? theme.colorScheme.primary.withValues(alpha: 0.12)
: _hovered
? theme.colorScheme.surfaceContainerHigh
: Colors.transparent;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: FaiMotion.fast,
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.md,
vertical: 6,
),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(
color: widget.selected
? theme.colorScheme.primary.withValues(alpha: 0.3)
: Colors.transparent,
),
),
child: Text(
widget.label,
style: FaiTheme.mono(
size: 11,
weight: widget.selected ? FontWeight.w500 : FontWeight.w400,
color: fg,
),
),
),
),
);
}
}
class _LiveStatusBar extends StatelessWidget {
final int eventCount;
final String? error;
const _LiveStatusBar({required this.eventCount, required this.error});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final live = error == null;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.xl,
vertical: FaiSpace.sm,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
border: Border(
bottom: BorderSide(color: theme.colorScheme.outlineVariant),
),
),
child: Row(
children: [
FaiStatusDot(
color: live ? FaiColors.success : FaiColors.danger,
pulsing: live,
),
const SizedBox(width: FaiSpace.sm),
Text(
live
? 'live · polling 2s · $eventCount event${eventCount == 1 ? '' : 's'}'
: 'disconnected · $error',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const Spacer(),
if (live)
Row(
children: [
Icon(
Icons.shield_outlined,
size: 12,
color: theme.colorScheme.primary,
),
const SizedBox(width: 4),
Text(
'hash chain verified',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
],
),
],
),
);
}
}