chain-studio/lib/widgets/fai_delta_mark.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

162 lines
4 KiB
Dart

// FaiDeltaMark — the F∆I signature element. A precise triangle
// (∆) drawn from primitives, not a font glyph. Three states:
//
// - idle: static, accent colour, soft glow
// - live: gentle pulse, like a heartbeat
// - busy: rotates slowly, used while a long operation runs
//
// Sized small. Lives in the navigation rail header so it's
// always visible and identifies the brand without screaming.
import 'dart:math';
import 'package:flutter/material.dart';
enum FaiDeltaMode { idle, live, busy }
class FaiDeltaMark extends StatefulWidget {
final FaiDeltaMode mode;
final double size;
final Color color;
const FaiDeltaMark({
super.key,
required this.color,
this.mode = FaiDeltaMode.idle,
this.size = 36,
});
@override
State<FaiDeltaMark> createState() => _FaiDeltaMarkState();
}
class _FaiDeltaMarkState extends State<FaiDeltaMark>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: _durationFor(widget.mode),
);
_restart();
}
Duration _durationFor(FaiDeltaMode m) {
switch (m) {
case FaiDeltaMode.idle:
return const Duration(seconds: 1);
case FaiDeltaMode.live:
return const Duration(milliseconds: 1600);
case FaiDeltaMode.busy:
return const Duration(seconds: 4);
}
}
void _restart() {
_ctrl.duration = _durationFor(widget.mode);
switch (widget.mode) {
case FaiDeltaMode.idle:
_ctrl.stop();
_ctrl.value = 0;
break;
case FaiDeltaMode.live:
_ctrl.repeat(reverse: true);
break;
case FaiDeltaMode.busy:
_ctrl.repeat();
break;
}
}
@override
void didUpdateWidget(covariant FaiDeltaMark old) {
super.didUpdateWidget(old);
if (old.mode != widget.mode) _restart();
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _ctrl,
builder: (_, _) {
return CustomPaint(
size: Size.square(widget.size),
painter: _DeltaPainter(
color: widget.color,
mode: widget.mode,
t: _ctrl.value,
),
);
},
);
}
}
class _DeltaPainter extends CustomPainter {
final Color color;
final FaiDeltaMode mode;
final double t;
_DeltaPainter({required this.color, required this.mode, required this.t});
@override
void paint(Canvas canvas, Size size) {
final cx = size.width / 2;
final cy = size.height / 2;
final r = size.width * 0.42;
canvas.save();
canvas.translate(cx, cy);
if (mode == FaiDeltaMode.busy) {
canvas.rotate(t * 2 * pi);
}
// Outer triangle path.
final path = Path()
..moveTo(0, -r)
..lineTo(r * cos(pi / 6), r * sin(pi / 6))
..lineTo(-r * cos(pi / 6), r * sin(pi / 6))
..close();
// Glow halo for live / busy mode.
if (mode != FaiDeltaMode.idle) {
final pulseStrength = mode == FaiDeltaMode.live ? t : 1.0;
final glow = Paint()
..color = color.withValues(alpha: 0.25 + 0.25 * pulseStrength)
..maskFilter =
MaskFilter.blur(BlurStyle.normal, 6 + 6 * pulseStrength)
..style = PaintingStyle.fill;
canvas.drawPath(path, glow);
}
// Outline stroke.
final stroke = Paint()
..color = color
..strokeWidth = 2
..style = PaintingStyle.stroke
..strokeJoin = StrokeJoin.round;
canvas.drawPath(path, stroke);
// Inner accent dot — pulses opacity in live mode.
final dotAlpha = mode == FaiDeltaMode.live ? 0.4 + 0.6 * t : 1.0;
final dot = Paint()
..color = color.withValues(alpha: dotAlpha)
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(0, r * 0.15), r * 0.14, dot);
canvas.restore();
}
@override
bool shouldRepaint(covariant _DeltaPainter old) =>
old.t != t || old.mode != mode || old.color != color;
}