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

553 lines
25 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 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)).toList();
if (gps.isEmpty) return [];
final markers = <AppMapMarker>[];
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))),
],
),
);
},
);
}
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)),
],
),
);
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),
));
}
return polylines;
}
List<AppPolyline> _buildLongitudinalEventPolylines(
List<TelemetrySample> samples, {
required bool Function(double g) predicate,
required String idPrefix,
required Color color,
}) {
if (samples.isEmpty) return [];
// A threshold crossing is often only one telemetry sample wide. Requiring
// two consecutive qualifying samples made isolated acceleration/braking
// events disappear from the map. Build event runs, then include one GPS
// sample on each side so every event renders as a visible route segment.
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!));
}
// The surrounding point guarantees two points for an isolated event
// while preserving a single continuous segment for adjacent events.
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];
// Keep nearby threshold samples in one event run. A gap of more than
// two samples starts a new visual event while allowing brief sensor
// dropouts inside a hard maneuver.
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)),
]),
);
}