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 '../../services/auth/auth_providers.dart'; import '../../services/map/map_provider_interface.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'; 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; bool _initialized = false; bool _isSaving = false; String? _errorMessage; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { _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: Colors.redAccent, ), ); } } void _showAddOrEditStopDialog({ RouteStop? existingStop, double? initialLat, double? initialLng, String? defaultName, }) { final state = ref.read(routePlannerNotifierProvider); const maxAllowed = RouteService.maxTechnicalStops; if (existingStop == null && state.stops.length >= maxAllowed) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Maximum of $maxAllowed stops reached.'), backgroundColor: Color(0xFF1B1E26), ), ); 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) { return AlertDialog( backgroundColor: const Color(0xFF13151A), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), side: const BorderSide(color: Color(0xFF2A2E3D)), ), title: Text( existingStop != null ? 'Edit Stop' : 'Add Route Stop', style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, ), ), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: nameCtrl, style: const TextStyle(color: Colors.white), decoration: const InputDecoration( labelText: 'Stop Name *', labelStyle: TextStyle(color: Colors.white60), hintText: 'e.g. Skyline Summit, Fuel Stop', hintStyle: TextStyle(color: Colors.white24), enabledBorder: UnderlineInputBorder( borderSide: BorderSide(color: Color(0xFF2A2E3D)), ), focusedBorder: UnderlineInputBorder( borderSide: BorderSide(color: Color(0xFF00E5FF)), ), ), ), const SizedBox(height: 12), DropdownButtonFormField( initialValue: selectedType, isExpanded: true, dropdownColor: const Color(0xFF1B1E26), style: const TextStyle(color: Colors.white), decoration: const InputDecoration( labelText: 'Stop Type', labelStyle: TextStyle(color: Colors.white60), enabledBorder: UnderlineInputBorder( borderSide: BorderSide(color: Color(0xFF2A2E3D)), ), ), items: RouteStopType.values.map((type) { return DropdownMenuItem( value: type, child: Row( children: [ Icon( _getIconForStopType(type), color: const Color(0xFF00E5FF), size: 18, ), const SizedBox(width: 8), Expanded( child: Text( type.label, 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: const TextStyle( color: Colors.white, fontSize: 13, ), decoration: const InputDecoration( labelText: 'Latitude *', labelStyle: TextStyle(color: Colors.white60), hintText: '37.421998', hintStyle: TextStyle(color: Colors.white24), ), ), ), const SizedBox(width: 12), Expanded( child: TextField( controller: lngCtrl, keyboardType: const TextInputType.numberWithOptions( decimal: true, signed: true, ), style: const TextStyle( color: Colors.white, fontSize: 13, ), decoration: const InputDecoration( labelText: 'Longitude *', labelStyle: TextStyle(color: Colors.white60), hintText: '-122.084000', hintStyle: TextStyle(color: Colors.white24), ), ), ), ], ), const SizedBox(height: 12), TextField( controller: notesCtrl, style: const TextStyle(color: Colors.white), maxLines: 2, decoration: const InputDecoration( labelText: 'Notes (Optional)', labelStyle: TextStyle(color: Colors.white60), hintText: 'Regroup point, scenic view, cautions...', hintStyle: TextStyle(color: Colors.white24), ), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(dialogCtx), child: const Text( 'CANCEL', style: TextStyle(color: Colors.white54), ), ), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF00E5FF), 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 (lat == null || lat < -90.0 || lat > 90.0) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Invalid latitude (-90 to 90).'), ), ); return; } if (lng == null || lng < -180.0 || lng > 180.0) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Invalid longitude (-180 to 180).'), ), ); 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'), ), ], ); }, ), ); } 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: const Color(0xFF00E676), ), ); 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) { if (!_initialized) { return const Scaffold( backgroundColor: Color(0xFF0D0E11), body: Center( child: CircularProgressIndicator(color: Color(0xFF00E5FF)), ), ); } final state = ref.watch(routePlannerNotifierProvider); const maxAllowed = RouteService.maxTechnicalStops; final isAtLimit = state.stops.length >= maxAllowed; // Center map on first stop or default final mapCenter = state.stops.isNotEmpty ? AppLatLng(state.stops.first.latitude, state.stops.first.longitude) : const AppLatLng(37.7749, -122.4194); // 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 ? const Color(0xFF00E676) : isLast ? const Color(0xFFFF5252) : const Color(0xFF00E5FF), 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 final polylines = state.stops.length >= 2 ? [ AppPolyline( id: 'straight_line_preview', points: state.previewPoints .map((p) => AppLatLng(p.latitude, p.longitude)) .toList(), color: const Color(0xFF00E5FF), width: 3.5, ), ] : []; return Scaffold( backgroundColor: const Color(0xFF0D0E11), appBar: AppBar( backgroundColor: const Color(0xFF13151A), elevation: 0, leading: IconButton( icon: const Icon(Icons.arrow_back, color: Colors.white), onPressed: () => context.pop(), ), title: Text( widget.initialRouteId != null ? 'EDIT ROUTE' : 'ROUTE PLANNER', style: const TextStyle( fontWeight: FontWeight.w900, letterSpacing: 1.2, fontSize: 16, ), ), actions: [ if (_isSaving) const Center( child: Padding( padding: EdgeInsets.symmetric(horizontal: 16.0), child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: Color(0xFF00E5FF), ), ), ), ) else TextButton.icon( key: const ValueKey('route_planner_save_button'), onPressed: _saveRoute, icon: const Icon(Icons.save, color: Color(0xFF00E5FF), size: 18), label: const Text( 'SAVE', style: TextStyle( color: Color(0xFF00E5FF), fontWeight: FontWeight.bold, ), ), ), ], ), body: LayoutBuilder( builder: (context, constraints) { final isNarrow = constraints.maxWidth < 360; return Column( children: [ // Error banner if any if (_errorMessage != null) Container( width: double.infinity, color: const Color(0xFFFF5252).withValues(alpha: 0.15), padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), child: Row( children: [ const Icon( Icons.error_outline, color: Color(0xFFFF5252), size: 18, ), const SizedBox(width: 8), Expanded( child: Text( _errorMessage!, style: const TextStyle( color: Color(0xFFFF5252), fontSize: 12, ), ), ), IconButton( icon: const Icon( Icons.close, color: Colors.white54, size: 16, ), onPressed: () => setState(() => _errorMessage = null), padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), ], ), ), // Map Preview Section (220dp high) SizedBox( height: 220, width: double.infinity, child: Stack( children: [ UnifiedMapView( center: mapCenter, zoom: 12.0, markers: markers, polylines: polylines, ), // Distance & Stops HUD overlay Positioned( top: 8, left: 8, right: 8, child: Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 6, ), decoration: BoxDecoration( color: const Color(0xFF13151A).withValues(alpha: 0.9), borderRadius: BorderRadius.circular(8), border: Border.all(color: const Color(0xFF2A2E3D)), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Text( state.stops.length >= 2 ? 'Straight-line: ${state.formattedDistanceMiles} (${state.formattedDistanceKm})' : 'Add 2+ stops for distance', style: const TextStyle( color: Color(0xFF00E5FF), fontSize: 12, fontWeight: FontWeight.bold, ), overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2, ), decoration: BoxDecoration( color: isAtLimit ? const Color( 0xFFFFB300, ).withValues(alpha: 0.2) : const Color( 0xFF00E5FF, ).withValues(alpha: 0.15), borderRadius: BorderRadius.circular(4), ), child: Text( '${state.stops.length}/$maxAllowed STOPS', style: TextStyle( color: isAtLimit ? const Color(0xFFFFB300) : const Color(0xFF00E5FF), fontSize: 10, fontWeight: FontWeight.w900, ), ), ), ], ), ), ), // Straight-line disclaimer note Positioned( bottom: 4, left: 8, right: 8, child: Text( 'Straight-line preview. Navigation handoff launches in Google Maps / Waze.', textAlign: TextAlign.center, style: TextStyle( color: Colors.white.withValues(alpha: 0.65), fontSize: 9, shadows: const [ Shadow(color: Colors.black, blurRadius: 4), ], ), ), ), ], ), ), // Route Info Inputs (collapsible / compact) Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Column( children: [ TextField( controller: _titleController, style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15, ), decoration: const InputDecoration( hintText: 'Route Title (e.g. Skyline Ridge Loop) *', hintStyle: TextStyle( color: Colors.white30, fontSize: 14, ), isDense: true, contentPadding: EdgeInsets.symmetric(vertical: 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: const Color(0xFF1B1E26), child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.visibility_outlined, size: 14, color: Colors.white54, ), const SizedBox(width: 4), Text( _visibility.label, style: const TextStyle( color: Color(0xFF00E5FF), fontSize: 12, fontWeight: FontWeight.bold, ), ), const Icon( Icons.arrow_drop_down, size: 16, color: Color(0xFF00E5FF), ), ], ), ), itemBuilder: (context) => RouteVisibility.values.map((v) { return PopupMenuItem( value: v, child: Text( v.label, style: const TextStyle( color: Colors.white, fontSize: 13, ), ), ); }).toList(), ), ], ), ], ), ), const Divider(color: Color(0xFF2A2E3D), height: 1), // Stop List Controls (Add Stop / Add GPS / Reverse) Padding( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), child: Wrap( spacing: 8, runSpacing: 8, alignment: WrapAlignment.spaceBetween, crossAxisAlignment: WrapCrossAlignment.center, children: [ Row( mainAxisSize: MainAxisSize.min, 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: const Color(0xFF00E5FF), foregroundColor: Colors.black, padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 8, ), textStyle: const TextStyle( fontSize: 12, fontWeight: FontWeight.bold, ), ), ), const SizedBox(width: 8), OutlinedButton.icon( key: const ValueKey( 'route_planner_current_gps_button', ), onPressed: _addCurrentLocationStop, icon: const Icon( Icons.my_location, size: 16, color: Color(0xFF00E5FF), ), label: Text( isNarrow ? 'GPS' : 'CURRENT GPS', style: const TextStyle(color: Color(0xFF00E5FF)), ), style: OutlinedButton.styleFrom( side: const BorderSide(color: Color(0xFF2A2E3D)), padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 8, ), 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: const Icon( Icons.swap_vert, size: 16, color: Colors.white70, ), label: const Text( 'REVERSE', style: TextStyle(color: Colors.white70, fontSize: 11), ), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 4, ), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ), ], ), ), // Reorderable Stop List Expanded( child: state.stops.isEmpty ? Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.alt_route, size: 48, color: Colors.white.withValues(alpha: 0.15), ), const SizedBox(height: 12), const Text( 'No stops added yet.', style: TextStyle( color: Colors.white54, fontSize: 14, ), ), const SizedBox(height: 6), const Text( 'Tap "Add Stop" or "Current GPS" to begin planning.', style: TextStyle( color: Colors.white30, fontSize: 12, ), ), ], ), ) : ReorderableListView.builder( 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: const Color(0xFF13151A), borderRadius: BorderRadius.circular(10), border: Border.all( color: const Color(0xFF2A2E3D), ), ), child: Row( children: [ // Order badge Container( width: 26, height: 26, decoration: BoxDecoration( color: isFirst ? const Color(0xFF00E676) : isLast ? const Color(0xFFFF5252) : const Color(0xFF00E5FF), 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: const Color(0xFF00E5FF), ), const SizedBox(width: 4), Flexible( child: Text( stop.displayName, style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14, ), overflow: TextOverflow.ellipsis, ), ), ], ), const SizedBox(height: 2), Text( '${stop.latitude.toStringAsFixed(4)}, ${stop.longitude.toStringAsFixed(4)}', style: const TextStyle( color: Colors.white38, fontSize: 11, ), ), if (stop.notes.isNotEmpty) ...[ const SizedBox(height: 2), Text( stop.notes, style: const TextStyle( color: Colors.white70, fontSize: 11, fontStyle: FontStyle.italic, ), overflow: TextOverflow.ellipsis, ), ], ], ), ), // Actions: Handoff navigation, Edit, Delete IconButton( icon: const Icon( Icons.navigation, color: Color(0xFF00E5FF), 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: const Icon( Icons.edit, color: Colors.white60, size: 18, ), tooltip: 'Edit stop', onPressed: () => _showAddOrEditStopDialog( existingStop: stop, ), padding: EdgeInsets.zero, constraints: const BoxConstraints( minWidth: 32, ), ), IconButton( icon: const Icon( Icons.delete_outline, color: Color(0xFFFF5252), 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), const Icon( Icons.drag_handle, color: Colors.white30, size: 20, ), ], ), ); }, ), ), ], ); }, ), ); } }