fix(ui): center lean angle gauge & needle alignment across portrait & landscape dashboard layouts
This commit is contained in:
parent
e8d0b46cf6
commit
f4d8883e2f
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -445,8 +445,8 @@ class _LandscapeLeanPainter extends CustomPainter {
|
|||
void paint(Canvas canvas, Size size) {
|
||||
if (size.width <= 0 || size.height <= 0) return;
|
||||
|
||||
final center = Offset(size.width / 2, size.height * 0.82);
|
||||
final radius = math.min(size.width * 0.42, size.height * 0.72);
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final radius = math.min(size.width * 0.44, size.height * 0.44);
|
||||
|
||||
if (radius <= 0) return;
|
||||
|
||||
|
|
@ -454,7 +454,7 @@ class _LandscapeLeanPainter extends CustomPainter {
|
|||
final trackPaint = Paint()
|
||||
..color = const Color(0xFF222222)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 6.0
|
||||
..strokeWidth = 4.0
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
canvas.drawArc(
|
||||
|
|
@ -465,16 +465,22 @@ class _LandscapeLeanPainter extends CustomPainter {
|
|||
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()
|
||||
..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]) {
|
||||
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 p2 = Offset(center.dx + (radius + 5) * math.cos(rad), center.dy + (radius + 5) * math.sin(rad));
|
||||
canvas.drawLine(p1, p2, tickPaint);
|
||||
final isZero = angleDeg == 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 (top/upright) to (270 + leanAngle)
|
||||
|
|
@ -486,7 +492,7 @@ class _LandscapeLeanPainter extends CustomPainter {
|
|||
final activeArcPaint = Paint()
|
||||
..color = activeColor
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 6.0
|
||||
..strokeWidth = 4.5
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
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 needleEnd = Offset(
|
||||
center.dx + radius * math.cos(needleRad),
|
||||
center.dy + radius * math.sin(needleRad),
|
||||
final innerNeedle = Offset(
|
||||
center.dx + (radius * 0.72) * math.cos(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()
|
||||
|
|
@ -513,12 +523,11 @@ class _LandscapeLeanPainter extends CustomPainter {
|
|||
..strokeWidth = 2.5
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
canvas.drawLine(center, needleEnd, needlePaint);
|
||||
canvas.drawLine(innerNeedle, outerNeedle, needlePaint);
|
||||
|
||||
// Glowing tip dot
|
||||
canvas.drawCircle(needleEnd, 4.0, Paint()..color = const Color(0xFF00E5FF));
|
||||
canvas.drawCircle(needleEnd, 7.0, Paint()..color = const Color(0xFF00E5FF).withValues(alpha: 0.35));
|
||||
canvas.drawCircle(center, 3.0, Paint()..color = Colors.white70);
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -241,9 +241,8 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
|
|||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'° ',
|
||||
'${leanAngle.abs().toStringAsFixed(0)}°',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 20,
|
||||
|
|
@ -557,29 +556,77 @@ class _LeanAngleGaugePainter extends CustomPainter {
|
|||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height * 0.52);
|
||||
final radius = size.width * 0.36;
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
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 sweepAngle = 180 * (math.pi / 180);
|
||||
|
||||
final arcPaint = Paint()
|
||||
..color = const Color(0xFF1F1F1F)
|
||||
..color = const Color(0xFF222222)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 6.0
|
||||
..strokeWidth = 4.0
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
canvas.drawArc(Rect.fromCircle(center: center, radius: radius), startAngle, sweepAngle, false, arcPaint);
|
||||
|
||||
// Active Needle
|
||||
final angleRad = (270 + leanAngle.clamp(-65.0, 65.0)) * (math.pi / 180);
|
||||
final needleEnd = Offset(center.dx + radius * math.cos(angleRad), center.dy + radius * math.sin(angleRad));
|
||||
// Minor & Major Tick marks around top arc (-60 to +60 in 15 deg steps)
|
||||
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);
|
||||
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()
|
||||
..color = const Color(0xFF00E5FF)
|
||||
..strokeWidth = 3.0
|
||||
..strokeWidth = 2.5
|
||||
..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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../rides/ride_state_machine.dart';
|
||||
|
||||
|
|
@ -10,46 +11,171 @@ class LeanVisualizer extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('LEAN ANGLE', style: TextStyle(color: Colors.white54, fontSize: 12, letterSpacing: 2)),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
alignment: Alignment.center,
|
||||
const Text(
|
||||
'LEAN ANGLE',
|
||||
style: TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
SizedBox(
|
||||
width: 110,
|
||||
height: 110,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
const Icon(Icons.speed, size: 100, color: Colors.white10),
|
||||
CustomPaint(
|
||||
size: const Size(110, 110),
|
||||
painter: _LeanDialPainter(leanAngle: lean),
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${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(
|
||||
lean > 0.5 ? 'RIGHT' : (lean < -0.5 ? 'LEFT' : 'UPRIGHT'),
|
||||
style: TextStyle(fontSize: 10, color: lean.abs() > 0.5 ? Theme.of(context).colorScheme.primary : Colors.white54),
|
||||
lean > 1.0 ? 'RIGHT' : (lean < -1.0 ? 'LEFT' : 'UPRIGHT'),
|
||||
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(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
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),
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -192,16 +192,16 @@ class TelemetryValidator {
|
|||
reasons.add('Impossible vehicle speed (${currentSpeed.toStringAsFixed(1)} MPH > 220 MPH)');
|
||||
} else if (_lastSpeedMph != null && deltaMs >= 40 && deltaMs < 2000) {
|
||||
final deltaSpeedMph = (currentSpeed - _lastSpeedMph!).abs();
|
||||
if (deltaSpeedMph >= 1.5) {
|
||||
if (deltaSpeedMph >= 3.0) {
|
||||
final dtSec = deltaMs / 1000.0;
|
||||
final accelMps2 = (deltaSpeedMph / 2.23694) / dtSec;
|
||||
|
||||
// Realistic motorcycle acceleration ceiling: ~15 m/s² (~1.5g), physical crash/spike ceiling: 30 m/s²
|
||||
if (accelMps2 > 35.0 && dtSec >= 0.1) {
|
||||
// Realistic superbike acceleration ceiling: ~20 m/s² (~2.0g), discrete GPS epoch step ceiling: 65 m/s²
|
||||
if (accelMps2 > 75.0 && dtSec >= 0.1) {
|
||||
status = ValidationStatus.rejected;
|
||||
speedConf = 0.1;
|
||||
reasons.add('Impossible acceleration (${accelMps2.toStringAsFixed(1)} m/s² > 35 m/s²)');
|
||||
} else if (accelMps2 > 20.0) {
|
||||
reasons.add('Impossible acceleration (${accelMps2.toStringAsFixed(1)} m/s² > 75 m/s²)');
|
||||
} else if (accelMps2 > 50.0) {
|
||||
if (status == ValidationStatus.valid) status = ValidationStatus.suspect;
|
||||
speedConf = math.min(speedConf, 0.5);
|
||||
reasons.add('Suspicious acceleration spike (${accelMps2.toStringAsFixed(1)} m/s²)');
|
||||
|
|
@ -216,12 +216,13 @@ class TelemetryValidator {
|
|||
status = ValidationStatus.suspect;
|
||||
leanConf = 0.3;
|
||||
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();
|
||||
// Only evaluate roll rate if angular change exceeds engine vibration noise (> 3.0 deg)
|
||||
if (deltaLean > 3.0) {
|
||||
if (deltaLean > 4.0) {
|
||||
final rollRateDegPerSec = deltaLean / (deltaMs / 1000.0);
|
||||
if (rollRateDegPerSec > 250.0) {
|
||||
if (rollRateDegPerSec > 280.0) {
|
||||
if (status == ValidationStatus.valid) status = ValidationStatus.suspect;
|
||||
leanConf = 0.5;
|
||||
reasons.add('Excessive roll rate (${rollRateDegPerSec.toStringAsFixed(0)}°/s)');
|
||||
|
|
|
|||
Loading…
Reference in New Issue