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

1170 lines
43 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 '../../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_service.dart';
import '../../services/alerts/widgets/quick_swarm_report_modal.dart';
import '../../services/map/map_layer_settings.dart';
import 'widgets/tactical_layers_bottom_sheet.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;
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();
}
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
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) {
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 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),
),
],
),
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',
),
),
),
],
),
),
),
// 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 liveAlerts = layerSettings.showPoliceTraps
? (liveAlertsAsync.value ?? [])
: <RoadAlert>[];
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: 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,
markers: mapMarkers,
),
),
// Top Floating Search Card (portrait and large screens)
if (!isShortLandscape)
Positioned(
top: 10,
left: 14,
right: 14,
child: _buildTopSearchCard(colors),
),
// 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
Positioned(
bottom: 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) {
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: [
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) {
setState(() => _destinationQuery = val);
},
),
),
if (_destinationQuery.isNotEmpty)
GestureDetector(
key: const ValueKey('clear_search_button'),
onTap: () {
_searchController.clear();
setState(() => _destinationQuery = '');
},
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 & Fuel',
icon: Icons.local_gas_station,
colors: colors,
),
const SizedBox(width: 6),
_buildQuickDestinationChip(
label: 'Twisty Passes',
icon: Icons.alt_route,
colors: colors,
),
const SizedBox(width: 6),
_buildQuickDestinationChip(
label: 'Scenic Rest',
icon: Icons.photo_camera,
colors: colors,
),
const SizedBox(width: 6),
_buildQuickDestinationChip(
label: 'Repair/Moto',
icon: Icons.build,
colors: colors,
),
],
),
),
],
),
),
);
}
Widget _buildQuickDestinationChip({
required String label,
required IconData icon,
required MotorSignalThemeColors colors,
}) {
final isSelected = _destinationQuery.toLowerCase() == label.toLowerCase();
return InkWell(
key: ValueKey('chip_${label.toLowerCase().replaceAll(' ', '_').replaceAll('/', '_').replaceAll('&', 'and')}'),
borderRadius: BorderRadius.circular(20),
onTap: () {
setState(() {
if (isSelected) {
_destinationQuery = '';
_searchController.clear();
} else {
_destinationQuery = label;
_searchController.text = label;
}
});
},
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 : Colors.white70,
),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 11,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? colors.cyberCyan : Colors.white70,
),
),
],
),
),
);
}
}
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 button = Material(
color: backgroundColor ?? colors.surfaceContainer.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;
}
}