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

2096 lines
79 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 'dart:async';
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 '../../core/responsive/responsive_layout.dart';
import '../../core/theme/theme_extensions.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_providers.dart';
import '../../services/alerts/road_alert_service.dart';
import '../../services/alerts/widgets/quick_swarm_report_modal.dart';
import '../../services/map/map_layer_settings.dart';
import '../../services/routes/route_models.dart';
import '../../services/routes/route_provider.dart';
import '../../services/routes/route_providers.dart';
import '../../services/routes/navigation_handoff.dart';
import '../../services/navigation/navigation_session_service.dart';
import '../dashboard/widgets/navigation_maneuver_card.dart';
import 'widgets/tactical_layers_bottom_sheet.dart';
import '../replay/replay_screen.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;
double _currentZoom = 17.0;
bool _isNorthUp = true;
String _destinationQuery = '';
late final TextEditingController _searchController;
Timer? _searchDebounce;
bool _isSearching = false;
List<PlaceSearchResult> _searchResults = [];
ResolvedPlace? _selectedDestination;
RouteCalculationResult? _previewRouteResult;
bool _isAddressExpanded = false;
bool _isCalculatingRoute = false;
int _routeCalculationRequestId = 0;
RoutingOptions _routingOptions = const RoutingOptions();
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();
_searchController = TextEditingController();
if (widget.rideId != null) {
_loadRideHistory();
} else {
_isLoadingHistory = false;
_initLiveLocation();
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
ref.read(roadAlertServiceProvider).refreshAlerts(force: true);
}
});
}
@override
void dispose() {
_searchDebounce?.cancel();
_searchController.dispose();
super.dispose();
}
void _onSearchQueryChanged(String query, AppLatLng currentPos) {
setState(() => _destinationQuery = query);
_searchDebounce?.cancel();
final clean = query.trim();
if (clean.length < 2) {
setState(() {
_searchResults = [];
_isSearching = false;
});
return;
}
setState(() => _isSearching = true);
_searchDebounce = Timer(const Duration(milliseconds: 350), () async {
try {
final results = await ref
.read(activeRouteCalculationProvider)
.searchPlaces(clean, nearLocation: currentPos);
if (mounted && _destinationQuery.trim() == clean) {
setState(() {
_searchResults = results;
_isSearching = false;
});
}
} catch (_) {
if (mounted) setState(() => _isSearching = false);
}
});
}
Future<void> _onSelectPlace(
PlaceSearchResult place,
AppLatLng currentPos,
) async {
_searchDebounce?.cancel();
FocusScope.of(context).unfocus();
setState(() {
_searchResults = [];
_isAddressExpanded = false;
_searchController.text = place.primaryText;
_destinationQuery = place.primaryText;
});
try {
final resolved = place.hasCoordinates
? place.toResolvedPlace()!
: await ref
.read(activeRouteCalculationProvider)
.resolvePlace(place.providerId);
if (!mounted) return;
setState(() => _selectedDestination = resolved);
await _calculateRouteToDestination(resolved, currentPos);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to resolve place: $e')),
);
}
}
}
Future<void> _calculateRouteToDestination(
ResolvedPlace dest,
AppLatLng currentPos,
) async {
final reqId = ++_routeCalculationRequestId;
setState(() => _isCalculatingRoute = true);
try {
final origin = RouteStop(
id: 'origin',
order: 0,
displayName: 'Current Location',
latitude: currentPos.latitude,
longitude: currentPos.longitude,
);
final destinationStop = RouteStop(
id: 'dest',
order: 1,
displayName: dest.displayName,
latitude: dest.latitude,
longitude: dest.longitude,
);
final result = await ref
.read(activeRouteCalculationProvider)
.calculateRoute([origin, destinationStop], options: _routingOptions);
if (mounted && reqId == _routeCalculationRequestId) {
setState(() {
_previewRouteResult = result;
_isCalculatingRoute = false;
});
}
} catch (e) {
if (mounted && reqId == _routeCalculationRequestId) {
setState(() => _isCalculatingRoute = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Route calculation error: $e')),
);
}
}
}
void _clearDestination() {
_searchDebounce?.cancel();
_searchController.clear();
setState(() {
_destinationQuery = '';
_searchResults = [];
_selectedDestination = null;
_previewRouteResult = null;
_isAddressExpanded = false;
});
}
void _startTurnByTurnNavigation() {
if (_previewRouteResult == null || _selectedDestination == null) return;
final preview = _previewRouteResult!;
final dest = _selectedDestination!;
setState(() {
_previewRouteResult = null;
});
ref.read(navigationSessionProvider.notifier).startNavigation(
calculationResult: preview,
destination: dest,
options: _routingOptions,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Navigation started to ${dest.displayName}'),
backgroundColor: const Color(0xFF00E5FF),
),
);
}
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({bool showTwistyHighlights = true}) {
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 && showTwistyHighlights) {
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) {
final swarmService = ref.read(swarmRoadAlertServiceProvider);
final canVote = alert.sourceType == RoadAlertSourceType.userReported &&
swarmService.canUserVote(alert.id);
final hasVoted = swarmService.hasUserVoted(alert.id);
final isConfirmed = alert.isCommunityConfirmed;
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: isConfirmed
? Colors.greenAccent.withValues(alpha: 0.2)
: (alert.sourceType == RoadAlertSourceType.userReported
? Colors.orangeAccent.withValues(alpha: 0.2)
: Colors.white10),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isConfirmed
? Colors.greenAccent
: (alert.sourceType ==
RoadAlertSourceType.userReported
? Colors.orangeAccent
: Colors.transparent),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isConfirmed
? Icons.verified
: (alert.sourceType ==
RoadAlertSourceType.userReported
? Icons.schedule
: Icons.shield_outlined),
size: 12,
color: isConfirmed
? Colors.greenAccent
: (alert.sourceType ==
RoadAlertSourceType.userReported
? Colors.orangeAccent
: Colors.white70),
),
const SizedBox(width: 4),
Text(
isConfirmed
? 'COMMUNITY CONFIRMED'
: (alert.sourceType ==
RoadAlertSourceType.userReported
? 'UNVERIFIED'
: 'VERIFIED'),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: isConfirmed
? Colors.greenAccent
: (alert.sourceType ==
RoadAlertSourceType.userReported
? Colors.orangeAccent
: Colors.white70),
),
),
],
),
),
],
),
const SizedBox(height: 16),
Text(alert.description,
style: const TextStyle(fontSize: 15, height: 1.3)),
const SizedBox(height: 12),
if (alert.sourceType == RoadAlertSourceType.userReported)
Text(
'Trust & Verification: ${alert.confirmations} confirmed · ${alert.dismissals} dismissed',
style: const TextStyle(fontSize: 12, color: Colors.white70),
),
const SizedBox(height: 12),
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),
),
],
),
if (alert.sourceType == RoadAlertSourceType.userReported) ...[
const SizedBox(height: 16),
if (hasVoted)
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white10,
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: const Text(
'You have already verified this report.',
style: TextStyle(color: Colors.white70, fontSize: 12),
),
)
else
Row(
children: [
Expanded(
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor:
Colors.greenAccent.withValues(alpha: 0.2),
foregroundColor: Colors.greenAccent,
side: const BorderSide(
color: Colors.greenAccent, width: 1.5),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
icon: const Icon(Icons.thumb_up_alt_outlined, size: 18),
label: const Text('STILL THERE',
style: TextStyle(fontWeight: FontWeight.bold)),
onPressed: canVote
? () async {
final messenger =
ScaffoldMessenger.of(context);
Navigator.of(ctx).pop();
await swarmService.confirmAlert(alert.id);
messenger.showSnackBar(
const SnackBar(
content: Text(
'Alert confirmed! Thank you for verifying.'),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
),
);
}
: null,
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor:
Colors.redAccent.withValues(alpha: 0.2),
foregroundColor: Colors.redAccent,
side: const BorderSide(
color: Colors.redAccent, width: 1.5),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
icon:
const Icon(Icons.thumb_down_alt_outlined, size: 18),
label: const Text('NOT THERE',
style: TextStyle(fontWeight: FontWeight.bold)),
onPressed: canVote
? () async {
final messenger =
ScaffoldMessenger.of(context);
Navigator.of(ctx).pop();
await swarmService.clearAlert(alert.id);
messenger.showSnackBar(
const SnackBar(
content: Text(
'Report marked as clear. Thank you!'),
backgroundColor: Colors.orange,
duration: Duration(seconds: 2),
),
);
}
: null,
),
),
],
),
],
],
),
),
);
}
@override
Widget build(BuildContext context) {
if (_isLoadingHistory) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
ref.listen<NavigationSessionState>(navigationSessionProvider, (prev, next) {
if (prev?.isNavigating == true && !next.isNavigating && mounted) {
setState(() {
_previewRouteResult = null;
_selectedDestination = null;
});
}
});
final isFollowMode = ref.watch(mapFollowStateProvider);
final layerSettings = ref.watch(mapLayerSettingsProvider);
final colors = context.motorColors;
final theme = Theme.of(context);
return ResponsiveLayout(
builder: (context, responsive) {
final isShortLandscape = responsive.isLandscape && responsive.height < 450;
if (widget.rideId != null) {
final polylines = _buildPolylines(showTwistyHighlights: layerSettings.showTwistyHighlights);
return Scaffold(
appBar: AppBar(
title: Text('Ride #${widget.rideId} Multi-Layer Map'),
actions: [
IconButton(
key: const ValueKey('preview_layers_button'),
tooltip: 'Tactical Layers (MAP-02)',
icon: Icon(Icons.layers_outlined, color: colors.cyberCyan),
onPressed: () => TacticalLayersBottomSheet.show(context),
),
IconButton(
key: const ValueKey('play_replay_appbar_button'),
tooltip: 'Fire Off Replay',
icon: Icon(Icons.play_circle_fill, color: colors.cyberCyan),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ReplayScreen(rideId: widget.rideId!, autoPlay: true),
),
);
},
),
],
),
body: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: UnifiedMapView(
key: ValueKey('preview_map_${widget.rideId}'),
center: _initialCenter ?? _fallbackLocation,
zoom: _currentZoom,
polylines: polylines,
markers: _getFilteredMarkers(),
),
),
if (_routePoints.isNotEmpty) _buildMultiLayerFilterOverlay(colors),
if (_showSpeedHeatmap) _buildSpeedLegend(colors),
if (_routePoints.isEmpty)
Positioned(
top: 16,
left: 20,
right: 20,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: colors.surfaceContainer.withValues(alpha: 0.95),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: colors.cyberCyan.withValues(alpha: 0.4),
),
),
child: Row(
children: [
Icon(
Icons.info_outline,
color: colors.cyberCyan,
size: 20,
),
const SizedBox(width: 10),
Expanded(
child: Text(
'No GPS breadcrumbs were recorded during this test session.',
style: TextStyle(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
fontSize: 12,
fontFamily: 'Inter',
),
),
),
],
),
),
),
// Bottom Center: FIRE OFF REPLAY floating button
if (_routePoints.isNotEmpty)
Positioned(
bottom: 24,
left: 20,
right: 80,
child: Center(
child: FilledButton.icon(
key: const ValueKey('fire_off_ride_preview_button'),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ReplayScreen(
rideId: widget.rideId!,
autoPlay: true,
),
),
);
},
icon: const Icon(Icons.play_arrow_rounded, size: 22),
label: const Text(
'FIRE OFF REPLAY',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12.5,
fontWeight: FontWeight.w800,
letterSpacing: 0.6,
),
),
style: FilledButton.styleFrom(
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
),
),
// Right HUD zoom controls on history preview
Positioned(
right: 14,
bottom: 30,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_HudIconButton(
key: const ValueKey('zoom_in_button'),
icon: Icons.add,
tooltip: 'Zoom In',
onTap: () {
setState(() => _currentZoom = (_currentZoom + 1.0).clamp(3.0, 19.0));
},
),
const SizedBox(height: 8),
_HudIconButton(
key: const ValueKey('zoom_out_button'),
icon: Icons.remove,
tooltip: 'Zoom Out',
onTap: () {
setState(() => _currentZoom = (_currentZoom - 1.0).clamp(3.0, 19.0));
},
),
],
),
),
],
),
);
}
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: _isNorthUp ? currentHeading : 0.0,
iconWidget: Icon(
Icons.navigation,
color: colors.cyberCyan,
size: 40,
),
);
final List<AppMapMarker> mapMarkers = [motorcycleMarker];
final allLiveAlerts = liveAlertsAsync.value ?? [];
final liveAlerts = allLiveAlerts.where((alert) {
if (!layerSettings.showPoliceTraps &&
(alert.category == RoadAlertCategory.police ||
alert.category == RoadAlertCategory.speedCamera)) {
return false;
}
return true;
}).toList();
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),
),
);
}
final navSession = ref.watch(navigationSessionProvider);
if (_selectedDestination != null) {
mapMarkers.add(
AppMapMarker(
id: 'destination_marker',
position: _selectedDestination!.latLng,
label: _selectedDestination!.displayName,
iconWidget: Container(
padding: const EdgeInsets.all(6),
decoration: const BoxDecoration(
color: Color(0xFFFF0055),
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black54, blurRadius: 4)],
),
child: const Icon(Icons.flag, color: Colors.white, size: 20),
),
),
);
}
final livePolylines = <AppPolyline>[];
if (_previewRouteResult != null &&
_previewRouteResult!.geometry.isNotEmpty) {
livePolylines.add(
AppPolyline(
id: 'route_preview_polyline',
points: _previewRouteResult!.geometry,
color: colors.cyberCyan,
width: 5.5,
),
);
} else if (navSession.isNavigating &&
navSession.calculationResult?.geometry.isNotEmpty == true) {
livePolylines.add(
AppPolyline(
id: 'nav_active_polyline',
points: navSession.calculationResult!.geometry,
color: colors.cyberCyan,
width: 5.5,
),
);
}
return Scaffold(
extendBodyBehindAppBar: isShortLandscape,
appBar: AppBar(
title: const Text('Live Map'),
backgroundColor: isShortLandscape
? colors.surfaceContainer.withValues(alpha: 0.75)
: null,
elevation: isShortLandscape ? 0 : null,
),
body: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: UnifiedMapView(
key: const ValueKey('live_map_view'),
center: currentPos,
zoom: _currentZoom,
bearing: _isNorthUp ? 0.0 : currentHeading,
markers: mapMarkers,
polylines: livePolylines,
),
),
// Active Navigation Maneuver HUD or Top Search & Preview Cards
if (navSession.isNavigating)
const Positioned(
top: 10,
left: 14,
right: 14,
child: NavigationManeuverCard(),
)
else if (!isShortLandscape) ...[
Positioned(
top: 10,
left: 14,
right: 14,
child: _buildTopSearchCard(colors, currentPos),
),
if (_searchResults.isNotEmpty)
Positioned(
top: 106,
left: 14,
right: 14,
child: _buildSearchResultsOverlay(colors, currentPos),
),
if (_previewRouteResult != null)
Positioned(
bottom: 24,
left: 14,
right: 14,
child: _buildRoutePreviewCard(colors, currentPos),
),
],
// Offline Cached Only Warning Pill
if (layerSettings.offlineOnly)
Positioned(
top: isShortLandscape ? 8 : 106,
left: 14,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF1E1708),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: colors.warningAmber.withValues(alpha: 0.6),
width: 0.8,
),
boxShadow: const [
BoxShadow(color: Colors.black45, blurRadius: 4),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.wifi_off, size: 12, color: colors.warningAmber),
const SizedBox(width: 5),
Text(
'OFFLINE TILES ONLY',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 9.5,
fontWeight: FontWeight.bold,
color: colors.warningAmber,
),
),
],
),
),
),
// Right Vertical HUD Dock (Layers, Compass Orient, Zoom In, Zoom Out, Recenter)
Positioned(
right: 14,
top: isShortLandscape ? 58 : 124,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_HudIconButton(
key: const ValueKey('tactical_layers_button'),
icon: Icons.layers_outlined,
tooltip: 'Tactical Layers (MAP-02)',
color: colors.cyberCyan,
onTap: () => TacticalLayersBottomSheet.show(context),
),
const SizedBox(height: 8),
_HudIconButton(
key: const ValueKey('compass_orient_button'),
icon: _isNorthUp ? Icons.explore : Icons.navigation,
tooltip: _isNorthUp ? 'North Up' : 'Heading Up',
color: _isNorthUp ? Colors.white70 : colors.cyberCyan,
onTap: () => setState(() => _isNorthUp = !_isNorthUp),
),
const SizedBox(height: 8),
_HudIconButton(
key: const ValueKey('zoom_in_button'),
icon: Icons.add,
tooltip: 'Zoom In',
onTap: () {
ref.read(mapFollowStateProvider.notifier).userInteracted();
setState(() => _currentZoom = (_currentZoom + 1.0).clamp(3.0, 19.0));
},
),
const SizedBox(height: 8),
_HudIconButton(
key: const ValueKey('zoom_out_button'),
icon: Icons.remove,
tooltip: 'Zoom Out',
onTap: () {
ref.read(mapFollowStateProvider.notifier).userInteracted();
setState(() => _currentZoom = (_currentZoom - 1.0).clamp(3.0, 19.0));
},
),
if (!isFollowMode) ...[
const SizedBox(height: 8),
_HudIconButton(
key: const ValueKey('recenter_rider_button'),
icon: Icons.my_location,
tooltip: 'Recenter on Rider',
color: colors.cyberCyan,
backgroundColor: colors.cyberCyan.withValues(alpha: 0.25),
onTap: () => ref.read(mapFollowStateProvider.notifier).recenter(),
),
],
],
),
),
// Bottom-left Quick Swarm Report Button (Repositioned to prevent card collision and hidden during search/typing)
if (MediaQuery.of(context).viewInsets.bottom == 0 && _searchResults.isEmpty)
Positioned(
bottom: _previewRouteResult != null ? 295 : 24,
left: 20,
child: FloatingActionButton.extended(
key: const ValueKey('quick_swarm_report_button'),
backgroundColor: const Color(0xFFFF9100),
foregroundColor: Colors.black,
icon: const Icon(Icons.campaign, size: 22),
label: const Text(
'REPORT',
style: TextStyle(
fontWeight: FontWeight.bold,
letterSpacing: 0.6,
),
),
onPressed: () => QuickSwarmReportModal.show(context),
),
),
],
),
);
},
);
}
Widget _buildMultiLayerFilterOverlay(MotorSignalThemeColors colors) {
return Positioned(
top: 10,
left: 10,
right: 10,
child: Card(
color: colors.surfaceContainer.withValues(alpha: 0.85),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: colors.outlineVariant),
),
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: colors.cyberCyan.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: colors.launchGreen.withValues(alpha: 0.35),
),
const SizedBox(width: 6),
FilterChip(
label: const Text('Braking'),
selected: _showHardBraking,
onSelected: (v) => setState(() => _showHardBraking = v),
selectedColor: colors.raceRed.withValues(alpha: 0.35),
),
const SizedBox(width: 6),
FilterChip(
label: const Text('Cornering'),
selected: _showCornering,
onSelected: (v) => setState(() => _showCornering = v),
selectedColor: colors.cyberCyan.withValues(alpha: 0.35),
),
],
),
),
),
),
);
}
Widget _buildSpeedLegend(MotorSignalThemeColors colors) => Positioned(
bottom: 20,
left: 14,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: colors.surfaceContainer.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colors.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'SPEED RANGES (MPH)',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 8.5,
fontWeight: FontWeight.bold,
color: colors.cyberCyan,
),
),
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,
fontWeight: FontWeight.w600,
),
),
],
),
);
Widget _buildTopSearchCard(MotorSignalThemeColors colors, AppLatLng currentPos) {
final theme = Theme.of(context);
return Card(
elevation: 6,
shadowColor: Colors.black54,
color: colors.surfaceContainer.withValues(alpha: 0.95),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: colors.cyberCyan.withValues(alpha: 0.25),
width: 0.8,
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (_isSearching || _isCalculatingRoute)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
Icon(Icons.search, size: 20, color: colors.cyberCyan),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _searchController,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 13,
color: theme.colorScheme.onSurface,
),
decoration: InputDecoration(
hintText: 'Search destinations, twisty passes...',
hintStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 12.5,
color: theme.colorScheme.onSurface.withValues(alpha: 0.45),
),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 6),
),
onChanged: (val) => _onSearchQueryChanged(val, currentPos),
onSubmitted: (val) => _onSearchQueryChanged(val, currentPos),
),
),
if (_destinationQuery.isNotEmpty)
GestureDetector(
key: const ValueKey('clear_search_button'),
onTap: _clearDestination,
child: Icon(
Icons.close,
size: 18,
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
),
),
],
),
const SizedBox(height: 6),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildQuickDestinationChip(
label: 'Gas Stations',
customKey: 'chip_gas_and_fuel',
icon: Icons.local_gas_station,
colors: colors,
currentPos: currentPos,
),
const SizedBox(width: 6),
_buildQuickDestinationChip(
label: 'Twisty Passes',
icon: Icons.alt_route,
colors: colors,
currentPos: currentPos,
),
const SizedBox(width: 6),
_buildQuickDestinationChip(
label: 'Scenic Rest',
icon: Icons.photo_camera,
colors: colors,
currentPos: currentPos,
),
const SizedBox(width: 6),
_buildQuickDestinationChip(
label: 'Repair/Moto',
icon: Icons.build,
colors: colors,
currentPos: currentPos,
),
],
),
),
],
),
),
);
}
Widget _buildQuickDestinationChip({
required String label,
required IconData icon,
required MotorSignalThemeColors colors,
required AppLatLng currentPos,
String? customKey,
}) {
final isSelected = _destinationQuery.toLowerCase() == label.toLowerCase();
final keyString = customKey ??
'chip_${label.toLowerCase().replaceAll(' ', '_').replaceAll('/', '_').replaceAll('&', 'and')}';
return InkWell(
key: ValueKey(keyString),
borderRadius: BorderRadius.circular(20),
onTap: () {
if (isSelected) {
_clearDestination();
} else {
_searchController.text = label;
_onSearchQueryChanged(label, currentPos);
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: isSelected
? colors.cyberCyan.withValues(alpha: 0.25)
: colors.surfaceContainerHigh.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected
? colors.cyberCyan
: colors.outlineVariant.withValues(alpha: 0.6),
width: isSelected ? 1.2 : 0.8,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 13,
color: isSelected ? colors.cyberCyan : Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 11,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? colors.cyberCyan : Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
Widget _buildSearchResultsOverlay(
MotorSignalThemeColors colors,
AppLatLng currentPos,
) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Card(
key: const ValueKey('search_results_overlay'),
elevation: 8,
shadowColor: Colors.black87,
color: theme.colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: colors.cyberCyan.withValues(alpha: isDark ? 0.4 : 0.6),
width: 1.0,
),
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 280),
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 6),
itemCount: _searchResults.length,
separatorBuilder: (_, _) => Divider(
height: 1,
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.35),
),
itemBuilder: (context, index) {
final item = _searchResults[index];
return ListTile(
dense: true,
leading: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: colors.cyberCyan.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: Icon(
item.hasCoordinates ? Icons.pin_drop : Icons.place_outlined,
size: 18,
color: colors.cyberCyan,
),
),
title: Text(
item.primaryText,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 13,
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
),
subtitle: item.secondaryText.isNotEmpty
? Text(
item.secondaryText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 11,
color: theme.colorScheme.onSurfaceVariant,
),
)
: null,
selectedTileColor: colors.cyberCyan.withValues(alpha: 0.12),
hoverColor: colors.cyberCyan.withValues(alpha: 0.08),
onTap: () => _onSelectPlace(item, currentPos),
);
},
),
),
);
}
Widget _buildRoutePreviewCard(
MotorSignalThemeColors colors,
AppLatLng currentPos,
) {
final preview = _previewRouteResult!;
final dest = _selectedDestination;
final confirmedPref = preview.options?.preference ?? _routingOptions.preference;
return Card(
key: const ValueKey('route_preview_card'),
elevation: 8,
color: colors.surfaceContainer.withValues(alpha: 0.95),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: colors.cyberCyan.withValues(alpha: 0.6),
width: 1.2,
),
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
key: const ValueKey('destination_address_expand_toggle'),
borderRadius: BorderRadius.circular(6),
onTap: () => setState(() => _isAddressExpanded = !_isAddressExpanded),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
dest?.displayName ?? 'Destination Route',
maxLines: _isAddressExpanded ? 4 : 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
const SizedBox(width: 4),
Icon(
_isAddressExpanded ? Icons.expand_less : Icons.expand_more,
size: 18,
color: colors.cyberCyan,
),
],
),
),
const SizedBox(height: 4),
Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
'${preview.distanceMiles.toStringAsFixed(1)} mi • ${preview.formattedDuration}',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
fontWeight: FontWeight.w700,
color: colors.cyberCyan,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: colors.cyberCyan.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: colors.cyberCyan,
width: 0.8,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isCalculatingRoute) ...[
SizedBox(
width: 8,
height: 8,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: colors.cyberCyan,
),
),
const SizedBox(width: 4),
],
Text(
confirmedPref.name.toUpperCase(),
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 9.5,
fontWeight: FontWeight.bold,
color: colors.cyberCyan,
),
),
],
),
),
if (preview.options?.avoidHighways == true)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: const Color(0xFFF59E0B).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: const Color(0xFFF59E0B),
width: 0.8,
),
),
child: const Text(
'NO HIGHWAYS',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 9.5,
fontWeight: FontWeight.bold,
color: Color(0xFFF59E0B),
),
),
),
if (preview.curvatureMetric != null && preview.curvatureMetric! >= 25.0)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: const Color(0xFF10B981).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: const Color(0xFF10B981),
width: 0.8,
),
),
child: Text(
'CURVES: ${preview.twistinessRating}',
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 9.5,
fontWeight: FontWeight.bold,
color: Color(0xFF10B981),
),
),
),
],
),
],
),
),
IconButton(
key: const ValueKey('clear_route_button'),
icon: Icon(Icons.close, color: Theme.of(context).colorScheme.onSurfaceVariant),
onPressed: _clearDestination,
tooltip: 'Cancel Destination',
),
],
),
const SizedBox(height: 10),
// Routing Preferences Selector Row
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildPreferenceChip(
preference: RoutingPreference.fastest,
label: 'Fastest',
colors: colors,
currentPos: currentPos,
),
const SizedBox(width: 6),
_buildPreferenceChip(
preference: RoutingPreference.shortest,
label: 'Shortest',
colors: colors,
currentPos: currentPos,
),
const SizedBox(width: 6),
_buildPreferenceChip(
preference: RoutingPreference.twisty,
label: 'Twisty (Curves)',
colors: colors,
currentPos: currentPos,
),
const SizedBox(width: 6),
_buildPreferenceChip(
preference: RoutingPreference.scenic,
label: 'Scenic (No Hwy)',
colors: colors,
currentPos: currentPos,
),
const SizedBox(width: 6),
FilterChip(
key: const ValueKey('preference_avoid_highways'),
label: const Text('Avoid Highways'),
selected: _routingOptions.avoidHighways,
labelStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 11,
fontWeight: _routingOptions.avoidHighways ? FontWeight.bold : FontWeight.normal,
color: _routingOptions.avoidHighways ? Colors.black : Theme.of(context).colorScheme.onSurfaceVariant,
),
selectedColor: colors.cyberCyan,
backgroundColor: colors.surfaceContainerHigh,
onSelected: (selected) {
setState(() {
_routingOptions = _routingOptions.copyWith(avoidHighways: selected);
});
if (_selectedDestination != null) {
_calculateRouteToDestination(_selectedDestination!, currentPos);
}
},
),
],
),
),
const SizedBox(height: 12),
// Navigation Actions: Start Turn-by-Turn & External Handoff
Row(
children: [
Expanded(
child: FilledButton.icon(
key: const ValueKey('start_navigation_button'),
icon: const Icon(Icons.navigation, size: 18),
label: const Text(
'START NAVIGATION',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
fontWeight: FontWeight.w800,
letterSpacing: 0.5,
),
),
style: FilledButton.styleFrom(
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
onPressed: _startTurnByTurnNavigation,
),
),
const SizedBox(width: 8),
OutlinedButton(
key: const ValueKey('handoff_navigation_button'),
style: OutlinedButton.styleFrom(
foregroundColor: colors.cyberCyan,
side: BorderSide(color: colors.cyberCyan),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
onPressed: () {
if (dest != null) {
NavigationHandoffService.showHandoffSheet(
context: context,
latitude: dest.latitude,
longitude: dest.longitude,
displayName: dest.displayName,
);
}
},
child: const Icon(Icons.open_in_new, size: 18),
),
],
),
],
),
),
);
}
Widget _buildPreferenceChip({
required RoutingPreference preference,
required String label,
required MotorSignalThemeColors colors,
required AppLatLng currentPos,
}) {
final isSelected = _routingOptions.preference == preference;
final onVariant = Theme.of(context).colorScheme.onSurfaceVariant;
return ChoiceChip(
key: ValueKey('preference_${preference.name}'),
label: Text(label),
selected: isSelected,
labelStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 11,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? Colors.black : onVariant,
),
selectedColor: colors.cyberCyan,
backgroundColor: colors.surfaceContainerHigh,
onSelected: (selected) {
if (selected) {
setState(() {
_routingOptions = _routingOptions.copyWith(
preference: preference,
avoidHighways: preference == RoutingPreference.twisty ||
preference == RoutingPreference.scenic,
);
});
if (_selectedDestination != null) {
_calculateRouteToDestination(_selectedDestination!, currentPos);
}
}
},
);
}
}
class _HudIconButton extends StatelessWidget {
final IconData icon;
final String? tooltip;
final VoidCallback onTap;
final Color? color;
final Color? backgroundColor;
const _HudIconButton({
super.key,
required this.icon,
this.tooltip,
required this.onTap,
this.color,
this.backgroundColor,
});
@override
Widget build(BuildContext context) {
final colors = context.motorColors;
final isDark = Theme.of(context).brightness == Brightness.dark;
final defaultBg = isDark ? colors.surfaceContainer : const Color(0xFF1E293B);
final button = Material(
color: backgroundColor ?? defaultBg.withValues(alpha: 0.9),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: colors.outlineVariant.withValues(alpha: 0.6),
width: 0.8,
),
),
elevation: 4,
shadowColor: Colors.black54,
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: onTap,
child: Container(
width: 40,
height: 40,
alignment: Alignment.center,
child: Icon(
icon,
size: 20,
color: color ?? Colors.white,
),
),
),
);
if (tooltip != null) {
return Tooltip(
message: tooltip!,
child: button,
);
}
return button;
}
}