import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; import 'package:go_router/go_router.dart'; import '../../core/responsive/responsive_layout.dart'; import '../../core/responsive/viewport_size.dart'; import '../../core/theme/theme_extensions.dart'; import '../../services/auth/auth_providers.dart'; import '../../services/map/map_provider_interface.dart'; import '../../services/map/map_providers.dart'; import '../../services/map/unified_map_view.dart'; import '../../services/routes/navigation_handoff.dart'; import '../../services/routes/route_models.dart'; import '../../services/routes/route_providers.dart'; import '../../services/routes/route_service.dart'; import 'models/route_curvature_analysis.dart'; import 'widgets/address_autocomplete_field.dart'; import 'widgets/curviness_priority_selector.dart'; class RoutePlannerScreen extends ConsumerStatefulWidget { final String? initialRouteId; final String? attachToGroupRideId; const RoutePlannerScreen({ super.key, this.initialRouteId, this.attachToGroupRideId, }); @override ConsumerState createState() => _RoutePlannerScreenState(); } class _RoutePlannerScreenState extends ConsumerState { final _titleController = TextEditingController(); final _descController = TextEditingController(); RouteVisibility _visibility = RouteVisibility.private; CurvinessPriority _curvinessPriority = CurvinessPriority.standard; bool _initialized = false; bool _isSaving = false; String? _errorMessage; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; ref.read(mapFollowStateProvider.notifier).recenter(); _loadInitialData(); }); } @override void dispose() { _titleController.dispose(); _descController.dispose(); super.dispose(); } Future _loadInitialData() async { if (widget.initialRouteId != null) { final service = ref.read(routeServiceProvider); final route = await service.getRoute(widget.initialRouteId!); if (route != null && mounted) { ref.read(routePlannerNotifierProvider.notifier).loadRoute(route); _titleController.text = route.title; _descController.text = route.description; setState(() { _visibility = route.visibility; _initialized = true; }); return; } } else { ref.read(routePlannerNotifierProvider.notifier).reset(); if (widget.attachToGroupRideId != null) { setState(() { _visibility = RouteVisibility.groupOnly; }); } } setState(() { _initialized = true; }); } Future _addCurrentLocationStop() async { try { final position = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.high, timeLimit: Duration(seconds: 10), ), ); if (!mounted) return; _showAddOrEditStopDialog( initialLat: position.latitude, initialLng: position.longitude, defaultName: 'My Current Location', ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Could not get GPS location: $e'), backgroundColor: context.motorColors.raceRed, ), ); } } void _showAddOrEditStopDialog({ RouteStop? existingStop, double? initialLat, double? initialLng, String? defaultName, }) { final colors = context.motorColors; final state = ref.read(routePlannerNotifierProvider); const maxAllowed = RouteService.maxTechnicalStops; if (existingStop == null && state.stops.length >= maxAllowed) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text('Maximum of 25 stops reached.'), backgroundColor: colors.surfaceContainerHighest, ), ); return; } final nameCtrl = TextEditingController( text: existingStop?.displayName ?? defaultName ?? '', ); final latCtrl = TextEditingController( text: existingStop != null ? existingStop.latitude.toStringAsFixed(6) : (initialLat?.toStringAsFixed(6) ?? ''), ); final lngCtrl = TextEditingController( text: existingStop != null ? existingStop.longitude.toStringAsFixed(6) : (initialLng?.toStringAsFixed(6) ?? ''), ); final notesCtrl = TextEditingController(text: existingStop?.notes ?? ''); RouteStopType selectedType = existingStop?.stopType ?? (state.stops.isEmpty ? RouteStopType.origin : RouteStopType.waypoint); showDialog( context: context, builder: (dialogCtx) => StatefulBuilder( builder: (context, setDialogState) { final screenSize = MediaQuery.sizeOf(context); final isCompactDialog = screenSize.height < 480; return AlertDialog( scrollable: true, backgroundColor: colors.surfaceContainer, insetPadding: EdgeInsets.symmetric( horizontal: 16, vertical: isCompactDialog ? 8 : 24, ), titlePadding: EdgeInsets.fromLTRB( 20, isCompactDialog ? 12 : 20, 20, 0, ), contentPadding: EdgeInsets.fromLTRB( 20, isCompactDialog ? 10 : 16, 20, isCompactDialog ? 8 : 16, ), actionsPadding: EdgeInsets.fromLTRB( 16, 0, 16, isCompactDialog ? 8 : 16, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), side: BorderSide(color: colors.outlineVariant), ), title: Text( existingStop != null ? 'Edit Stop' : 'Add Route Stop', style: TextStyle( color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold, ), ), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 440), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ OutlinedButton.icon( style: OutlinedButton.styleFrom( side: BorderSide(color: colors.cyberCyan), padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8), minimumSize: const Size(double.infinity, 36), ), icon: Icon(Icons.search, size: 16, color: colors.cyberCyan), label: Text( 'SEARCH ADDRESS / POI', style: TextStyle( color: colors.cyberCyan, fontSize: 12, fontWeight: FontWeight.bold, ), ), onPressed: () => _openAddressSearchForStop( context, colors, setDialogState, nameCtrl, latCtrl, lngCtrl, notesCtrl, ), ), const SizedBox(height: 12), TextField( controller: nameCtrl, scrollPadding: const EdgeInsets.only(bottom: 120, top: 24), style: TextStyle(color: Theme.of(context).colorScheme.onSurface), decoration: InputDecoration( labelText: 'Stop Name *', labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), hintText: 'e.g. Skyline Summit, Fuel Stop', hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)), suffixIcon: IconButton( icon: Icon(Icons.search, color: colors.cyberCyan, size: 20), tooltip: 'Search address', onPressed: () => _openAddressSearchForStop( context, colors, setDialogState, nameCtrl, latCtrl, lngCtrl, notesCtrl, ), ), enabledBorder: UnderlineInputBorder( borderSide: BorderSide(color: colors.outlineVariant), ), focusedBorder: UnderlineInputBorder( borderSide: BorderSide(color: colors.cyberCyan), ), ), ), const SizedBox(height: 12), DropdownButtonFormField( initialValue: selectedType, isExpanded: true, dropdownColor: colors.surfaceContainerHigh, style: TextStyle(color: Theme.of(context).colorScheme.onSurface), decoration: InputDecoration( labelText: 'Stop Type', labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), enabledBorder: UnderlineInputBorder( borderSide: BorderSide(color: colors.outlineVariant), ), ), items: RouteStopType.values.map((type) { return DropdownMenuItem( value: type, child: Row( children: [ Icon( _getIconForStopType(type), color: colors.cyberCyan, size: 18, ), const SizedBox(width: 8), Expanded( child: Text( type.label, style: TextStyle(color: Theme.of(context).colorScheme.onSurface), overflow: TextOverflow.ellipsis, ), ), ], ), ); }).toList(), onChanged: (val) { if (val != null) { setDialogState(() => selectedType = val); } }, ), const SizedBox(height: 12), Row( children: [ Expanded( child: TextField( controller: latCtrl, keyboardType: const TextInputType.numberWithOptions( decimal: true, signed: true, ), style: TextStyle( color: Theme.of(context).colorScheme.onSurface, fontSize: 13, ), decoration: InputDecoration( labelText: 'Latitude *', labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), hintText: '37.421998', hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)), ), ), ), const SizedBox(width: 12), Expanded( child: TextField( controller: lngCtrl, keyboardType: const TextInputType.numberWithOptions( decimal: true, signed: true, ), style: TextStyle( color: Theme.of(context).colorScheme.onSurface, fontSize: 13, ), decoration: InputDecoration( labelText: 'Longitude *', labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), hintText: '-122.084000', hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)), ), ), ), ], ), const SizedBox(height: 12), TextField( controller: notesCtrl, style: TextStyle(color: Theme.of(context).colorScheme.onSurface), maxLines: 2, decoration: InputDecoration( labelText: 'Notes (Optional)', labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), hintText: 'Regroup point, scenic view, cautions...', hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)), ), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(dialogCtx), child: Text( 'CANCEL', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), ), ), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: colors.cyberCyan, foregroundColor: Colors.black, ), onPressed: () { final name = nameCtrl.text.trim(); final lat = double.tryParse(latCtrl.text.trim()); final lng = double.tryParse(lngCtrl.text.trim()); if (name.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Please provide a stop name.'), ), ); return; } if (!RoutePlannerNotifier.isValidCoordinate(lat, lng)) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Invalid coordinates: latitude [-90, 90], longitude [-180, 180], non-zero.'), ), ); return; } final notifier = ref.read( routePlannerNotifierProvider.notifier, ); if (existingStop != null) { notifier.updateStop( existingStop.copyWith( displayName: name, latitude: lat!, longitude: lng!, stopType: selectedType, notes: notesCtrl.text.trim(), ), ); } else { notifier.addStop( displayName: name, latitude: lat!, longitude: lng!, stopType: selectedType, notes: notesCtrl.text.trim(), ); } Navigator.pop(dialogCtx); }, child: Text(existingStop != null ? 'SAVE' : 'ADD'), ), ], ); }, ), ); } void _openAddressSearchForStop( BuildContext context, MotorSignalThemeColors colors, void Function(void Function()) setDialogState, TextEditingController nameCtrl, TextEditingController latCtrl, TextEditingController lngCtrl, TextEditingController notesCtrl, ) { showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: colors.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (sheetCtx) => Padding( padding: EdgeInsets.only( left: 16, right: 16, top: 16, bottom: MediaQuery.of(sheetCtx).viewInsets.bottom + 16, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( 'SEARCH ADDRESS OR PLACE', style: TextStyle( color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold, fontSize: 14, ), ), const SizedBox(height: 12), AddressAutocompleteField( labelText: 'Search location', autofocus: true, onPlaceResolved: (place) { if (place != null) { setDialogState(() { nameCtrl.text = place.displayName; latCtrl.text = place.latitude.toStringAsFixed(6); lngCtrl.text = place.longitude.toStringAsFixed(6); if (notesCtrl.text.isEmpty && place.formattedAddress != null) { notesCtrl.text = place.formattedAddress!; } }); Navigator.pop(sheetCtx); } }, ), ], ), ), ); } Future _saveRoute() async { final title = _titleController.text.trim(); if (title.length < 3 || title.length > 80) { setState(() { _errorMessage = 'Route title must be between 3 and 80 characters.'; }); return; } final user = ref.read(currentUserProvider); if (user == null) { setState(() { _errorMessage = 'You must be signed in to save a route.'; }); return; } final state = ref.read(routePlannerNotifierProvider); const maxAllowed = RouteService.maxTechnicalStops; if (state.stops.length > maxAllowed) { setState(() { _errorMessage = 'Cannot exceed $maxAllowed stops.'; }); return; } setState(() { _isSaving = true; _errorMessage = null; }); try { final notifier = ref.read(routePlannerNotifierProvider.notifier); final savedRoute = await notifier.saveRoute( title: title, description: _descController.text.trim(), ownerUid: user.uid, visibility: _visibility, attachedGroupRideId: widget.attachToGroupRideId, ); // If attached to a group ride, update the group ride with route reference if (widget.attachToGroupRideId != null) { final service = ref.read(routeServiceProvider); await service.attachRouteToGroupRide( groupRideId: widget.attachToGroupRideId!, route: savedRoute, callerUid: user.uid, ); } if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Route "${savedRoute.title}" saved successfully!'), backgroundColor: context.motorColors.launchGreen, ), ); context.pop(savedRoute); } } catch (e) { if (mounted) { setState(() { _errorMessage = 'Failed to save route: $e'; _isSaving = false; }); } } } IconData _getIconForStopType(RouteStopType type) { switch (type) { case RouteStopType.start: case RouteStopType.origin: return Icons.play_circle_fill; case RouteStopType.meetup: return Icons.people; case RouteStopType.waypoint: return Icons.place; case RouteStopType.fuel: return Icons.local_gas_station; case RouteStopType.rest: return Icons.hotel; case RouteStopType.food: return Icons.restaurant; case RouteStopType.scenic: return Icons.photo_camera; case RouteStopType.regroup: return Icons.group; case RouteStopType.destination: return Icons.flag; case RouteStopType.other: return Icons.location_on; } } @override Widget build(BuildContext context) { final colors = context.motorColors; if (!_initialized) { return Scaffold( backgroundColor: colors.surfaceContainer, body: Center( child: CircularProgressIndicator(color: colors.cyberCyan), ), ); } final state = ref.watch(routePlannerNotifierProvider); const maxAllowed = RouteService.maxTechnicalStops; final isAtLimit = state.stops.length >= maxAllowed; final lastKnownLocation = ref.watch(routePlannerLocationProvider).valueOrNull; // Route context takes priority over cached rider location and the US overview. final mapCenter = state.geometry.isNotEmpty ? state.geometry.first : (state.stops.isNotEmpty ? AppLatLng(state.stops.first.latitude, state.stops.first.longitude) : lastKnownLocation ?? const AppLatLng(39.5, -98.35)); final mapZoom = state.geometry.isNotEmpty || state.stops.isNotEmpty || lastKnownLocation != null ? 12.0 : 3.0; // Build markers final markers = state.stops.map((stop) { final isFirst = stop.orderIndex == 0; final isLast = stop.orderIndex == state.stops.length - 1 && state.stops.length > 1; return AppMapMarker( id: stop.id, position: AppLatLng(stop.latitude, stop.longitude), label: stop.displayName, iconWidget: Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: isFirst ? colors.launchGreen : isLast ? colors.raceRed : colors.cyberCyan, shape: BoxShape.circle, boxShadow: const [ BoxShadow(color: Colors.black54, blurRadius: 4, spreadRadius: 1), ], ), child: Text( '${stop.orderIndex + 1}', style: const TextStyle( color: Colors.black, fontWeight: FontWeight.bold, fontSize: 12, ), ), ), onTap: () { NavigationHandoffService.showHandoffSheet( context: context, latitude: stop.latitude, longitude: stop.longitude, displayName: stop.displayName, ); }, ); }).toList(); // Build polylines from authoritative calculated road geometry final polylines = []; if (state.geometry.isNotEmpty) { polylines.add( AppPolyline( id: 'route_planner_road_geometry', points: state.geometry, color: colors.cyberCyan, width: 3.5, ), ); } final isCompactScreen = MediaQuery.sizeOf(context).height < 500; return Scaffold( backgroundColor: colors.surfaceContainer, appBar: AppBar( backgroundColor: colors.surfaceContainerHigh, elevation: 0, toolbarHeight: isCompactScreen ? 46 : kToolbarHeight, leading: IconButton( icon: Icon(Icons.arrow_back, color: Theme.of(context).colorScheme.onSurface), onPressed: () => context.pop(), ), title: Text( widget.initialRouteId != null ? 'EDIT ROUTE' : 'ROUTE PLANNER', style: TextStyle( color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.w900, letterSpacing: 1.2, fontSize: 16, ), ), actions: [ if (_isSaving) Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: colors.cyberCyan, ), ), ), ) else TextButton.icon( key: const ValueKey('route_planner_save_button'), onPressed: _saveRoute, icon: Icon(Icons.save, color: colors.cyberCyan, size: 18), label: Text( 'SAVE', style: TextStyle( color: colors.cyberCyan, fontWeight: FontWeight.bold, ), ), ), ], ), body: ResponsiveLayout( builder: (context, snapshot) { final isNarrow = snapshot.width < 360; final isExpandedLayout = snapshot.sizeClass == WindowSizeClass.expanded || (snapshot.isLandscape && snapshot.width >= 600); if (isExpandedLayout) { return _buildExpandedLayout( context, colors, snapshot, state, maxAllowed, isAtLimit, mapCenter, mapZoom, markers, polylines, isNarrow, ); } return _buildCompactLayout( context, colors, snapshot, state, maxAllowed, isAtLimit, mapCenter, mapZoom, markers, polylines, isNarrow, ); }, ), ); } Widget _buildExpandedLayout( BuildContext context, MotorSignalThemeColors colors, ResponsiveSnapshot snapshot, RoutePlannerState state, int maxAllowed, bool isAtLimit, AppLatLng mapCenter, double mapZoom, List markers, List polylines, bool isNarrow, ) { final isCompactHeight = snapshot.height < 500; final isVeryCompactHeight = snapshot.height < 360; final showDisclaimer = snapshot.height >= 260; return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Left Column: Map preview + HUD Expanded( flex: 5, child: Container( decoration: BoxDecoration( border: Border( right: BorderSide(color: colors.outlineVariant), ), ), child: Stack( children: [ UnifiedMapView( key: const ValueKey('route_planner_map_expanded'), center: mapCenter, zoom: mapZoom, markers: markers, polylines: polylines, ), Positioned( top: isCompactHeight ? 6 : 12, left: isCompactHeight ? 8 : 12, right: isCompactHeight ? 8 : 12, child: _buildHudOverlay( colors, state, maxAllowed, isAtLimit, isCompactHeight, ), ), if (showDisclaimer) Positioned( bottom: 6, left: 8, right: 8, child: _buildDisclaimerText(), ), ], ), ), ), // Right Column: Form inputs + Stop list Expanded( flex: 6, child: isCompactHeight ? _buildStopsList( colors, state, true, isVeryCompactHeight, header: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (_errorMessage != null) _buildErrorBanner(colors), Padding( padding: const EdgeInsets.fromLTRB(12, 6, 12, 4), child: _buildRouteInfoInputs(colors, true), ), Divider(color: colors.outlineVariant, height: 1), Padding( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 6, ), child: _buildStopControls(colors, state, isNarrow, true), ), if (state.stops.isEmpty) _buildEmptyStopsContent( colors, true, isVeryCompactHeight, ), ], ), ) : Column( children: [ if (_errorMessage != null) _buildErrorBanner(colors), Padding( padding: const EdgeInsets.all(16), child: _buildRouteInfoInputs(colors, false), ), Divider(color: colors.outlineVariant, height: 1), Padding( padding: const EdgeInsets.all(12), child: _buildStopControls(colors, state, isNarrow, false), ), Expanded( child: _buildStopsList(colors, state, false, false), ), ], ), ), ], ); } Widget _buildCompactLayout( BuildContext context, MotorSignalThemeColors colors, ResponsiveSnapshot snapshot, RoutePlannerState state, int maxAllowed, bool isAtLimit, AppLatLng mapCenter, double mapZoom, List markers, List polylines, bool isNarrow, ) { final isCompactHeight = snapshot.height < 500; final isVeryCompactHeight = snapshot.height < 360; // Dynamically compute map preview height based on available vertical space: // Generous space (>= 620dp): 220dp // Moderate space (500–619dp): 160dp // Constrained space (380–499dp, e.g. IME keyboard open): 110dp // Severely constrained space (340–379dp): 60dp // Extreme constraint (< 340dp, e.g. 640x320 landscape): 0dp (collapses so inputs stay visible and editable) final double mapHeight; final bool showDisclaimer; if (snapshot.height >= 620) { mapHeight = 220.0; showDisclaimer = true; } else if (snapshot.height >= 500) { mapHeight = 160.0; showDisclaimer = true; } else if (snapshot.height >= 380) { mapHeight = 110.0; showDisclaimer = false; } else if (snapshot.height >= 340) { mapHeight = 60.0; showDisclaimer = false; } else { mapHeight = 0.0; showDisclaimer = false; } if (isCompactHeight) { return _buildStopsList( colors, state, true, isVeryCompactHeight, header: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (_errorMessage != null) _buildErrorBanner(colors), if (mapHeight > 0) SizedBox( height: mapHeight, width: double.infinity, child: Stack( children: [ UnifiedMapView( key: const ValueKey('route_planner_map_compact'), center: mapCenter, zoom: 12.0, markers: markers, polylines: polylines, ), Positioned( top: 4, left: 8, right: 8, child: _buildHudOverlay( colors, state, maxAllowed, isAtLimit, true, ), ), ], ), ), Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: _buildRouteInfoInputs(colors, true), ), Divider(color: colors.outlineVariant, height: 1), Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: _buildStopControls(colors, state, isNarrow, true), ), if (state.stops.isEmpty) _buildEmptyStopsContent( colors, true, isVeryCompactHeight, ), ], ), ); } return Column( children: [ if (_errorMessage != null) _buildErrorBanner(colors), // Map Preview Section if (mapHeight > 0) SizedBox( height: mapHeight, width: double.infinity, child: Stack( children: [ UnifiedMapView( key: const ValueKey('route_planner_map_compact'), center: mapCenter, zoom: mapZoom, markers: markers, polylines: polylines, ), Positioned( top: 8, left: 8, right: 8, child: _buildHudOverlay( colors, state, maxAllowed, isAtLimit, false, ), ), if (showDisclaimer) Positioned( bottom: 4, left: 8, right: 8, child: _buildDisclaimerText(), ), ], ), ), // Route Info Inputs Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: _buildRouteInfoInputs(colors, false), ), Divider(color: colors.outlineVariant, height: 1), // Stop List Controls Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: _buildStopControls(colors, state, isNarrow, false), ), // Reorderable Stop List Expanded( child: _buildStopsList( colors, state, false, isVeryCompactHeight, ), ), ], ); } Widget _buildErrorBanner(MotorSignalThemeColors colors, [String? message]) { final effectiveError = message ?? _errorMessage; if (effectiveError == null) return const SizedBox.shrink(); return Container( width: double.infinity, color: colors.raceRed.withValues(alpha: 0.15), padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), child: Row( children: [ Icon( Icons.error_outline, color: colors.raceRed, size: 18, ), const SizedBox(width: 8), Expanded( child: Text( effectiveError, style: TextStyle( color: colors.raceRed, fontSize: 12, ), ), ), IconButton( icon: Icon( Icons.close, color: Theme.of(context).colorScheme.onSurfaceVariant, size: 16, ), onPressed: () => setState(() => _errorMessage = null), padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), ], ), ); } Widget _buildHudOverlay( MotorSignalThemeColors colors, RoutePlannerState state, int maxAllowed, bool isAtLimit, bool isCompactHeight, ) { String distanceText; if (state.stops.length < 2) { distanceText = 'Add 2+ stops for route'; } else if (state.isCalculatingRoute) { distanceText = 'Calculating road route...'; } else if (state.routeCalculationError != null) { distanceText = 'Route calculation failed'; } else if (state.geometry.isNotEmpty && state.estimatedDistanceMeters != null) { distanceText = 'Road: ${state.formattedRoadDistanceMiles} (${state.formattedRoadDistanceKm})${state.formattedRoadDuration != null ? ' • ${state.formattedRoadDuration}' : ''}'; } else { distanceText = 'Straight-line: ${state.formattedDistanceMiles} (${state.formattedDistanceKm})'; } return Container( padding: EdgeInsets.symmetric( horizontal: 8, vertical: isCompactHeight ? 4 : 6, ), decoration: BoxDecoration( color: colors.surfaceContainer.withValues(alpha: 0.9), borderRadius: BorderRadius.circular(8), border: Border.all(color: colors.outlineVariant), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Text( distanceText, style: TextStyle( color: colors.cyberCyan, fontSize: isCompactHeight ? 11 : 12, fontWeight: FontWeight.bold, ), overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 8), if (state.stops.length >= 2) ...[ Builder(builder: (context) { final report = RouteCurvatureReport.analyze( geometry: state.geometry, stops: state.stops, priority: _curvinessPriority, ); return Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2, ), decoration: BoxDecoration( color: colors.launchGreen.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(4), border: Border.all(color: colors.launchGreen.withValues(alpha: 0.35)), ), child: Text( '${report.totalCorners} CORNERS • ${report.formattedElevationGain}', style: TextStyle( color: colors.launchGreen, fontSize: 10, fontWeight: FontWeight.w800, ), ), ); }), const SizedBox(width: 8), ], Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2, ), decoration: BoxDecoration( color: isAtLimit ? colors.warningAmber.withValues(alpha: 0.2) : colors.cyberCyan.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(4), ), child: Text( '${state.stops.length}/$maxAllowed STOPS', style: TextStyle( color: isAtLimit ? colors.warningAmber : colors.cyberCyan, fontSize: 10, fontWeight: FontWeight.w900, ), ), ), ], ), ); } Widget _buildDisclaimerText() { final theme = Theme.of(context); return Text( 'Calculated road route. Navigation handoff launches in Google Maps / Waze.', textAlign: TextAlign.center, style: TextStyle( color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.75), fontSize: 9, shadows: const [ Shadow(color: Colors.black26, blurRadius: 4), ], ), ); } Widget _buildRouteInfoInputs(MotorSignalThemeColors colors, bool isCompactHeight) { final theme = Theme.of(context); final onSurface = theme.colorScheme.onSurface; final onSurfaceVariant = theme.colorScheme.onSurfaceVariant; return Column( children: [ TextField( controller: _titleController, scrollPadding: const EdgeInsets.only(bottom: 120, top: 24), style: TextStyle( color: onSurface, fontWeight: FontWeight.bold, fontSize: 15, ), decoration: InputDecoration( hintText: 'Route Title (e.g. Skyline Ridge Loop) *', hintStyle: TextStyle( color: onSurfaceVariant.withValues(alpha: 0.5), fontSize: 14, ), isDense: true, contentPadding: EdgeInsets.symmetric( vertical: isCompactHeight ? 4 : 8, ), border: InputBorder.none, ), ), Wrap( spacing: 8, runSpacing: 4, alignment: WrapAlignment.spaceBetween, crossAxisAlignment: WrapCrossAlignment.center, children: [ PopupMenuButton( initialValue: _visibility, onSelected: (v) => setState(() => _visibility = v), color: colors.surfaceContainerHigh, child: Padding( padding: EdgeInsets.symmetric( vertical: isCompactHeight ? 2 : 4, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.visibility_outlined, size: 14, color: onSurfaceVariant, ), const SizedBox(width: 4), Text( _visibility.label, style: TextStyle( color: colors.cyberCyan, fontSize: 12, fontWeight: FontWeight.bold, ), ), Icon( Icons.arrow_drop_down, size: 16, color: colors.cyberCyan, ), ], ), ), itemBuilder: (context) => RouteVisibility.values.map((v) { return PopupMenuItem( value: v, child: Text( v.label, style: TextStyle( color: onSurface, fontSize: 13, ), ), ); }).toList(), ), ], ), ], ); } Widget _buildStopControls( MotorSignalThemeColors colors, RoutePlannerState state, bool isNarrow, bool isCompactHeight, ) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ CurvinessPrioritySelector( selected: _curvinessPriority, onChanged: (p) => setState(() => _curvinessPriority = p), colors: colors, isCompact: isCompactHeight, ), SizedBox(height: isCompactHeight ? 6 : 8), Wrap( spacing: 8, runSpacing: 6, alignment: WrapAlignment.spaceBetween, crossAxisAlignment: WrapCrossAlignment.center, children: [ Wrap( spacing: 8, runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: [ ElevatedButton.icon( key: const ValueKey('route_planner_add_stop_button'), onPressed: () => _showAddOrEditStopDialog(), icon: const Icon(Icons.add_location_alt, size: 16), label: Text(isNarrow ? 'ADD' : 'ADD STOP'), style: ElevatedButton.styleFrom( backgroundColor: colors.cyberCyan, foregroundColor: Colors.black, padding: EdgeInsets.symmetric( horizontal: 10, vertical: isCompactHeight ? 6 : 8, ), minimumSize: isCompactHeight ? const Size(0, 34) : null, tapTargetSize: isCompactHeight ? MaterialTapTargetSize.shrinkWrap : null, textStyle: const TextStyle( fontSize: 12, fontWeight: FontWeight.bold, ), ), ), OutlinedButton.icon( key: const ValueKey('route_planner_current_gps_button'), onPressed: _addCurrentLocationStop, icon: Icon( Icons.my_location, size: 16, color: colors.cyberCyan, ), label: Text( isNarrow ? 'GPS' : 'CURRENT GPS', style: TextStyle(color: colors.cyberCyan), ), style: OutlinedButton.styleFrom( side: BorderSide(color: colors.outlineVariant), padding: EdgeInsets.symmetric( horizontal: 10, vertical: isCompactHeight ? 6 : 8, ), minimumSize: isCompactHeight ? const Size(0, 34) : null, tapTargetSize: isCompactHeight ? MaterialTapTargetSize.shrinkWrap : null, textStyle: const TextStyle(fontSize: 12), ), ), ], ), if (state.stops.length >= 2) TextButton.icon( key: const ValueKey('route_planner_reverse_button'), onPressed: () => ref .read(routePlannerNotifierProvider.notifier) .reverseStops(), icon: Icon( Icons.swap_vert, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant, ), label: Text( 'REVERSE', style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 11, ), ), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 4, ), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ), ], ), ], ); } Widget _buildEmptyStopsContent( MotorSignalThemeColors colors, bool isCompactHeight, bool isVeryCompactHeight, ) { final theme = Theme.of(context); final onSurfaceVariant = theme.colorScheme.onSurfaceVariant; return Padding( padding: EdgeInsets.symmetric(vertical: isCompactHeight ? 6 : 12), child: Column( mainAxisSize: MainAxisSize.min, children: [ if (!isVeryCompactHeight) ...[ Icon( Icons.alt_route, size: isCompactHeight ? 28 : 48, color: onSurfaceVariant.withValues(alpha: 0.2), ), SizedBox(height: isCompactHeight ? 4 : 12), ], Text( 'No stops added yet.', style: TextStyle( color: onSurfaceVariant, fontSize: isCompactHeight ? 13 : 14, ), ), const SizedBox(height: 4), Text( 'Tap "Add Stop" or "Current GPS" to begin planning.', style: TextStyle( color: onSurfaceVariant.withValues(alpha: 0.6), fontSize: isCompactHeight ? 11 : 12, ), ), ], ), ); } Widget _buildStopsList( MotorSignalThemeColors colors, RoutePlannerState state, bool isCompactHeight, bool isVeryCompactHeight, { Widget? header, }) { final theme = Theme.of(context); final onSurface = theme.colorScheme.onSurface; final onSurfaceVariant = theme.colorScheme.onSurfaceVariant; if (header == null && state.stops.isEmpty) { return Center( child: SingleChildScrollView( physics: const ClampingScrollPhysics(), child: _buildEmptyStopsContent( colors, isCompactHeight, isVeryCompactHeight, ), ), ); } return ReorderableListView.builder( header: header, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 4, ), itemCount: state.stops.length, // ignore: deprecated_member_use onReorder: (oldIdx, newIdx) { ref .read(routePlannerNotifierProvider.notifier) .reorderStops(oldIdx, newIdx); }, itemBuilder: (context, index) { final stop = state.stops[index]; final isFirst = index == 0; final isLast = index == state.stops.length - 1 && state.stops.length > 1; return Container( key: ValueKey(stop.id), margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(10), border: Border.all(color: colors.outlineVariant), ), child: Row( children: [ // Order badge Container( width: 26, height: 26, decoration: BoxDecoration( color: isFirst ? colors.launchGreen : isLast ? colors.raceRed : colors.cyberCyan, shape: BoxShape.circle, ), alignment: Alignment.center, child: Text( '${index + 1}', style: const TextStyle( color: Colors.black, fontWeight: FontWeight.bold, fontSize: 12, ), ), ), const SizedBox(width: 10), // Stop Details Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( _getIconForStopType(stop.stopType), size: 14, color: colors.cyberCyan, ), const SizedBox(width: 4), Flexible( child: Text( stop.displayName, style: TextStyle( color: onSurface, fontWeight: FontWeight.bold, fontSize: 14, ), overflow: TextOverflow.ellipsis, ), ), ], ), const SizedBox(height: 2), Text( '${stop.latitude.toStringAsFixed(4)}, ${stop.longitude.toStringAsFixed(4)}', style: TextStyle( color: onSurfaceVariant, fontSize: 11, ), ), if (stop.notes.isNotEmpty) ...[ const SizedBox(height: 2), Text( stop.notes, style: TextStyle( color: onSurfaceVariant, fontSize: 11, fontStyle: FontStyle.italic, ), overflow: TextOverflow.ellipsis, ), ], ], ), ), // Actions: Handoff navigation, Edit, Delete IconButton( icon: Icon( Icons.navigation, color: colors.cyberCyan, size: 18, ), tooltip: 'Open in Maps', onPressed: () { NavigationHandoffService.showHandoffSheet( context: context, latitude: stop.latitude, longitude: stop.longitude, displayName: stop.displayName, ); }, padding: EdgeInsets.zero, constraints: const BoxConstraints( minWidth: 32, ), ), IconButton( icon: Icon( Icons.edit, color: onSurfaceVariant, size: 18, ), tooltip: 'Edit stop', onPressed: () => _showAddOrEditStopDialog( existingStop: stop, ), padding: EdgeInsets.zero, constraints: const BoxConstraints( minWidth: 32, ), ), IconButton( icon: Icon( Icons.delete_outline, color: colors.raceRed, size: 18, ), tooltip: 'Remove stop', onPressed: () { ref .read( routePlannerNotifierProvider.notifier, ) .removeStop(stop.id); }, padding: EdgeInsets.zero, constraints: const BoxConstraints( minWidth: 32, ), ), const SizedBox(width: 4), Icon( Icons.drag_handle, color: onSurfaceVariant.withValues(alpha: 0.4), size: 20, ), ], ), ); }, ); } }