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; const AnalogGauge({ super.key, required this.value, required this.minValue, required this.maxValue, required this.label, required this.unit, required this.accentColor, }); @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, duration: const Duration(milliseconds: 150), ); _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.easeOut)); _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, displayValue: widget.value, unit: widget.unit, label: widget.label, ), ), ), ); } } class _GaugePainter extends CustomPainter { final double normalizedValue; // 0.0–1.0 final Color accentColor; final double displayValue; final String unit; final String label; 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.displayValue, required this.unit, required this.label, }); @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 = const Color(0xFF2A2A2A) ..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 = Colors.white54 ..strokeWidth = size.width * 0.012 ..strokeCap = StrokeCap.round; final minorPaint = Paint() ..color = Colors.white24 ..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 = Colors.white ..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 = Colors.white, ); canvas.drawCircle( center, size.width * 0.025, Paint()..color = const Color(0xFF0A0A0A), ); } void _drawCenterText(Canvas canvas, Offset center, Size size) { // Value final valSpan = TextSpan( text: displayValue.toStringAsFixed(0), style: TextStyle( color: Colors.white, 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: Colors.white54, 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, ), ); } @override bool shouldRepaint(_GaugePainter old) => old.normalizedValue != normalizedValue || old.accentColor != accentColor || old.displayValue != displayValue; }