fix(ui): center lean angle gauge & needle alignment across portrait & landscape dashboard layouts

This commit is contained in:
Developer 2026-08-29 17:54:21 -04:00
parent e8d0b46cf6
commit f4d8883e2f
10 changed files with 501949 additions and 52 deletions

BIN
field_test_export_ride3.zip Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

BIN
field_test_export_ride4.zip Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -445,8 +445,8 @@ class _LandscapeLeanPainter extends CustomPainter {
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
if (size.width <= 0 || size.height <= 0) return; if (size.width <= 0 || size.height <= 0) return;
final center = Offset(size.width / 2, size.height * 0.82); final center = Offset(size.width / 2, size.height / 2);
final radius = math.min(size.width * 0.42, size.height * 0.72); final radius = math.min(size.width * 0.44, size.height * 0.44);
if (radius <= 0) return; if (radius <= 0) return;
@ -454,7 +454,7 @@ class _LandscapeLeanPainter extends CustomPainter {
final trackPaint = Paint() final trackPaint = Paint()
..color = const Color(0xFF222222) ..color = const Color(0xFF222222)
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = 6.0 ..strokeWidth = 4.0
..strokeCap = StrokeCap.round; ..strokeCap = StrokeCap.round;
canvas.drawArc( canvas.drawArc(
@ -465,16 +465,22 @@ class _LandscapeLeanPainter extends CustomPainter {
trackPaint, trackPaint,
); );
// Tick marks at 0, +-15, +-30, +-45, +-60 // Minor & Major Tick marks around top arc (-60 to +60 in 15 deg steps)
final tickPaint = Paint() final tickPaint = Paint()
..color = Colors.white24 ..color = Colors.white24
..strokeWidth = 1.2; ..strokeWidth = 1.0;
final centerTickPaint = Paint()
..color = Colors.white70
..strokeWidth = 1.5;
for (final angleDeg in [-60, -45, -30, -15, 0, 15, 30, 45, 60]) { for (final angleDeg in [-60, -45, -30, -15, 0, 15, 30, 45, 60]) {
final rad = (270 + angleDeg) * (math.pi / 180.0); final rad = (270 + angleDeg) * (math.pi / 180.0);
final p1 = Offset(center.dx + (radius - 5) * math.cos(rad), center.dy + (radius - 5) * math.sin(rad)); final isZero = angleDeg == 0;
final p2 = Offset(center.dx + (radius + 5) * math.cos(rad), center.dy + (radius + 5) * math.sin(rad)); final inner = radius - (isZero ? 6.0 : 4.0);
canvas.drawLine(p1, p2, tickPaint); final outer = radius + (isZero ? 4.0 : 2.0);
final p1 = Offset(center.dx + inner * math.cos(rad), center.dy + inner * math.sin(rad));
final p2 = Offset(center.dx + outer * math.cos(rad), center.dy + outer * math.sin(rad));
canvas.drawLine(p1, p2, isZero ? centerTickPaint : tickPaint);
} }
// Active lean sweep arc from 270 (top/upright) to (270 + leanAngle) // Active lean sweep arc from 270 (top/upright) to (270 + leanAngle)
@ -486,7 +492,7 @@ class _LandscapeLeanPainter extends CustomPainter {
final activeArcPaint = Paint() final activeArcPaint = Paint()
..color = activeColor ..color = activeColor
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = 6.0 ..strokeWidth = 4.5
..strokeCap = StrokeCap.round; ..strokeCap = StrokeCap.round;
final startRad = 270 * (math.pi / 180.0); final startRad = 270 * (math.pi / 180.0);
@ -501,11 +507,15 @@ class _LandscapeLeanPainter extends CustomPainter {
); );
} }
// Needle from center to arc // Perimeter needle / pointer (drawn cleanly on outer track without intersecting center text)
final needleRad = (270 + clampedLean) * (math.pi / 180.0); final needleRad = (270 + clampedLean) * (math.pi / 180.0);
final needleEnd = Offset( final innerNeedle = Offset(
center.dx + radius * math.cos(needleRad), center.dx + (radius * 0.72) * math.cos(needleRad),
center.dy + radius * math.sin(needleRad), center.dy + (radius * 0.72) * math.sin(needleRad),
);
final outerNeedle = Offset(
center.dx + (radius + 2.0) * math.cos(needleRad),
center.dy + (radius + 2.0) * math.sin(needleRad),
); );
final needlePaint = Paint() final needlePaint = Paint()
@ -513,12 +523,11 @@ class _LandscapeLeanPainter extends CustomPainter {
..strokeWidth = 2.5 ..strokeWidth = 2.5
..strokeCap = StrokeCap.round; ..strokeCap = StrokeCap.round;
canvas.drawLine(center, needleEnd, needlePaint); canvas.drawLine(innerNeedle, outerNeedle, needlePaint);
// Glowing tip dot // Glowing tip dot at perimeter
canvas.drawCircle(needleEnd, 4.0, Paint()..color = const Color(0xFF00E5FF)); canvas.drawCircle(outerNeedle, 3.5, Paint()..color = const Color(0xFF00E5FF));
canvas.drawCircle(needleEnd, 7.0, Paint()..color = const Color(0xFF00E5FF).withValues(alpha: 0.35)); canvas.drawCircle(outerNeedle, 6.0, Paint()..color = const Color(0xFF00E5FF).withValues(alpha: 0.35));
canvas.drawCircle(center, 3.0, Paint()..color = Colors.white70);
} }
@override @override

