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

708 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); // SF Default
// Layer toggle states
bool _showRoute = true;
bool _showSpeedHeatmap = false;
bool _showHardAccel = true;
bool _showHardBraking = true;
bool _showCornering = true;
@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;
if (telemetry.isNotEmpty) {
_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 = [];
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;
});
}
}
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<AppPolyline> _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,
),
];
}
// Multi-color segmented polyline according to speed brackets
final polylines = <AppPolyline>[];
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<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,
));
}
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)
}
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),
),
),
);
}
}
final filteredEvents = _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)),
),
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)),
],
),
],
),
),
);
}
@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) {
// 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);
},
),
],
),
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'),
),
),
],
),
),
),
],
);
},
),
);
}
// 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 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)'),
),
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();
},
),
),
],
);
},
),
);
}
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),
),
],
),
),
),
),
);
}
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 _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)),
],
),
);
}
}