diff --git a/lib/features/map/map_screen.dart b/lib/features/map/map_screen.dart index 2dc88b1..b143127 100644 --- a/lib/features/map/map_screen.dart +++ b/lib/features/map/map_screen.dart @@ -25,15 +25,20 @@ class _MapScreenState extends ConsumerState { List _routePoints = []; List _eventMarkers = []; AppLatLng? _initialCenter; - AppLatLng _fallbackLocation = const AppLatLng(37.7749, -122.4194); // SF Default + AppLatLng _fallbackLocation = const AppLatLng(37.7749, -122.4194); - // Layer toggle states bool _showRoute = true; bool _showSpeedHeatmap = false; bool _showHardAccel = true; bool _showHardBraking = true; bool _showCornering = true; + // These are intentionally derived from the already-persisted telemetry. + // They do not alter the frozen telemetry acquisition/fusion pipeline. + static const double _hardAccelThresholdG = 0.35; + static const double _hardBrakingThresholdG = -0.35; + static const Duration _eventMergeWindow = Duration(milliseconds: 1500); + @override void initState() { super.initState(); @@ -48,13 +53,10 @@ class _MapScreenState extends ConsumerState { Future _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); - }); + setState(() => _fallbackLocation = AppLatLng(lastPos.latitude, lastPos.longitude)); } else { final currentPos = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings(timeLimit: Duration(seconds: 3)), @@ -70,638 +72,389 @@ class _MapScreenState extends ConsumerState { altitudeAccuracy: 0, headingAccuracy: 0, )); - - if (mounted) { - setState(() { - _fallbackLocation = AppLatLng(currentPos.latitude, currentPos.longitude); - }); - } + if (mounted) setState(() => _fallbackLocation = AppLatLng(currentPos.latitude, currentPos.longitude)); } } catch (_) {} } Future _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; - if (telemetry.isNotEmpty) { - _routePoints = telemetry - .where((t) => t.latitude != null && t.longitude != null) - .map((t) => AppLatLng(t.latitude!, t.longitude!)) - .toList(); + _eventMarkers = _buildStoredEventMarkers(events); + _eventMarkers.addAll(_buildTelemetryEventMarkers(telemetry)); - if (_routePoints.isNotEmpty) { - _initialCenter = _routePoints.first; - } - } - - _eventMarkers = []; - for (final event in events) { - if (event.latitude != null && event.longitude != null) { - Color markerColor = Colors.redAccent; - IconData markerIcon = Icons.warning_amber_rounded; - final typeLower = event.eventType.toLowerCase(); - - 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; - } - - _eventMarkers.add( - AppMapMarker( - id: 'event_${event.id}', - position: AppLatLng(event.latitude!, event.longitude!), - label: event.eventType, - 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) { - final timeStr = DateFormat('HH:mm:ss.SSS').format(event.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( - event.eventType.toUpperCase(), - style: const TextStyle( - fontFamily: 'Sora', - fontSize: 14, - fontWeight: FontWeight.w900, - color: Colors.white, - ), - ), - ), - ], - ), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildDialogRow('Timestamp:', timeStr), - _buildDialogRow('Event Value:', event.value.toStringAsFixed(2)), - _buildDialogRow('GPS Location:', '${event.latitude!.toStringAsFixed(5)}, ${event.longitude!.toStringAsFixed(5)}'), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('CLOSE', style: TextStyle(color: Colors.white70, fontFamily: 'JetBrains Mono')), - ), - ], - ), - ); - } - }, - ), - ); - } - } - - if (mounted) { - setState(() { - _isLoadingHistory = false; - }); - } + if (mounted) setState(() => _isLoadingHistory = false); } - Widget _buildDialogRow(String label, String value) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(label, style: const TextStyle(fontFamily: 'Inter', fontSize: 11, color: Colors.white54)), - Text(value, style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, fontWeight: FontWeight.bold, color: Colors.white)), - ], + List _buildStoredEventMarkers(List events) { + final markers = []; + 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; + } + + // Build map events directly from persisted longitudinal G when RideEvents are + // absent/incomplete. Existing RideEvents remain untouched and are still shown. + List _buildTelemetryEventMarkers(List samples) { + final gps = samples.where((s) => + s.latitude != null && s.longitude != null && + (s.longitudinalG != null || s.accelX != null)).toList(); + if (gps.isEmpty) return []; + + final markers = []; + DateTime? lastAccel; + DateTime? lastBrake; + + for (final sample in gps) { + final g = sample.longitudinalG ?? sample.accelX; + if (g == null || !g.isFinite) continue; + + if (g >= _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: g, + timestamp: sample.timestamp, + markerColor: const Color(0xFF76FF03), + markerIcon: Icons.arrow_upward, + )); + lastAccel = sample.timestamp; + } else if (g <= _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: g, + timestamp: sample.timestamp, + markerColor: const Color(0xFFFF5252), + markerIcon: Icons.arrow_downward, + )); + lastBrake = 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('G 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))), + ], + ), + ); + }, ); } - List _buildPolylines() { - if (_routePoints.isEmpty) return []; - - if (!_showSpeedHeatmap) { - if (!_showRoute) return []; - // Standard cyan route polyline - return [ - AppPolyline( - id: 'history_route', - points: _routePoints, - color: const Color(0xFF00E5FF), - width: 4.5, + 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)), + Text(value, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.white)), + ], ), - ]; - } + ); - // Multi-color segmented polyline according to speed brackets + List _buildPolylines() { + if (_routePoints.isEmpty || !_showRoute) return []; + if (!_showSpeedHeatmap) { + return [AppPolyline(id: 'history_route', points: _routePoints, color: const Color(0xFF00E5FF), width: 4.5)]; + } final polylines = []; final validSamples = _telemetrySamples.where((t) => t.latitude != null && t.longitude != null).toList(); if (validSamples.isEmpty) return []; - - // Step downsampling if large dataset final step = (validSamples.length / 1500).ceil().clamp(1, 10); - List 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, - )); + 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, - )); + polylines.add(AppPolyline(id: 'speed_seg_${polylines.length}', points: currentSegment, color: currentColor, width: 5.0)); } - return polylines; } Color _getSpeedColor(double speedMph) { - if (speedMph < 25) return const Color(0xFF00E5FF); // 0 - 25 MPH (Cyan) - if (speedMph < 50) return const Color(0xFF2979FF); // 25 - 50 MPH (Blue) - if (speedMph < 75) return const Color(0xFFFFD600); // 50 - 75 MPH (Yellow) - if (speedMph < 100) return const Color(0xFFFF6D00); // 75 - 100 MPH (Orange) - return const Color(0xFFE040FB); // 100+ MPH (Magenta) + 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 _getFilteredMarkers() { final markers = []; 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), - ), - ), - ); + 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.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)), + )); } } - - final filteredEvents = _eventMarkers.where((m) { + 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; - }); - - markers.addAll(filteredEvents); + })); return markers; } void _showRoadAlertDetails(RoadAlert alert) { showModalBottomSheet( - context: context, - backgroundColor: Colors.grey[900], - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), + context: context, backgroundColor: Colors.grey[900], + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), builder: (ctx) => Padding( - padding: const EdgeInsets.all(20.0), - 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)), - ], - ), - ], - ), + 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())); - } - + 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) { - // Historical Route View 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); - }, + 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(), ), - ], - ), - body: LayoutBuilder( - builder: (context, constraints) { - return 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'), - ), - ), - ], - ), - ), - ), - ], - ); - }, - ), + )), + 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')))]), + )), + ])), ); } - // Live Tracking View 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 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 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), - ), - ); + 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( - 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)'), - ), - const PopupMenuItem( - value: AppMapType.googleMaps, - child: Text('Google Maps'), - ), - ], - ), - ], - ), - body: LayoutBuilder( - builder: (context, constraints) { - return 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(); - }, - ), - ), - ], - ); - }, + title: const Text('Live Map'), backgroundColor: isLandscape ? Colors.black.withValues(alpha: 0.6) : null, elevation: isLandscape ? 0 : null, + actions: [PopupMenuButton( + 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.0, vertical: 4.0), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - FilterChip( - label: const Text('Route'), - selected: _showRoute, - onSelected: (val) => setState(() => _showRoute = val), - selectedColor: const Color(0xFF00E5FF).withValues(alpha: 0.35), - ), - const SizedBox(width: 6), - FilterChip( - label: const Text('Speed'), - selected: _showSpeedHeatmap, - onSelected: (val) => setState(() => _showSpeedHeatmap = val), - selectedColor: const Color(0xFFE040FB).withValues(alpha: 0.35), - ), - const SizedBox(width: 6), - FilterChip( - label: const Text('Accel'), - selected: _showHardAccel, - onSelected: (val) => setState(() => _showHardAccel = val), - selectedColor: const Color(0xFF76FF03).withValues(alpha: 0.35), - ), - const SizedBox(width: 6), - FilterChip( - label: const Text('Braking'), - selected: _showHardBraking, - onSelected: (val) => setState(() => _showHardBraking = val), - selectedColor: const Color(0xFFFF5252).withValues(alpha: 0.35), - ), - const SizedBox(width: 6), - FilterChip( - label: const Text('Cornering'), - selected: _showCornering, - onSelected: (val) => setState(() => _showCornering = val), - selectedColor: const Color(0xFF00E5FF).withValues(alpha: 0.35), - ), - ], - ), - ), - ), - ), - ); + 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() { - return 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 _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) { - return 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)), - ], - ), - ); - } + 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)), + ]), + ); }