Some checks failed
Security / Security check (push) Failing after 1s
Settings dialog's Theme Plugin section is now a grid of swatched tiles: - Built-in (none) — falls back to FaiTheme.light/.dark - One tile per installed studio.theme.* plugin, each showing the plugin's primary/secondary/tertiary as live colour dots. Tile loads its preview lazily so a dozen installed themes don't block the picker. - Custom — opens a colour-picker dialog with 12 curated Material presets + a hex input + live preview. Selecting applies ColorScheme.fromSeed for both brightnesses. main.dart's _pluginThemes parses a 'custom:#RRGGBB' sigil in the same notifier slot as plugin capability ids, so the existing persistence + restoration paths cover the custom case with no new state. Bumps editor to 0.11.0 (type-checked port connections + dynamic card width fix + card-height border allowance) and Studio to 0.58.0. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
173 lines
4.5 KiB
Dart
173 lines
4.5 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;
|
|
|
|
// In live mode: scale the whole mark between 0.85 and 1.15
|
|
// so the pulse is visible without staring. Idle stays at 1.0.
|
|
final scale = mode == FaiDeltaMode.live ? 0.85 + 0.30 * t : 1.0;
|
|
final r = size.width * 0.36 * scale;
|
|
|
|
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. In live mode the glow ring grows with the pulse
|
|
// and the alpha breathes harder.
|
|
if (mode != FaiDeltaMode.idle) {
|
|
final pulseStrength = mode == FaiDeltaMode.live ? t : 1.0;
|
|
final glow = Paint()
|
|
..color = color.withValues(alpha: 0.30 + 0.40 * pulseStrength)
|
|
..maskFilter = MaskFilter.blur(BlurStyle.normal, 8 + 14 * pulseStrength)
|
|
..style = PaintingStyle.fill;
|
|
canvas.drawPath(path, glow);
|
|
}
|
|
|
|
// Filled triangle, brighter on the up-beat for live mode.
|
|
final fillAlpha = mode == FaiDeltaMode.live ? 0.15 + 0.20 * t : 0.18;
|
|
final fill = Paint()
|
|
..color = color.withValues(alpha: fillAlpha)
|
|
..style = PaintingStyle.fill;
|
|
canvas.drawPath(path, fill);
|
|
|
|
// Outline stroke.
|
|
final stroke = Paint()
|
|
..color = color
|
|
..strokeWidth = 2.2
|
|
..style = PaintingStyle.stroke
|
|
..strokeJoin = StrokeJoin.round;
|
|
canvas.drawPath(path, stroke);
|
|
|
|
// Inner accent dot.
|
|
final dotAlpha = mode == FaiDeltaMode.live ? 0.55 + 0.45 * t : 1.0;
|
|
final dot = Paint()
|
|
..color = color.withValues(alpha: dotAlpha)
|
|
..style = PaintingStyle.fill;
|
|
canvas.drawCircle(Offset(0, r * 0.18), r * 0.16, dot);
|
|
|
|
canvas.restore();
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _DeltaPainter old) =>
|
|
old.t != t || old.mode != mode || old.color != color;
|
|
}
|