The DeltaMark ran an endless 9s breathing animation (60fps repaint) inside the persistent nav rail, so it repainted on every scroll frame and stuttered scrolling on Flutter web. Make the rail mark static (animated: false) and isolate the animated variant (landing page) behind a RepaintBoundary. Signed-off-by: flemming-it <sf@flemming.it>
111 lines
3 KiB
Dart
111 lines
3 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../theme/reclaim_tokens.dart';
|
|
|
|
/// The F∆I / Ch∆In brand mark: an upward triangle (the `∆`) with
|
|
/// a petrol "eye" at the centre. Geometry mirrors fai_web's
|
|
/// Logo.astro — the triangle IS the "A"; the eye-dot IS the dot
|
|
/// of the "i".
|
|
///
|
|
/// Subtle 9s breathing pulse on the eye (matches fai_web's
|
|
/// `.eye-pulse`). Set [animated] to false for static contexts
|
|
/// (icons, screenshots).
|
|
class DeltaMark extends StatefulWidget {
|
|
const DeltaMark({super.key, this.size = 64, this.animated = true});
|
|
|
|
final double size;
|
|
final bool animated;
|
|
|
|
@override
|
|
State<DeltaMark> createState() => _DeltaMarkState();
|
|
}
|
|
|
|
class _DeltaMarkState extends State<DeltaMark>
|
|
with SingleTickerProviderStateMixin {
|
|
late final AnimationController _controller;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(seconds: 9),
|
|
);
|
|
if (widget.animated) {
|
|
_controller.repeat();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Static mark: no controller listening, paint once.
|
|
if (!widget.animated) {
|
|
return CustomPaint(
|
|
size: Size.square(widget.size),
|
|
painter: _DeltaPainter(pulse: 1.0),
|
|
);
|
|
}
|
|
// Animated: isolate the per-frame repaint in its own layer so
|
|
// the breathing pulse never invalidates surrounding content
|
|
// (web scroll stays smooth).
|
|
return RepaintBoundary(
|
|
child: AnimatedBuilder(
|
|
animation: _controller,
|
|
builder: (context, _) {
|
|
final t = _controller.value;
|
|
// Breathe: scale 1 → 1.28 → 1 over the cycle.
|
|
final pulse =
|
|
1.0 + 0.28 * (1 - (math.cos(t * 2 * math.pi).abs()));
|
|
return CustomPaint(
|
|
size: Size.square(widget.size),
|
|
painter: _DeltaPainter(pulse: pulse),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DeltaPainter extends CustomPainter {
|
|
_DeltaPainter({required this.pulse});
|
|
|
|
final double pulse;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final w = size.width;
|
|
final h = size.height;
|
|
|
|
// Upward triangle, geometric centre.
|
|
final stroke = Paint()
|
|
..color = const Color(0xFFE9E7E2)
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = w * 0.04
|
|
..strokeJoin = StrokeJoin.miter;
|
|
|
|
final inset = w * 0.08;
|
|
final tri = Path()
|
|
..moveTo(w / 2, inset)
|
|
..lineTo(w - inset, h - inset)
|
|
..lineTo(inset, h - inset)
|
|
..close();
|
|
canvas.drawPath(tri, stroke);
|
|
|
|
// Eye — petrol fill, centred on the triangle's centroid.
|
|
final eyeCentre = Offset(w / 2, inset + (h - 2 * inset) * 2 / 3);
|
|
final eyeR = w * 0.06 * pulse;
|
|
final eye = Paint()..color = ReclaimColors.signal;
|
|
canvas.drawCircle(eyeCentre, eyeR, eye);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _DeltaPainter old) => old.pulse != pulse;
|
|
}
|