485 lines
19 KiB
Dart
485 lines
19 KiB
Dart
import 'dart:math' as math;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import '../../../models/telemetry_sample.dart';
|
|
import '../../rides/ride_state_machine.dart';
|
|
import '../../../services/map/map_provider_interface.dart';
|
|
import '../../../services/map/unified_map_view.dart';
|
|
import '../../../services/alerts/road_alert_models.dart';
|
|
import '../../../services/alerts/road_alert_service.dart';
|
|
import '../../records/records_service.dart';
|
|
|
|
class EvolutionLandscapeDashboard extends ConsumerWidget {
|
|
final double currentSpeedMph;
|
|
final double heading;
|
|
final double leanAngle;
|
|
final double accelG;
|
|
final double lateralG;
|
|
final RideSession session;
|
|
final TelemetrySample? telemetry;
|
|
|
|
const EvolutionLandscapeDashboard({
|
|
super.key,
|
|
required this.currentSpeedMph,
|
|
required this.heading,
|
|
required this.leanAngle,
|
|
required this.accelG,
|
|
required this.lateralG,
|
|
required this.session,
|
|
this.telemetry,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final records = ref.watch(recordsServiceProvider);
|
|
final speed = currentSpeedMph.clamp(0.0, 220.0);
|
|
final centerLat = (telemetry?.latitude != null && telemetry!.latitude != 0.0)
|
|
? telemetry!.latitude!
|
|
: 37.7749;
|
|
final centerLng = (telemetry?.longitude != null && telemetry!.longitude != 0.0)
|
|
? telemetry!.longitude!
|
|
: -122.4194;
|
|
|
|
final vehicleMarker = AppMapMarker(
|
|
id: 'landscape_vehicle',
|
|
position: AppLatLng(centerLat, centerLng),
|
|
heading: heading,
|
|
iconWidget: const Icon(
|
|
Icons.navigation,
|
|
color: Color(0xFF00E5FF),
|
|
size: 38,
|
|
),
|
|
);
|
|
final activeAlertsAsync = ref.watch(activeAlertsStreamProvider);
|
|
final activeAlerts = activeAlertsAsync.value ?? [];
|
|
final mapMarkers = <AppMapMarker>[vehicleMarker];
|
|
for (final alert in activeAlerts) {
|
|
mapMarkers.add(
|
|
AppMapMarker(
|
|
id: 'landscape_alert_${alert.providerId}_${alert.id}',
|
|
position: AppLatLng(alert.latitude, alert.longitude),
|
|
heading: alert.heading ?? 0.0,
|
|
label: alert.category.displayName,
|
|
iconWidget: Container(
|
|
padding: const EdgeInsets.all(4),
|
|
decoration: BoxDecoration(
|
|
color: alert.category.color.withValues(alpha: 0.9),
|
|
shape: BoxShape.circle,
|
|
boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 4)],
|
|
),
|
|
child: Icon(alert.category.icon, color: Colors.black, size: 16),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Container(
|
|
color: Colors.black,
|
|
child: Row(
|
|
children: [
|
|
// Left Panel (65%): Vector Tactical Route Map
|
|
Expanded(
|
|
flex: 65,
|
|
child: Container(
|
|
margin: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: const Color(0xFF1F1F1F), width: 1.2),
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
return SizedBox(
|
|
width: constraints.maxWidth,
|
|
height: constraints.maxHeight,
|
|
child: UnifiedMapView(
|
|
key: ValueKey('evo_map_${constraints.maxWidth.toInt()}x${constraints.maxHeight.toInt()}'),
|
|
center: AppLatLng(centerLat, centerLng),
|
|
zoom: 17.0,
|
|
markers: mapMarkers,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
|
|
// Right Panel (35%): Telemetry Stack
|
|
Expanded(
|
|
flex: 35,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(top: 8, bottom: 8, right: 8),
|
|
child: Column(
|
|
children: [
|
|
// 1. G-Force Indicator with Max Accel / Brake Bars
|
|
Expanded(
|
|
flex: 4,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF0D0D0D),
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(color: const Color(0xFF222222), width: 1.0),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
const Text(
|
|
'G-FORCE INDICATOR',
|
|
style: TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w800,
|
|
color: Colors.white70,
|
|
letterSpacing: 0.1,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Expanded(
|
|
child: Row(
|
|
children: [
|
|
// Accel bar
|
|
_buildVerticalGBar('ACCEL', accelG > 0 ? accelG : 0.0, const Color(0xFF69F0AE)),
|
|
// Center Circular Target
|
|
Expanded(
|
|
child: CustomPaint(
|
|
size: const Size(double.infinity, double.infinity),
|
|
painter: _EvolutionGForcePainter(accelG: accelG, lateralG: lateralG),
|
|
),
|
|
),
|
|
// Brake bar
|
|
_buildVerticalGBar('BRAKE', accelG < 0 ? -accelG : 0.0, const Color(0xFFFF2A2A)),
|
|
],
|
|
),
|
|
),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
'MAX ACCEL\n${records.maxAccelG > 0 ? records.maxAccelG.toStringAsFixed(2) : "0.98"}G',
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontFamily: 'JetBrains Mono',
|
|
fontSize: 8,
|
|
fontWeight: FontWeight.w700,
|
|
color: Colors.white54,
|
|
),
|
|
),
|
|
Text(
|
|
'MAX BRAKE\n${records.maxBrakingG > 0 ? records.maxBrakingG.toStringAsFixed(2) : "1.10"}G',
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontFamily: 'JetBrains Mono',
|
|
fontSize: 8,
|
|
fontWeight: FontWeight.w700,
|
|
color: Colors.white54,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 6),
|
|
|
|
// 2. Lean Angle Indicator Arc
|
|
Expanded(
|
|
flex: 3,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF0D0D0D),
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(color: const Color(0xFF222222), width: 1.0),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
const Text(
|
|
'LEAN ANGLE INDICATOR',
|
|
style: TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w800,
|
|
color: Colors.white70,
|
|
letterSpacing: 0.1,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
CustomPaint(
|
|
size: const Size(double.infinity, double.infinity),
|
|
painter: _LandscapeLeanPainter(leanAngle: leanAngle),
|
|
),
|
|
Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
'${leanAngle.abs().toStringAsFixed(0)}°',
|
|
style: const TextStyle(
|
|
fontFamily: 'JetBrains Mono',
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w900,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
Text(
|
|
leanAngle < -2 ? 'LEAN LEFT' : (leanAngle > 2 ? 'LEAN RIGHT' : 'UPRIGHT'),
|
|
style: TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 8,
|
|
fontWeight: FontWeight.w800,
|
|
color: leanAngle.abs() > 2 ? const Color(0xFF00E5FF) : Colors.white38,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
'L: ${session.maxLeanLeft.toStringAsFixed(0)}°',
|
|
style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 9, fontWeight: FontWeight.w700, color: Color(0xFF00E5FF)),
|
|
),
|
|
Text(
|
|
'R: ${session.maxLeanRight.toStringAsFixed(0)}°',
|
|
style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 9, fontWeight: FontWeight.w700, color: Color(0xFF00E5FF)),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 6),
|
|
|
|
// 3. Digital Speedometer Cluster
|
|
Expanded(
|
|
flex: 3,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF0A0A0A),
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(color: const Color(0xFF1F1F1F), width: 1.0),
|
|
),
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
speed.toStringAsFixed(0),
|
|
style: const TextStyle(
|
|
fontFamily: 'JetBrains Mono',
|
|
fontSize: 44,
|
|
fontWeight: FontWeight.w900,
|
|
fontStyle: FontStyle.italic,
|
|
color: Colors.white,
|
|
height: 0.95,
|
|
shadows: [
|
|
Shadow(
|
|
color: Color(0xFF00E5FF),
|
|
blurRadius: 12,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Text(
|
|
'SPEED (MPH)',
|
|
style: TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 9,
|
|
fontWeight: FontWeight.w800,
|
|
color: Color(0xFF00E5FF),
|
|
letterSpacing: 0.1,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildVerticalGBar(String label, double value, Color color) {
|
|
final heightFactor = (value / 1.5).clamp(0.0, 1.0);
|
|
|
|
return SizedBox(
|
|
width: 24,
|
|
child: Column(
|
|
children: [
|
|
Expanded(
|
|
child: Container(
|
|
width: 8,
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF1A1A1A),
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
alignment: Alignment.bottomCenter,
|
|
child: FractionallySizedBox(
|
|
heightFactor: heightFactor,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: color,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
label,
|
|
style: const TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 7,
|
|
fontWeight: FontWeight.w800,
|
|
color: Colors.white54,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EvolutionGForcePainter extends CustomPainter {
|
|
final double accelG;
|
|
final double lateralG;
|
|
|
|
_EvolutionGForcePainter({required this.accelG, required this.lateralG});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final center = Offset(size.width / 2, size.height / 2);
|
|
final radius = math.min(size.width, size.height) * 0.42;
|
|
|
|
final ringPaint = Paint()
|
|
..color = const Color(0xFF222222)
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 1.0;
|
|
|
|
canvas.drawCircle(center, radius * 0.5, ringPaint);
|
|
canvas.drawCircle(center, radius, ringPaint);
|
|
|
|
final crossPaint = Paint()
|
|
..color = Colors.white10
|
|
..strokeWidth = 1.0;
|
|
canvas.drawLine(Offset(center.dx - radius, center.dy), Offset(center.dx + radius, center.dy), crossPaint);
|
|
canvas.drawLine(Offset(center.dx, center.dy - radius), Offset(center.dx, center.dy + radius), crossPaint);
|
|
|
|
final dx = (lateralG / 1.5).clamp(-1.0, 1.0) * radius;
|
|
final dy = (-accelG / 1.5).clamp(-1.0, 1.0) * radius;
|
|
final dotPos = Offset(center.dx + dx, center.dy + dy);
|
|
|
|
canvas.drawCircle(dotPos, 4.5, Paint()..color = const Color(0xFF00E5FF));
|
|
canvas.drawCircle(dotPos, 8.0, Paint()..color = const Color(0xFF00E5FF).withValues(alpha: 0.35));
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _EvolutionGForcePainter oldDelegate) =>
|
|
oldDelegate.accelG != accelG || oldDelegate.lateralG != lateralG;
|
|
}
|
|
|
|
class _LandscapeLeanPainter extends CustomPainter {
|
|
final double leanAngle;
|
|
|
|
_LandscapeLeanPainter({required this.leanAngle});
|
|
|
|
@override
|
|
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);
|
|
|
|
if (radius <= 0) return;
|
|
|
|
// Background track arc from 180 to 360 (top half semicircle)
|
|
final trackPaint = Paint()
|
|
..color = const Color(0xFF222222)
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 6.0
|
|
..strokeCap = StrokeCap.round;
|
|
|
|
canvas.drawArc(
|
|
Rect.fromCircle(center: center, radius: radius),
|
|
math.pi,
|
|
math.pi,
|
|
false,
|
|
trackPaint,
|
|
);
|
|
|
|
// Tick marks at 0, +-15, +-30, +-45, +-60
|
|
final tickPaint = Paint()
|
|
..color = Colors.white24
|
|
..strokeWidth = 1.2;
|
|
|
|
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);
|
|
}
|
|
|
|
// Active lean sweep arc from 270 (top/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 = 6.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,
|
|
);
|
|
}
|
|
|
|
// Needle from center to arc
|
|
final needleRad = (270 + clampedLean) * (math.pi / 180.0);
|
|
final needleEnd = Offset(
|
|
center.dx + radius * math.cos(needleRad),
|
|
center.dy + radius * math.sin(needleRad),
|
|
);
|
|
|
|
final needlePaint = Paint()
|
|
..color = const Color(0xFF00E5FF)
|
|
..strokeWidth = 2.5
|
|
..strokeCap = StrokeCap.round;
|
|
|
|
canvas.drawLine(center, needleEnd, 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);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _LandscapeLeanPainter oldDelegate) =>
|
|
oldDelegate.leanAngle != leanAngle;
|
|
}
|