MotorSignal-App/lib/features/dashboard/widgets/evolution_landscape_dashboa...

537 lines
21 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: Stack(
children: [
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,
),
);
},
),
Positioned(
bottom: 12,
left: 12,
child: ElevatedButton.icon(
onPressed: () {
if (session.state == RideState.riding) {
ref.read(rideStateMachineProvider.notifier).finishRide();
} else {
ref.read(rideStateMachineProvider.notifier).startRide();
}
},
style: ElevatedButton.styleFrom(
backgroundColor: session.state == RideState.riding ? const Color(0xFF3D0000) : const Color(0xFF00252A),
foregroundColor: session.state == RideState.riding ? const Color(0xFFFF2A2A) : const Color(0xFF00E5FF),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: session.state == RideState.riding ? const Color(0xFFFF2A2A) : const Color(0xFF00E5FF),
width: 1.5,
),
),
elevation: 4,
),
icon: Icon(
session.state == RideState.riding ? Icons.stop_circle : Icons.play_circle,
size: 18,
color: session.state == RideState.riding ? const Color(0xFFFF2A2A) : const Color(0xFF00E5FF),
),
label: Text(
session.state == RideState.riding ? 'STOP SESSION' : 'START SESSION',
style: const TextStyle(
fontFamily: 'Sora',
fontSize: 12,
fontWeight: FontWeight.w800,
letterSpacing: 0.1,
),
),
),
),
],
),
),
),
// 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 / 2);
final radius = math.min(size.width * 0.44, size.height * 0.44);
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 = 4.0
..strokeCap = StrokeCap.round;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
math.pi,
math.pi,
false,
trackPaint,
);
// 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 angleDeg in [-60, -45, -30, -15, 0, 15, 30, 45, 60]) {
final rad = (270 + angleDeg) * (math.pi / 180.0);
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)
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 outer track without intersecting center text)
final needleRad = (270 + clampedLean) * (math.pi / 180.0);
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()
..color = const Color(0xFF00E5FF)
..strokeWidth = 2.5
..strokeCap = StrokeCap.round;
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
bool shouldRepaint(covariant _LandscapeLeanPainter oldDelegate) =>
oldDelegate.leanAngle != leanAngle;
}