import 'dart:math'; import 'package:flutter/material.dart'; class AnalogGauge extends StatefulWidget { final double value; final double minValue; final double maxValue; final String label; final String unit; final Color accentColor; /// Formatted main value; defaults to [value] with no decimals. final String? valueText; /// Second line under the unit, e.g. the sub sensor "120 km/h". final String? subText; final Color foreground; final Color muted; final Color track; final Color background; const AnalogGauge({ super.key, required this.value, required this.minValue, required this.maxValue, required this.label, required this.unit, required this.accentColor, this.valueText, this.subText, this.foreground = Colors.white, this.muted = Colors.white54, this.track = const Color(0xFF2A2A2A), this.background = const Color(0xFF0A0A0A), }); @override State createState() => _AnalogGaugeState(); } class _AnalogGaugeState extends State with SingleTickerProviderStateMixin { late AnimationController _ctrl; late Animation _anim; double get _normalized => ((widget.value - widget.minValue) / (widget.maxValue - widget.minValue)) .clamp(0.0, 1.0); @override void initState() { super.initState(); _ctrl = AnimationController( vsync: this, // Roughly one polling interval: the needle glides from reading to // reading instead of jumping and stopping (was 150 ms ease-out, which // looked stepped at 10-20 Hz data). duration: const Duration(milliseconds: 110), ); _anim = Tween(begin: 0, end: _normalized) .animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut)); _ctrl.forward(); } @override void didUpdateWidget(AnalogGauge old) { super.didUpdateWidget(old); if (old.value != widget.value) { _anim = Tween(begin: _anim.value, end: _normalized) .animate(CurvedAnimation(parent: _ctrl, curve: Curves.linear)); _ctrl.forward(from: 0); } } @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AspectRatio( aspectRatio: 1, child: AnimatedBuilder( animation: _anim, builder: (_, __) => CustomPaint( painter: _GaugePainter( normalizedValue: _anim.value, accentColor: widget.accentColor, valueText: widget.valueText ?? widget.value.toStringAsFixed(0), subText: widget.subText, unit: widget.unit, label: widget.label, foreground: widget.foreground, muted: widget.muted, track: widget.track, background: widget.background, ), ), ), ); } } class _GaugePainter extends CustomPainter { final double normalizedValue; // 0.0–1.0 final Color accentColor; final String valueText; final String? subText; final String unit; final String label; final Color foreground; final Color muted; final Color track; final Color background; static const double _startAngle = 135 * pi / 180; // 7:30 position static const double _sweepAngle = 270 * pi / 180; // 270° sweep _GaugePainter({ required this.normalizedValue, required this.accentColor, required this.valueText, required this.subText, required this.unit, required this.label, required this.foreground, required this.muted, required this.track, required this.background, }); @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); final radius = size.width * 0.42; final rect = Rect.fromCircle(center: center, radius: radius); // ── Background arc ────────────────────────────────────────────── final bgPaint = Paint() ..color = track ..style = PaintingStyle.stroke ..strokeWidth = size.width * 0.06 ..strokeCap = StrokeCap.round; canvas.drawArc(rect, _startAngle, _sweepAngle, false, bgPaint); // ── Red zone (last 20%) ────────────────────────────────────────── final redPaint = Paint() ..color = Colors.red.withAlpha(120) ..style = PaintingStyle.stroke ..strokeWidth = size.width * 0.06 ..strokeCap = StrokeCap.round; canvas.drawArc( rect, _startAngle + _sweepAngle * 0.8, _sweepAngle * 0.2, false, redPaint, ); // ── Value arc ──────────────────────────────────────────────────── if (normalizedValue > 0) { final isRedZone = normalizedValue > 0.8; final valPaint = Paint() ..color = isRedZone ? Colors.red : accentColor ..style = PaintingStyle.stroke ..strokeWidth = size.width * 0.06 ..strokeCap = StrokeCap.round; canvas.drawArc( rect, _startAngle, _sweepAngle * normalizedValue, false, valPaint, ); } // ── Tick marks ─────────────────────────────────────────────────── _drawTicks(canvas, center, radius, size); // ── Needle ──────────────────────────────────────────────────────── _drawNeedle(canvas, center, radius, size); // ── Center text ────────────────────────────────────────────────── _drawCenterText(canvas, center, size); } void _drawTicks(Canvas canvas, Offset center, double radius, Size size) { final majorPaint = Paint() ..color = muted ..strokeWidth = size.width * 0.012 ..strokeCap = StrokeCap.round; final minorPaint = Paint() ..color = muted.withAlpha(90) ..strokeWidth = size.width * 0.006 ..strokeCap = StrokeCap.round; const totalTicks = 50; // 2% each for (int i = 0; i <= totalTicks; i++) { final t = i / totalTicks; final angle = _startAngle + _sweepAngle * t; final isMajor = i % 5 == 0; final paint = isMajor ? majorPaint : minorPaint; final outerR = radius - size.width * 0.01; final innerR = outerR - (isMajor ? size.width * 0.06 : size.width * 0.03); canvas.drawLine( center + Offset(cos(angle) * innerR, sin(angle) * innerR), center + Offset(cos(angle) * outerR, sin(angle) * outerR), paint, ); } } void _drawNeedle(Canvas canvas, Offset center, double radius, Size size) { final angle = _startAngle + _sweepAngle * normalizedValue; final tipR = radius - size.width * 0.08; final tailR = size.width * 0.08; final needlePaint = Paint() ..color = foreground ..strokeWidth = size.width * 0.018 ..strokeCap = StrokeCap.round; canvas.drawLine( center - Offset(cos(angle) * tailR, sin(angle) * tailR), center + Offset(cos(angle) * tipR, sin(angle) * tipR), needlePaint, ); // Center dot canvas.drawCircle( center, size.width * 0.04, Paint()..color = foreground, ); canvas.drawCircle( center, size.width * 0.025, Paint()..color = background, ); } void _drawCenterText(Canvas canvas, Offset center, Size size) { // Value final valSpan = TextSpan( text: valueText, style: TextStyle( color: foreground, fontSize: size.width * 0.14, fontWeight: FontWeight.bold, letterSpacing: -1, ), ); final valPainter = TextPainter( text: valSpan, textDirection: TextDirection.ltr, )..layout(); valPainter.paint( canvas, center + Offset( -valPainter.width / 2, size.width * 0.1, ), ); // Unit final unitSpan = TextSpan( text: unit, style: TextStyle( color: muted, fontSize: size.width * 0.07, ), ); final unitPainter = TextPainter( text: unitSpan, textDirection: TextDirection.ltr, )..layout(); unitPainter.paint( canvas, center + Offset( -unitPainter.width / 2, size.width * 0.1 + valPainter.height + 2, ), ); // Label above the hub, e.g. "RPM" final labelPainter = TextPainter( text: TextSpan( text: label, style: TextStyle( color: muted, fontSize: size.width * 0.055, letterSpacing: 1, ), ), textDirection: TextDirection.ltr, )..layout(); labelPainter.paint( canvas, center + Offset(-labelPainter.width / 2, -size.width * 0.2), ); // Sub sensor line under the unit final sub = subText; if (sub != null) { final subPainter = TextPainter( text: TextSpan( text: sub, style: TextStyle( color: accentColor, fontSize: size.width * 0.075, fontWeight: FontWeight.w600, ), ), textDirection: TextDirection.ltr, )..layout(); subPainter.paint( canvas, center + Offset( -subPainter.width / 2, size.width * 0.1 + valPainter.height + unitPainter.height + size.width * 0.02, ), ); } } @override bool shouldRepaint(_GaugePainter old) => old.normalizedValue != normalizedValue || old.accentColor != accentColor || old.valueText != valueText || old.subText != subText || old.label != label || old.unit != unit || old.foreground != foreground; }