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>
This commit is contained in:
parent
127c497f73
commit
c94504247f
23 changed files with 1951 additions and 468 deletions
83
lib/widgets/fai_card.dart
Normal file
83
lib/widgets/fai_card.dart
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// FaiCard — flat card with subtle accent gradient on the top
|
||||
// border. Replaces Material's default Card for the F∆I look.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
class FaiCard extends StatelessWidget {
|
||||
/// Card body. Padding is applied internally; pass plain content.
|
||||
final Widget child;
|
||||
|
||||
/// When true, paint a 2px accent-coloured stripe along the top
|
||||
/// edge. Used by foreground / "this matters" cards (a pending
|
||||
/// approval, a failed step).
|
||||
final bool accentTop;
|
||||
|
||||
/// When true, paint a faint vertical accent stripe on the left.
|
||||
/// Used by status rows (live event, healthy service).
|
||||
final Color? accentLeft;
|
||||
|
||||
/// Internal padding. Defaults to [FaiSpace.lg].
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
const FaiCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.accentTop = false,
|
||||
this.accentLeft,
|
||||
this.padding = const EdgeInsets.all(FaiSpace.lg),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (accentTop)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 2,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
theme.colorScheme.primary.withValues(alpha: 0.0),
|
||||
theme.colorScheme.primary,
|
||||
theme.colorScheme.primary.withValues(alpha: 0.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (accentLeft != null)
|
||||
Positioned(
|
||||
top: FaiSpace.md,
|
||||
bottom: FaiSpace.md,
|
||||
left: 0,
|
||||
width: 3,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: accentLeft,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topRight: Radius.circular(2),
|
||||
bottomRight: Radius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(padding: padding, child: child),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
134
lib/widgets/fai_data_row.dart
Normal file
134
lib/widgets/fai_data_row.dart
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// FaiDataRow — Linear-style dense list row used by the audit
|
||||
// stream. Hover-elevation, accent-coloured leading stripe, mono
|
||||
// timestamp, expressive type badge.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/theme.dart';
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
class FaiDataRow extends StatefulWidget {
|
||||
/// Coloured leading stripe (4px). Used to encode event type
|
||||
/// (success / warning / failure) at a glance.
|
||||
final Color accent;
|
||||
|
||||
/// Left-most column: a short, monospaced label (timestamp, id).
|
||||
final String leading;
|
||||
|
||||
/// Title in the middle. Usually the event type, in mono.
|
||||
final String title;
|
||||
|
||||
/// Subtitle on the right of title. Free-form, lighter colour.
|
||||
final String subtitle;
|
||||
|
||||
/// Right-most column: trailing label (duration, etc).
|
||||
final String? trailing;
|
||||
|
||||
/// Optional tap handler.
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const FaiDataRow({
|
||||
super.key,
|
||||
required this.accent,
|
||||
required this.leading,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FaiDataRow> createState() => _FaiDataRowState();
|
||||
}
|
||||
|
||||
class _FaiDataRowState extends State<FaiDataRow> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
cursor: widget.onTap != null
|
||||
? SystemMouseCursors.click
|
||||
: SystemMouseCursors.basic,
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: AnimatedContainer(
|
||||
duration: FaiMotion.fast,
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered
|
||||
? theme.colorScheme.surfaceContainerHigh
|
||||
: theme.colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
border: Border.all(
|
||||
color: _hovered
|
||||
? theme.colorScheme.outline
|
||||
: theme.colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: FaiSpace.md,
|
||||
vertical: FaiSpace.sm,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 3,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.accent,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.md),
|
||||
SizedBox(
|
||||
width: 88,
|
||||
child: Text(
|
||||
widget.leading,
|
||||
style: FaiTheme.mono(
|
||||
size: 11,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 200,
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: FaiTheme.mono(
|
||||
size: 12,
|
||||
weight: FontWeight.w500,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.subtitle,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (widget.trailing != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: FaiSpace.md),
|
||||
child: Text(
|
||||
widget.trailing!,
|
||||
style: FaiTheme.mono(
|
||||
size: 11,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
162
lib/widgets/fai_delta_mark.dart
Normal file
162
lib/widgets/fai_delta_mark.dart
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
// 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;
|
||||
}
|
||||
75
lib/widgets/fai_empty_state.dart
Normal file
75
lib/widgets/fai_empty_state.dart
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
// FaiEmptyState — gracious empty / loading / error placeholder.
|
||||
// Replaces "(no data)" strings with one tone, one icon, one tip.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
class FaiEmptyState extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String? hint;
|
||||
final Widget? action;
|
||||
|
||||
/// Icon tone. Defaults to muted; set to [Theme.colorScheme.error]
|
||||
/// for connection-failure variants.
|
||||
final Color? iconColor;
|
||||
|
||||
const FaiEmptyState({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.hint,
|
||||
this.action,
|
||||
this.iconColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final color = iconColor ?? theme.colorScheme.onSurfaceVariant;
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(FaiSpace.xxl),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, size: 24, color: color),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
if (hint != null) ...[
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
Text(
|
||||
hint!,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (action != null) ...[
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
action!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
90
lib/widgets/fai_pill.dart
Normal file
90
lib/widgets/fai_pill.dart
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// FaiPill — small inline label. Used for capabilities, versions,
|
||||
// permission scopes, status text. Replaces ad-hoc Container chips.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/theme.dart';
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
enum FaiPillTone {
|
||||
neutral,
|
||||
accent,
|
||||
success,
|
||||
warning,
|
||||
danger,
|
||||
}
|
||||
|
||||
class FaiPill extends StatelessWidget {
|
||||
final String label;
|
||||
final FaiPillTone tone;
|
||||
final IconData? icon;
|
||||
final bool monospace;
|
||||
|
||||
const FaiPill({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.tone = FaiPillTone.neutral,
|
||||
this.icon,
|
||||
this.monospace = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final (bg, fg) = _colors(theme.colorScheme);
|
||||
final textStyle = monospace
|
||||
? FaiTheme.mono(size: 11, weight: FontWeight.w500, color: fg)
|
||||
: theme.textTheme.labelSmall?.copyWith(color: fg);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: FaiSpace.sm,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 11, color: fg),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Text(label, style: textStyle),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
(Color, Color) _colors(ColorScheme scheme) {
|
||||
switch (tone) {
|
||||
case FaiPillTone.neutral:
|
||||
return (scheme.surfaceContainerHighest, scheme.onSurfaceVariant);
|
||||
case FaiPillTone.accent:
|
||||
return (
|
||||
scheme.primary.withValues(alpha: 0.15),
|
||||
scheme.primary,
|
||||
);
|
||||
case FaiPillTone.success:
|
||||
return (
|
||||
FaiColors.success.withValues(alpha: 0.15),
|
||||
scheme.brightness == Brightness.dark
|
||||
? FaiColors.success
|
||||
: const Color(0xFF15803D),
|
||||
);
|
||||
case FaiPillTone.warning:
|
||||
return (
|
||||
FaiColors.warning.withValues(alpha: 0.15),
|
||||
scheme.brightness == Brightness.dark
|
||||
? FaiColors.warning
|
||||
: const Color(0xFFB45309),
|
||||
);
|
||||
case FaiPillTone.danger:
|
||||
return (
|
||||
scheme.errorContainer,
|
||||
scheme.onErrorContainer,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
101
lib/widgets/fai_status_dot.dart
Normal file
101
lib/widgets/fai_status_dot.dart
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
// FaiStatusDot — small pulsing dot used as a "live" indicator.
|
||||
// Goes still when [pulsing] is false, breathes gently when true.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
class FaiStatusDot extends StatefulWidget {
|
||||
final Color color;
|
||||
|
||||
/// When true, the dot fades between full and 30% opacity.
|
||||
/// When false, it stays at full opacity (steady-state).
|
||||
final bool pulsing;
|
||||
|
||||
final double size;
|
||||
|
||||
const FaiStatusDot({
|
||||
super.key,
|
||||
required this.color,
|
||||
this.pulsing = false,
|
||||
this.size = 8,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FaiStatusDot> createState() => _FaiStatusDotState();
|
||||
}
|
||||
|
||||
class _FaiStatusDotState extends State<FaiStatusDot>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ctrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1400),
|
||||
);
|
||||
if (widget.pulsing) _ctrl.repeat(reverse: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FaiStatusDot old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (widget.pulsing && !_ctrl.isAnimating) {
|
||||
_ctrl.repeat(reverse: true);
|
||||
} else if (!widget.pulsing && _ctrl.isAnimating) {
|
||||
_ctrl.stop();
|
||||
_ctrl.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _ctrl,
|
||||
builder: (_, _) {
|
||||
final t = widget.pulsing ? _ctrl.value : 0.0;
|
||||
final alpha = 1.0 - (t * 0.6);
|
||||
return Container(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.color.withValues(alpha: alpha),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: widget.pulsing
|
||||
? [
|
||||
BoxShadow(
|
||||
color: widget.color.withValues(alpha: 0.4 * alpha),
|
||||
blurRadius: 6 * (1 + t),
|
||||
spreadRadius: 1 * t,
|
||||
),
|
||||
]
|
||||
: const [],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience presets.
|
||||
class FaiStatusDots {
|
||||
FaiStatusDots._();
|
||||
static Widget live() => const FaiStatusDot(
|
||||
color: FaiColors.success,
|
||||
pulsing: true,
|
||||
);
|
||||
static Widget idle() => const FaiStatusDot(
|
||||
color: FaiColors.muted,
|
||||
);
|
||||
static Widget down() => const FaiStatusDot(
|
||||
color: FaiColors.danger,
|
||||
);
|
||||
}
|
||||
12
lib/widgets/widgets.dart
Normal file
12
lib/widgets/widgets.dart
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// Barrel file for the F∆I Studio widget library.
|
||||
//
|
||||
// Pages should `import '../widgets/widgets.dart';` and use the
|
||||
// primitives directly — no Material `Card` / generic `Container`
|
||||
// soup in page code.
|
||||
|
||||
export 'fai_card.dart';
|
||||
export 'fai_data_row.dart';
|
||||
export 'fai_delta_mark.dart';
|
||||
export 'fai_empty_state.dart';
|
||||
export 'fai_pill.dart';
|
||||
export 'fai_status_dot.dart';
|
||||
Loading…
Add table
Add a link
Reference in a new issue