MotorSignal-App/lib/features/map/map_screen.dart

626 lines
27 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:drift/drift.dart' as drift;
import 'package:geolocator/geolocator.dart';
import 'package:intl/intl.dart';
import '../../database/database.dart';
import '../../services/location_service.dart';
import '../../services/map/map_provider_interface.dart';
import '../../services/map/map_providers.dart';
import '../../services/map/unified_map_view.dart';
import '../../services/alerts/road_alert_models.dart';
import '../../services/alerts/road_alert_service.dart';
class MapScreen extends ConsumerStatefulWidget {
final int? rideId;
const MapScreen({super.key, this.rideId});
@override
ConsumerState<MapScreen> createState() => _MapScreenState();
}
class _MapScreenState extends ConsumerState<MapScreen> {
bool _isLoadingHistory = true;
List<TelemetrySample> _telemetrySamples = [];
List<AppLatLng> _routePoints = [];
List<AppMapMarker> _eventMarkers = [];
AppLatLng? _initialCenter;
AppLatLng _fallbackLocation = const AppLatLng(37.7749, -122.4194);
bool _showRoute = true;
bool _showSpeedHeatmap = false;
bool _showHardAccel = true;
bool _showHardBraking = true;
bool _showCornering = true;
static const double _hardAccelThresholdG = 0.35;
static const double _hardBrakingThresholdG = -0.35;
static const double _corneringLeanThresholdDeg = 20.0;
static const double _corneringLateralGThreshold = 0.35;
static const Duration _eventMergeWindow = Duration(milliseconds: 1500);
@override
void initState() {
super.initState();
if (widget.rideId != null) {
_loadRideHistory();
} else {
_isLoadingHistory = false;
_initLiveLocation();
}
}
Future<void> _initLiveLocation() async {
final locationService = ref.read(locationServiceProvider);
await locationService.requestPermissions();
try {
final lastPos = await Geolocator.getLastKnownPosition();
if (lastPos != null && mounted) {
setState(() => _fallbackLocation = AppLatLng(lastPos.latitude, lastPos.longitude));
} else {
final currentPos = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(timeLimit: Duration(seconds: 3)),
).catchError((_) => Position(
longitude: -122.4194,
latitude: 37.7749,
timestamp: DateTime.now(),
accuracy: 0,
altitude: 0,
heading: 0,
speed: 0,
speedAccuracy: 0,
altitudeAccuracy: 0,
headingAccuracy: 0,
));
if (mounted) setState(() => _fallbackLocation = AppLatLng(currentPos.latitude, currentPos.longitude));
}
} catch (_) {}
}
Future<void> _loadRideHistory() async {
final db = ref.read(databaseProvider);
final telemetry = await (db.select(db.telemetrySamples)
..where((t) => t.rideId.equals(widget.rideId!))
..orderBy([(t) => drift.OrderingTerm(expression: t.timestamp)]))
.get();
final events = await (db.select(db.rideEvents)
..where((e) => e.rideId.equals(widget.rideId!)))
.get();
_telemetrySamples = telemetry;
_routePoints = telemetry
.where((t) => t.latitude != null && t.longitude != null)
.map((t) => AppLatLng(t.latitude!, t.longitude!))
.toList();
if (_routePoints.isNotEmpty) _initialCenter = _routePoints.first;
_eventMarkers = _buildStoredEventMarkers(events);
_eventMarkers.addAll(_buildTelemetryEventMarkers(telemetry));
if (mounted) setState(() => _isLoadingHistory = false);
}
List<AppMapMarker> _buildStoredEventMarkers(List<RideEvent> events) {
final markers = <AppMapMarker>[];
for (final event in events) {
if (event.latitude == null || event.longitude == null) continue;
final typeLower = event.eventType.toLowerCase();
Color markerColor = Colors.redAccent;
IconData markerIcon = Icons.warning_amber_rounded;
if (typeLower.contains('corner') || typeLower.contains('lean')) {
markerColor = const Color(0xFF00E5FF);
markerIcon = Icons.cached;
} else if (typeLower.contains('accel')) {
markerColor = const Color(0xFF76FF03);
markerIcon = Icons.arrow_upward;
} else if (typeLower.contains('brak')) {
markerColor = const Color(0xFFFF5252);
markerIcon = Icons.arrow_downward;
} else if (typeLower.contains('speed')) {
markerColor = const Color(0xFFE040FB);
markerIcon = Icons.speed;
}
markers.add(_eventMarker(
id: 'event_${event.id}',
position: AppLatLng(event.latitude!, event.longitude!),
label: event.eventType,
value: event.value,
timestamp: event.timestamp,
markerColor: markerColor,
markerIcon: markerIcon,
));
}
return markers;
}
List<AppMapMarker> _buildTelemetryEventMarkers(List<TelemetrySample> samples) {
final gps = samples.where((s) =>
s.latitude != null && s.longitude != null &&
(s.longitudinalG != null || s.accelX != null || s.lateralG != null || s.leanAngle != null)).toList();
if (gps.isEmpty) return [];
final markers = <AppMapMarker>[];
DateTime? lastAccel;
DateTime? lastBrake;
DateTime? lastCorner;
for (final sample in gps) {
final longitudinalG = sample.longitudinalG ?? sample.accelX;
final lateralG = sample.lateralG;
final leanAngle = sample.leanAngle;
if (longitudinalG != null && longitudinalG.isFinite) {
if (longitudinalG >= _hardAccelThresholdG &&
(lastAccel == null || sample.timestamp.difference(lastAccel).abs() > _eventMergeWindow)) {
markers.add(_eventMarker(
id: 'telemetry_accel_${sample.id}',
position: AppLatLng(sample.latitude!, sample.longitude!),
label: 'Hard Acceleration',
value: longitudinalG,
timestamp: sample.timestamp,
markerColor: const Color(0xFF76FF03),
markerIcon: Icons.arrow_upward,
));
lastAccel = sample.timestamp;
} else if (longitudinalG <= _hardBrakingThresholdG &&
(lastBrake == null || sample.timestamp.difference(lastBrake).abs() > _eventMergeWindow)) {
markers.add(_eventMarker(
id: 'telemetry_braking_${sample.id}',
position: AppLatLng(sample.latitude!, sample.longitude!),
label: 'Hard Braking',
value: longitudinalG,
timestamp: sample.timestamp,
markerColor: const Color(0xFFFF5252),
markerIcon: Icons.arrow_downward,
));
lastBrake = sample.timestamp;
}
}
final isCornering = (leanAngle != null && leanAngle.isFinite && leanAngle.abs() >= _corneringLeanThresholdDeg) ||
(lateralG != null && lateralG.isFinite && lateralG.abs() >= _corneringLateralGThreshold);
if (isCornering &&
(lastCorner == null || sample.timestamp.difference(lastCorner).abs() > _eventMergeWindow)) {
final cornerValue = leanAngle != null && leanAngle.isFinite
? leanAngle
: lateralG!.abs();
markers.add(_eventMarker(
id: 'telemetry_corner_${sample.id}',
position: AppLatLng(sample.latitude!, sample.longitude!),
label: 'Cornering',
value: cornerValue,
timestamp: sample.timestamp,
markerColor: const Color(0xFF00E5FF),
markerIcon: Icons.turn_right,
));
lastCorner = sample.timestamp;
}
}
return markers;
}
AppMapMarker _eventMarker({
required String id,
required AppLatLng position,
required String label,
required double value,
required DateTime timestamp,
required Color markerColor,
required IconData markerIcon,
}) {
return AppMapMarker(
id: id,
position: position,
label: label,
iconWidget: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: markerColor.withValues(alpha: 0.9),
shape: BoxShape.circle,
boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 4)],
),
child: Icon(markerIcon, color: Colors.black, size: 18),
),
onTap: () {
if (!mounted) return;
final timeStr = DateFormat('HH:mm:ss.SSS').format(timestamp.toLocal());
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF111111),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: markerColor.withValues(alpha: 0.6), width: 1.2),
),
title: Row(
children: [
Icon(markerIcon, color: markerColor, size: 20),
const SizedBox(width: 8),
Expanded(child: Text(label.toUpperCase(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w900, color: Colors.white))),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDialogRow('Timestamp:', timeStr),
_buildDialogRow('Value:', value.toStringAsFixed(2)),
_buildDialogRow('GPS Location:', '${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('CLOSE', style: TextStyle(color: Colors.white70))),
],
),
);
},
);
}
Widget _buildDialogRow(String label, String value) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(fontSize: 11, color: Colors.white54)),
Flexible(child: Text(value, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.white), textAlign: TextAlign.right)),
],
),
);
List<AppPolyline> _buildPolylines() {
if (_routePoints.isEmpty) return [];
final polylines = <AppPolyline>[];
if (_showRoute) {
polylines.add(AppPolyline(
id: 'history_route',
points: _routePoints,
color: const Color(0xFF00E5FF),
width: 4.5,
));
}
final validSamples = _telemetrySamples
.where((t) => t.latitude != null && t.longitude != null)
.toList();
if (validSamples.isEmpty) return polylines;
if (_showSpeedHeatmap) {
final step = (validSamples.length / 1500).ceil().clamp(1, 10);
List<AppLatLng> currentSegment = [];
Color currentColor = _getSpeedColor(validSamples.first.speed ?? 0.0);
for (int i = 0; i < validSamples.length; i += step) {
final s = validSamples[i];
final pt = AppLatLng(s.latitude!, s.longitude!);
final segColor = _getSpeedColor(s.speed ?? 0.0);
if (segColor != currentColor && currentSegment.isNotEmpty) {
currentSegment.add(pt);
polylines.add(AppPolyline(id: 'speed_seg_${polylines.length}', points: List.from(currentSegment), color: currentColor, width: 5.0));
currentSegment = [pt];
currentColor = segColor;
} else {
currentSegment.add(pt);
}
}
if (currentSegment.length > 1) {
polylines.add(AppPolyline(id: 'speed_seg_${polylines.length}', points: currentSegment, color: currentColor, width: 5.0));
}
}
if (_showHardAccel) {
polylines.addAll(_buildLongitudinalEventPolylines(
validSamples,
predicate: (g) => g >= _hardAccelThresholdG,
idPrefix: 'accel_layer',
color: const Color(0xFF76FF03),
));
}
if (_showHardBraking) {
polylines.addAll(_buildLongitudinalEventPolylines(
validSamples,
predicate: (g) => g <= _hardBrakingThresholdG,
idPrefix: 'braking_layer',
color: const Color(0xFFFF5252),
));
}
if (_showCornering) {
polylines.addAll(_buildCorneringEventPolylines(validSamples));
}
return polylines;
}
List<AppPolyline> _buildLongitudinalEventPolylines(
List<TelemetrySample> samples, {
required bool Function(double g) predicate,
required String idPrefix,
required Color color,
}) {
if (samples.isEmpty) return [];
final polylines = <AppPolyline>[];
final eventIndices = <int>[];
for (var i = 0; i < samples.length; i++) {
final g = samples[i].longitudinalG ?? samples[i].accelX;
if (g != null && g.isFinite && predicate(g)) {
eventIndices.add(i);
}
}
if (eventIndices.isEmpty) return polylines;
var runStart = eventIndices.first;
var runEnd = eventIndices.first;
var segmentIndex = 0;
void emitRun(int start, int end) {
final expandedStart = start > 0 ? start - 1 : start;
final expandedEnd = end < samples.length - 1 ? end + 1 : end;
final points = <AppLatLng>[];
for (var i = expandedStart; i <= expandedEnd; i++) {
final sample = samples[i];
points.add(AppLatLng(sample.latitude!, sample.longitude!));
}
if (points.length >= 2) {
polylines.add(AppPolyline(
id: '${idPrefix}_$segmentIndex',
points: points,
color: color,
width: 7.0,
));
segmentIndex++;
}
}
for (var i = 1; i < eventIndices.length; i++) {
final index = eventIndices[i];
if (index - runEnd <= 2) {
runEnd = index;
} else {
emitRun(runStart, runEnd);
runStart = runEnd = index;
}
}
emitRun(runStart, runEnd);
return polylines;
}
List<AppPolyline> _buildCorneringEventPolylines(List<TelemetrySample> samples) {
if (samples.isEmpty) return [];
final eventIndices = <int>[];
for (var i = 0; i < samples.length; i++) {
final sample = samples[i];
final lean = sample.leanAngle;
final lateral = sample.lateralG;
final isCornering = (lean != null && lean.isFinite && lean.abs() >= _corneringLeanThresholdDeg) ||
(lateral != null && lateral.isFinite && lateral.abs() >= _corneringLateralGThreshold);
if (isCornering) eventIndices.add(i);
}
if (eventIndices.isEmpty) return [];
final polylines = <AppPolyline>[];
var runStart = eventIndices.first;
var runEnd = eventIndices.first;
var segmentIndex = 0;
void emitRun(int start, int end) {
// Include a GPS breadcrumb before and after the detected cornering
// samples. This makes a short threshold crossing visible as a route
// segment rather than a point hidden beneath a marker.
final expandedStart = start > 0 ? start - 1 : start;
final expandedEnd = end < samples.length - 1 ? end + 1 : end;
final points = <AppLatLng>[];
for (var i = expandedStart; i <= expandedEnd; i++) {
final sample = samples[i];
if (sample.latitude != null && sample.longitude != null) {
points.add(AppLatLng(sample.latitude!, sample.longitude!));
}
}
if (points.length >= 2) {
polylines.add(AppPolyline(
id: 'cornering_layer_$segmentIndex',
points: points,
color: const Color(0xFF00E5FF),
width: 7.0,
));
segmentIndex++;
}
}
for (var i = 1; i < eventIndices.length; i++) {
final index = eventIndices[i];
if (index - runEnd <= 2) {
runEnd = index;
} else {
emitRun(runStart, runEnd);
runStart = runEnd = index;
}
}
emitRun(runStart, runEnd);
return polylines;
}
Color _getSpeedColor(double speedMph) {
if (speedMph < 25) return const Color(0xFF00E5FF);
if (speedMph < 50) return const Color(0xFF2979FF);
if (speedMph < 75) return const Color(0xFFFFD600);
if (speedMph < 100) return const Color(0xFFFF6D00);
return const Color(0xFFE040FB);
}
List<AppMapMarker> _getFilteredMarkers() {
final markers = <AppMapMarker>[];
if (_routePoints.isNotEmpty) {
markers.add(AppMapMarker(
id: 'route_start', position: _routePoints.first, label: 'START',
iconWidget: Container(padding: const EdgeInsets.all(5), decoration: const BoxDecoration(color: Colors.greenAccent, shape: BoxShape.circle, boxShadow: [BoxShadow(color: Colors.black54, blurRadius: 4)]), child: const Icon(Icons.flag, color: Colors.black, size: 18)),
));
if (_routePoints.length > 1) {
markers.add(AppMapMarker(
id: 'route_end', position: _routePoints.last, label: 'FINISH',
iconWidget: Container(padding: const EdgeInsets.all(5), decoration: const BoxDecoration(color: Colors.amberAccent, shape: BoxShape.circle, boxShadow: [BoxShadow(color: Colors.black54, blurRadius: 4)]), child: const Icon(Icons.sports_score, color: Colors.black, size: 18)),
));
}
}
markers.addAll(_eventMarkers.where((m) {
final label = (m.label ?? '').toLowerCase();
if (label.contains('accel') && !_showHardAccel) return false;
if (label.contains('brak') && !_showHardBraking) return false;
if ((label.contains('corner') || label.contains('lean')) && !_showCornering) return false;
return true;
}));
return markers;
}
void _showRoadAlertDetails(RoadAlert alert) {
showModalBottomSheet(
context: context, backgroundColor: Colors.grey[900],
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
builder: (ctx) => Padding(
padding: const EdgeInsets.all(20),
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Container(padding: const EdgeInsets.all(8), decoration: BoxDecoration(color: alert.category.color.withValues(alpha: 0.2), shape: BoxShape.circle), child: Icon(alert.category.icon, color: alert.category.color, size: 28)),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(alert.category.displayName.toUpperCase(), style: TextStyle(color: alert.category.color, fontWeight: FontWeight.bold, fontSize: 12, letterSpacing: 1.1)),
if (alert.roadName != null && alert.roadName!.isNotEmpty) Text(alert.roadName!, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
])),
Container(padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration(color: Colors.white10, borderRadius: BorderRadius.circular(12)), child: Text(alert.sourceType == RoadAlertSourceType.userReported ? 'User Reported' : 'Verified Provider', style: const TextStyle(fontSize: 10, color: Colors.white70))),
]),
const SizedBox(height: 16),
Text(alert.description, style: const TextStyle(fontSize: 15, height: 1.3)),
const SizedBox(height: 16),
const Divider(color: Colors.white24),
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
if (alert.distanceFromUser != null) Text('Distance: ${(alert.distanceFromUser! * 0.000621371).toStringAsFixed(2)} mi', style: const TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold)),
Text('Source: ${alert.source}', style: const TextStyle(color: Colors.white54, fontSize: 12)),
]),
]),
),
);
}
@override
Widget build(BuildContext context) {
if (_isLoadingHistory) return const Scaffold(body: Center(child: CircularProgressIndicator()));
final activeProviderType = ref.watch(mapProviderTypeNotifierProvider);
final isFollowMode = ref.watch(mapFollowStateProvider);
final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape;
if (widget.rideId != null) {
final polylines = _buildPolylines();
return Scaffold(
appBar: AppBar(
title: Text('Ride #${widget.rideId} Multi-Layer Map'),
actions: [IconButton(
tooltip: 'Switch Map Provider',
icon: Icon(activeProviderType == AppMapType.openStreetMap ? Icons.map : Icons.map_outlined),
onPressed: () {
final nextType = activeProviderType == AppMapType.openStreetMap ? AppMapType.googleMaps : AppMapType.openStreetMap;
ref.read(mapProviderTypeNotifierProvider.notifier).setMapProviderType(nextType);
},
)],
),
body: LayoutBuilder(builder: (context, constraints) => Stack(fit: StackFit.expand, children: [
Positioned.fill(child: SizedBox(
width: constraints.maxWidth, height: constraints.maxHeight,
child: UnifiedMapView(
key: ValueKey('preview_map_${constraints.maxWidth.toInt()}x${constraints.maxHeight.toInt()}'),
center: _initialCenter ?? _fallbackLocation, zoom: 15.0,
polylines: polylines, markers: _getFilteredMarkers(),
),
)),
if (_routePoints.isNotEmpty) _buildMultiLayerFilterOverlay(),
if (_showSpeedHeatmap) _buildSpeedLegend(),
if (_routePoints.isEmpty) Positioned(top: 16, left: 20, right: 20, child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(color: const Color(0xFF0D0D0D).withValues(alpha: 0.9), borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFF00E5FF).withValues(alpha: 0.4))),
child: const Row(children: [Icon(Icons.info_outline, color: Color(0xFF00E5FF), size: 20), SizedBox(width: 10), Expanded(child: Text('No GPS breadcrumbs were recorded during this test session.', style: TextStyle(color: Colors.white70, fontSize: 12, fontFamily: 'Inter')))]),
)),
])),
);
}
final locationAsync = ref.watch(locationStreamProvider);
final liveAlertsAsync = ref.watch(activeAlertsStreamProvider);
final currentPos = locationAsync.when(data: (pos) => AppLatLng(pos.latitude, pos.longitude), error: (_, _) => _fallbackLocation, loading: () => _fallbackLocation);
final currentHeading = locationAsync.when(data: (pos) => pos.heading, error: (_, _) => 0.0, loading: () => 0.0);
final motorcycleMarker = AppMapMarker(id: 'motorcycle_live', position: currentPos, heading: currentHeading, iconWidget: const Icon(Icons.navigation, color: Colors.cyanAccent, size: 40));
final List<AppMapMarker> mapMarkers = [motorcycleMarker];
final liveAlerts = liveAlertsAsync.value ?? [];
for (var alert in liveAlerts) {
mapMarkers.add(AppMapMarker(
id: '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(6), 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: 22)),
onTap: () => _showRoadAlertDetails(alert),
));
}
return Scaffold(
extendBodyBehindAppBar: isLandscape,
appBar: AppBar(
title: const Text('Live Map'), backgroundColor: isLandscape ? Colors.black.withValues(alpha: 0.6) : null, elevation: isLandscape ? 0 : null,
actions: [PopupMenuButton<AppMapType>(
icon: const Icon(Icons.layers),
onSelected: (type) => ref.read(mapProviderTypeNotifierProvider.notifier).setMapProviderType(type),
itemBuilder: (context) => const [
PopupMenuItem(value: AppMapType.openStreetMap, child: Text('OpenStreetMap / Tactical (Primary)')),
PopupMenuItem(value: AppMapType.googleMaps, child: Text('Google Maps')),
],
)],
),
body: LayoutBuilder(builder: (context, constraints) => Stack(fit: StackFit.expand, children: [
Positioned.fill(child: SizedBox(width: constraints.maxWidth, height: constraints.maxHeight, child: UnifiedMapView(
key: ValueKey('live_map_${constraints.maxWidth.toInt()}x${constraints.maxHeight.toInt()}'), center: currentPos, zoom: 17.0, markers: mapMarkers,
))),
if (!isFollowMode) Positioned(bottom: 24, right: 24, child: FloatingActionButton.extended(
backgroundColor: Colors.cyanAccent, foregroundColor: Colors.black, icon: const Icon(Icons.my_location), label: const Text('RECENTER', style: TextStyle(fontWeight: FontWeight.bold)),
onPressed: () => ref.read(mapFollowStateProvider.notifier).recenter(),
)),
])),
);
}
Widget _buildMultiLayerFilterOverlay() {
return Positioned(top: 10, left: 10, right: 10, child: Card(
color: Colors.black.withValues(alpha: 0.8), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12), side: const BorderSide(color: Color(0xFF1F1F1F))),
child: Padding(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: SingleChildScrollView(scrollDirection: Axis.horizontal, child: Row(children: [
FilterChip(label: const Text('Route'), selected: _showRoute, onSelected: (v) => setState(() => _showRoute = v), selectedColor: const Color(0xFF00E5FF).withValues(alpha: 0.35)),
const SizedBox(width: 6),
FilterChip(label: const Text('Speed'), selected: _showSpeedHeatmap, onSelected: (v) => setState(() => _showSpeedHeatmap = v), selectedColor: const Color(0xFFE040FB).withValues(alpha: 0.35)),
const SizedBox(width: 6),
FilterChip(label: const Text('Accel'), selected: _showHardAccel, onSelected: (v) => setState(() => _showHardAccel = v), selectedColor: const Color(0xFF76FF03).withValues(alpha: 0.35)),
const SizedBox(width: 6),
FilterChip(label: const Text('Braking'), selected: _showHardBraking, onSelected: (v) => setState(() => _showHardBraking = v), selectedColor: const Color(0xFFFF5252).withValues(alpha: 0.35)),
const SizedBox(width: 6),
FilterChip(label: const Text('Cornering'), selected: _showCornering, onSelected: (v) => setState(() => _showCornering = v), selectedColor: const Color(0xFF00E5FF).withValues(alpha: 0.35)),
]))),
));
}
Widget _buildSpeedLegend() => Positioned(bottom: 20, left: 14, child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.85), borderRadius: BorderRadius.circular(8), border: Border.all(color: const Color(0xFF222222))),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
const Text('SPEED RANGES (MPH)', style: TextStyle(fontFamily: 'JetBrains Mono', fontSize: 8.5, fontWeight: FontWeight.bold, color: Colors.white70)),
const SizedBox(height: 4),
_buildLegendItem('0–25', const Color(0xFF00E5FF)),
_buildLegendItem('25–50', const Color(0xFF2979FF)),
_buildLegendItem('50–75', const Color(0xFFFFD600)),
_buildLegendItem('75–100', const Color(0xFFFF6D00)),
_buildLegendItem('100+', const Color(0xFFE040FB)),
]),
));
Widget _buildLegendItem(String label, Color color) => Padding(
padding: const EdgeInsets.symmetric(vertical: 1.5),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Container(width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
const SizedBox(width: 6),
Text(label, style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 9, color: Colors.white)),
]),
);
}