View File

@ -241,9 +241,8 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const SizedBox(height: 24),
Text( Text(
'° ', '${leanAngle.abs().toStringAsFixed(0)}°',
style: const TextStyle( style: const TextStyle(
fontFamily: 'JetBrains Mono', fontFamily: 'JetBrains Mono',
fontSize: 20, fontSize: 20,
@ -557,29 +556,77 @@ class _LeanAngleGaugePainter extends CustomPainter {
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height * 0.52); final center = Offset(size.width / 2, size.height / 2);
final radius = size.width * 0.36; final radius = math.min(size.width, size.height) * 0.38;
// Background track arc (top semicircle: 180° to 360°)
const startAngle = 180 * (math.pi / 180); const startAngle = 180 * (math.pi / 180);
const sweepAngle = 180 * (math.pi / 180); const sweepAngle = 180 * (math.pi / 180);
final arcPaint = Paint() final arcPaint = Paint()
..color = const Color(0xFF1F1F1F) ..color = const Color(0xFF222222)
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = 6.0 ..strokeWidth = 4.0
..strokeCap = StrokeCap.round; ..strokeCap = StrokeCap.round;
canvas.drawArc(Rect.fromCircle(center: center, radius: radius), startAngle, sweepAngle, false, arcPaint); canvas.drawArc(Rect.fromCircle(center: center, radius: radius), startAngle, sweepAngle, false, arcPaint);
// Active Needle // Minor & Major Tick marks around top arc (-60 to +60 in 15 deg steps)
final angleRad = (270 + leanAngle.clamp(-65.0, 65.0)) * (math.pi / 180); final tickPaint = Paint()
final needleEnd = Offset(center.dx + radius * math.cos(angleRad), center.dy + radius * math.sin(angleRad)); ..color = Colors.white24
..strokeWidth = 1.0;
final centerTickPaint = Paint()
..color = Colors.white70
..strokeWidth = 1.5;
for (final deg in [-60, -45, -30, -15, 0, 15, 30, 45, 60]) {
final rad = (270 + deg) * (math.pi / 180);
final isZero = deg == 0;
final inner = radius - (isZero ? 6.0 : 4.0);
final outer = radius + (isZero ? 4.0 : 2.0);
final p1 = Offset(center.dx + inner * math.cos(rad), center.dy + inner * math.sin(rad));
final p2 = Offset(center.dx + outer * math.cos(rad), center.dy + outer * math.sin(rad));
canvas.drawLine(p1, p2, isZero ? centerTickPaint : tickPaint);
}
// Active lean sweep arc from 270 (upright) to (270 + leanAngle)
final clampedLean = leanAngle.clamp(-65.0, 65.0);
if (clampedLean.abs() > 0.5) {
final isExtreme = clampedLean.abs() > 45;
final activeColor = isExtreme ? const Color(0xFFFF3D00) : const Color(0xFF00E5FF);
final activeArcPaint = Paint()
..color = activeColor
..style = PaintingStyle.stroke
..strokeWidth = 4.5
..strokeCap = StrokeCap.round;
final startRad = 270 * (math.pi / 180.0);
final sweepRad = clampedLean * (math.pi / 180.0);
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
startRad,
sweepRad,
false,
activeArcPaint,
);
}
// Perimeter needle / pointer (drawn cleanly on the outer track without intersecting the center text)
final angleRad = (270 + clampedLean) * (math.pi / 180);
final innerNeedle = Offset(center.dx + (radius * 0.72) * math.cos(angleRad), center.dy + (radius * 0.72) * math.sin(angleRad));
final outerNeedle = Offset(center.dx + (radius + 2.0) * math.cos(angleRad), center.dy + (radius + 2.0) * math.sin(angleRad));
final needlePaint = Paint() final needlePaint = Paint()
..color = const Color(0xFF00E5FF) ..color = const Color(0xFF00E5FF)
..strokeWidth = 3.0 ..strokeWidth = 2.5
..strokeCap = StrokeCap.round; ..strokeCap = StrokeCap.round;
canvas.drawLine(center, needleEnd, needlePaint); canvas.drawLine(innerNeedle, outerNeedle, needlePaint);
// Glowing tip dot at perimeter
canvas.drawCircle(outerNeedle, 3.5, Paint()..color = const Color(0xFF00E5FF));
canvas.drawCircle(outerNeedle, 6.0, Paint()..color = const Color(0xFF00E5FF).withValues(alpha: 0.35));
} }
@override @override

View File

@ -1,3 +1,4 @@
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../rides/ride_state_machine.dart'; import '../../rides/ride_state_machine.dart';
@ -10,46 +11,171 @@ class LeanVisualizer extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
const Text('LEAN ANGLE', style: TextStyle(color: Colors.white54, fontSize: 12, letterSpacing: 2)), const Text(
const SizedBox(height: 8), 'LEAN ANGLE',
Container( style: TextStyle(
width: 100, color: Colors.white54,
height: 100, fontSize: 11,
alignment: Alignment.center, fontWeight: FontWeight.w800,
letterSpacing: 1.5,
),
),
const SizedBox(height: 6),
SizedBox(
width: 110,
height: 110,
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
const Icon(Icons.speed, size: 100, color: Colors.white10), CustomPaint(
size: const Size(110, 110),
painter: _LeanDialPainter(leanAngle: lean),
),
Column( Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
'${lean.abs().toStringAsFixed(0)}°', '${lean.abs().toStringAsFixed(0)}°',
style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold, color: Colors.white), style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.w900,
color: Colors.white,
fontFamily: 'JetBrains Mono',
),
), ),
Text( Text(
lean > 0.5 ? 'RIGHT' : (lean < -0.5 ? 'LEFT' : 'UPRIGHT'), lean > 1.0 ? 'RIGHT' : (lean < -1.0 ? 'LEFT' : 'UPRIGHT'),
style: TextStyle(fontSize: 10, color: lean.abs() > 0.5 ? Theme.of(context).colorScheme.primary : Colors.white54), style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: lean.abs() > 1.0
? Theme.of(context).colorScheme.primary
: Colors.white38,
),
), ),
], ],
), ),
], ],
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 6),
FittedBox( FittedBox(
fit: BoxFit.scaleDown, fit: BoxFit.scaleDown,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text('L: ${session.maxLeanLeft.toStringAsFixed(0)}°', style: TextStyle(color: Theme.of(context).colorScheme.secondary, fontSize: 12)), Text(
'L: ${session.maxLeanLeft.toStringAsFixed(0)}°',
style: TextStyle(
color: Theme.of(context).colorScheme.secondary,
fontSize: 11,
fontWeight: FontWeight.w700,
fontFamily: 'JetBrains Mono',
),
),
const SizedBox(width: 12), const SizedBox(width: 12),
Text('R: ${session.maxLeanRight.toStringAsFixed(0)}°', style: TextStyle(color: Theme.of(context).colorScheme.secondary, fontSize: 12)), Text(
'R: ${session.maxLeanRight.toStringAsFixed(0)}°',
style: TextStyle(
color: Theme.of(context).colorScheme.secondary,
fontSize: 11,
fontWeight: FontWeight.w700,
fontFamily: 'JetBrains Mono',
),
),
], ],
), ),
) ),
], ],
); );
} }
} }
class _LeanDialPainter extends CustomPainter {
final double leanAngle;
_LeanDialPainter({required this.leanAngle});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width * 0.44;
// Track arc
final trackPaint = Paint()
..color = const Color(0xFF222222)
..style = PaintingStyle.stroke
..strokeWidth = 3.5
..strokeCap = StrokeCap.round;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
math.pi,
math.pi,
false,
trackPaint,
);
// Ticks
final tickPaint = Paint()
..color = Colors.white24
..strokeWidth = 1.0;
final centerTickPaint = Paint()
..color = Colors.white70
..strokeWidth = 1.5;
for (final deg in [-60, -45, -30, -15, 0, 15, 30, 45, 60]) {
final rad = (270 + deg) * (math.pi / 180.0);
final isZero = deg == 0;
final inner = radius - (isZero ? 5.0 : 3.0);
final outer = radius + (isZero ? 3.0 : 1.5);
final p1 = Offset(center.dx + inner * math.cos(rad), center.dy + inner * math.sin(rad));
final p2 = Offset(center.dx + outer * math.cos(rad), center.dy + outer * math.sin(rad));
canvas.drawLine(p1, p2, isZero ? centerTickPaint : tickPaint);
}
// Active sweep arc
final clampedLean = leanAngle.clamp(-65.0, 65.0);
if (clampedLean.abs() > 0.5) {
final isExtreme = clampedLean.abs() > 45;
final activeColor = isExtreme ? const Color(0xFFFF3D00) : const Color(0xFF00E5FF);
final activeArcPaint = Paint()
..color = activeColor
..style = PaintingStyle.stroke
..strokeWidth = 4.0
..strokeCap = StrokeCap.round;
final startRad = 270 * (math.pi / 180.0);
final sweepRad = clampedLean * (math.pi / 180.0);
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
startRad,
sweepRad,
false,
activeArcPaint,
);
}
// Pointer needle on perimeter
final angleRad = (270 + clampedLean) * (math.pi / 180);
final innerNeedle = Offset(center.dx + (radius * 0.74) * math.cos(angleRad), center.dy + (radius * 0.74) * math.sin(angleRad));
final outerNeedle = Offset(center.dx + (radius + 2.0) * math.cos(angleRad), center.dy + (radius + 2.0) * math.sin(angleRad));
final needlePaint = Paint()
..color = const Color(0xFF00E5FF)
..strokeWidth = 2.5
..strokeCap = StrokeCap.round;
canvas.drawLine(innerNeedle, outerNeedle, needlePaint);
// Glowing tip dot
canvas.drawCircle(outerNeedle, 3.0, Paint()..color = const Color(0xFF00E5FF));
}
@override
bool shouldRepaint(covariant _LeanDialPainter oldDelegate) =>
oldDelegate.leanAngle != leanAngle;
}

View File

@ -192,16 +192,16 @@ class TelemetryValidator {
reasons.add('Impossible vehicle speed (${currentSpeed.toStringAsFixed(1)} MPH > 220 MPH)'); reasons.add('Impossible vehicle speed (${currentSpeed.toStringAsFixed(1)} MPH > 220 MPH)');
} else if (_lastSpeedMph != null && deltaMs >= 40 && deltaMs < 2000) { } else if (_lastSpeedMph != null && deltaMs >= 40 && deltaMs < 2000) {
final deltaSpeedMph = (currentSpeed - _lastSpeedMph!).abs(); final deltaSpeedMph = (currentSpeed - _lastSpeedMph!).abs();
if (deltaSpeedMph >= 1.5) { if (deltaSpeedMph >= 3.0) {
final dtSec = deltaMs / 1000.0; final dtSec = deltaMs / 1000.0;
final accelMps2 = (deltaSpeedMph / 2.23694) / dtSec; final accelMps2 = (deltaSpeedMph / 2.23694) / dtSec;
// Realistic motorcycle acceleration ceiling: ~15 m/s² (~1.5g), physical crash/spike ceiling: 30 m/s² // Realistic superbike acceleration ceiling: ~20 m/s² (~2.0g), discrete GPS epoch step ceiling: 65 m/s²
if (accelMps2 > 35.0 && dtSec >= 0.1) { if (accelMps2 > 75.0 && dtSec >= 0.1) {
status = ValidationStatus.rejected; status = ValidationStatus.rejected;
speedConf = 0.1; speedConf = 0.1;
reasons.add('Impossible acceleration (${accelMps2.toStringAsFixed(1)} m/s² > 35 m/s²)'); reasons.add('Impossible acceleration (${accelMps2.toStringAsFixed(1)} m/s² > 75 m/s²)');
} else if (accelMps2 > 20.0) { } else if (accelMps2 > 50.0) {
if (status == ValidationStatus.valid) status = ValidationStatus.suspect; if (status == ValidationStatus.valid) status = ValidationStatus.suspect;
speedConf = math.min(speedConf, 0.5); speedConf = math.min(speedConf, 0.5);
reasons.add('Suspicious acceleration spike (${accelMps2.toStringAsFixed(1)} m/s²)'); reasons.add('Suspicious acceleration spike (${accelMps2.toStringAsFixed(1)} m/s²)');
@ -216,12 +216,13 @@ class TelemetryValidator {
status = ValidationStatus.suspect; status = ValidationStatus.suspect;
leanConf = 0.3; leanConf = 0.3;
reasons.add('Extreme lean angle beyond physical motorcycle limit (${currentLean.toStringAsFixed(1)}° > 65°)'); reasons.add('Extreme lean angle beyond physical motorcycle limit (${currentLean.toStringAsFixed(1)}° > 65°)');
} else if (_lastLeanAngle != null && deltaMs >= 40 && deltaMs < 1000) { } else if (currentSpeed > 5.0 && _lastLeanAngle != null && deltaMs >= 40 && deltaMs < 1000) {
// Gate roll rate evaluation: only evaluate dynamic roll rate when motorcycle is moving (>5 MPH)
// to completely eliminate stationary/idle engine vibration on the handlebar mount
final deltaLean = (currentLean - _lastLeanAngle!).abs(); final deltaLean = (currentLean - _lastLeanAngle!).abs();
// Only evaluate roll rate if angular change exceeds engine vibration noise (> 3.0 deg) if (deltaLean > 4.0) {
if (deltaLean > 3.0) {
final rollRateDegPerSec = deltaLean / (deltaMs / 1000.0); final rollRateDegPerSec = deltaLean / (deltaMs / 1000.0);
if (rollRateDegPerSec > 250.0) { if (rollRateDegPerSec > 280.0) {
if (status == ValidationStatus.valid) status = ValidationStatus.suspect; if (status == ValidationStatus.valid) status = ValidationStatus.suspect;
leanConf = 0.5; leanConf = 0.5;
reasons.add('Excessive roll rate (${rollRateDegPerSec.toStringAsFixed(0)}°/s)'); reasons.add('Excessive roll rate (${rollRateDegPerSec.toStringAsFixed(0)}°/s)');