diff --git a/lib/features/group_rides/create_group_ride_screen.dart b/lib/features/group_rides/create_group_ride_screen.dart index eff2c6f..8d8e06a 100644 --- a/lib/features/group_rides/create_group_ride_screen.dart +++ b/lib/features/group_rides/create_group_ride_screen.dart @@ -100,10 +100,21 @@ class _CreateGroupRideScreenState extends ConsumerState { return; } + if (!RoutePlannerNotifier.isValidCoordinate(_meetupPlace!.latitude, _meetupPlace!.longitude) || + !RoutePlannerNotifier.isValidCoordinate(_destinationPlace!.latitude, _destinationPlace!.longitude)) { + setState(() { + _calculatedRouteResult = null; + _routeCalculationError = 'Invalid start or destination coordinates.'; + _isCalculatingRoute = false; + }); + return; + } + final seq = ++_calculationSequence; setState(() { _isCalculatingRoute = true; _routeCalculationError = null; + _calculatedRouteResult = null; // Clear stale geometry immediately }); final orderedStops = [ @@ -122,6 +133,14 @@ class _CreateGroupRideScreenState extends ConsumerState { for (int i = 0; i < _stops.length; i++) { final s = _stops[i]; + if (!RoutePlannerNotifier.isValidCoordinate(s.latitude, s.longitude)) { + setState(() { + _isCalculatingRoute = false; + _calculatedRouteResult = null; + _routeCalculationError = 'Stop ${i + 1} has invalid coordinates.'; + }); + return; + } orderedStops.add(RouteStop( id: 'stop_$i', order: i + 1, @@ -267,7 +286,8 @@ class _CreateGroupRideScreenState extends ConsumerState { _selectedRouteId = route.id; _selectedRouteTitle = route.title; _selectedRouteDistanceMeters = route.totalDistanceMeters; - _selectedRouteStopCount = route.stops.length; + final ordered = route.orderedStops; + _selectedRouteStopCount = ordered.length; if (_titleController.text.trim().isEmpty) { _titleController.text = route.title; @@ -277,8 +297,8 @@ class _CreateGroupRideScreenState extends ConsumerState { _descriptionController.text = route.description; } - if (route.stops.isNotEmpty) { - final origin = route.stops.first; + if (ordered.isNotEmpty) { + final origin = ordered.first; _meetupPlace = ResolvedPlace( providerId: origin.id, displayName: origin.displayName, @@ -291,8 +311,8 @@ class _CreateGroupRideScreenState extends ConsumerState { _meetupNotesController.text = origin.notes; } - if (route.stops.length > 1) { - final dest = route.stops.last; + if (ordered.length > 1) { + final dest = ordered.last; _hasDestination = true; _destinationPlace = ResolvedPlace( providerId: dest.id, @@ -304,9 +324,9 @@ class _CreateGroupRideScreenState extends ConsumerState { _destNameController.text = dest.displayName; _stops.clear(); - if (route.stops.length > 2) { - for (int i = 1; i < route.stops.length - 1; i++) { - final s = route.stops[i]; + if (ordered.length > 2) { + for (int i = 1; i < ordered.length - 1; i++) { + final s = ordered[i]; _stops.add( GroupRideLocation( latitude: s.latitude, @@ -327,6 +347,9 @@ class _CreateGroupRideScreenState extends ConsumerState { void _showSelectRouteSheet(MotorSignalThemeColors colors) { final user = ref.read(currentUserProvider); if (user == null) return; + final theme = Theme.of(context); + final onSurface = theme.colorScheme.onSurface; + final onSurfaceVariant = theme.colorScheme.onSurfaceVariant; showModalBottomSheet( context: context, @@ -345,18 +368,18 @@ class _CreateGroupRideScreenState extends ConsumerState { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( + Text( 'USE EXISTING ROUTE', style: TextStyle( - color: Colors.white, + color: onSurface, fontWeight: FontWeight.bold, fontSize: 16, ), ), const SizedBox(height: 8), - const Text( + Text( 'Pre-fills title, meetup location, intermediate waypoints, and destination.', - style: TextStyle(color: Colors.white54, fontSize: 12), + style: TextStyle(color: onSurfaceVariant, fontSize: 12), ), const SizedBox(height: 12), routesAsync.when( @@ -373,12 +396,12 @@ class _CreateGroupRideScreenState extends ConsumerState { ), data: (routes) { if (routes.isEmpty) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 24), + return Padding( + padding: const EdgeInsets.symmetric(vertical: 24), child: Center( child: Text( 'No saved routes found.', - style: TextStyle(color: Colors.white54), + style: TextStyle(color: onSurfaceVariant), ), ), ); @@ -397,14 +420,14 @@ class _CreateGroupRideScreenState extends ConsumerState { color: colors.cyberCyan), title: Text( route.title, - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: onSurface, fontWeight: FontWeight.bold), ), subtitle: Text( - '${route.stops.length} stops • ${route.formattedDistanceMiles}', - style: const TextStyle( - color: Colors.white54, fontSize: 12), + '${route.orderedStops.length} stops • ${route.formattedDistanceMiles}', + style: TextStyle( + color: onSurfaceVariant, fontSize: 12), ), onTap: () { Navigator.pop(sheetCtx); @@ -455,141 +478,152 @@ class _CreateGroupRideScreenState extends ConsumerState { showDialog( context: context, builder: (ctx) => StatefulBuilder( - builder: (ctx, setDialogState) => AlertDialog( - backgroundColor: colors.surfaceContainerHigh, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: colors.outlineVariant), - ), - title: Text( - existing != null ? 'Edit Intermediate Stop' : 'Add Intermediate Stop', - style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold), - ), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - AddressAutocompleteField( - labelText: 'Stop Address / Location *', - hintText: 'Search address or landmark...', - initialPlace: resolved, - onPlaceResolved: (place) { - setDialogState(() { - resolved = place; - if (place != null && nameCtrl.text.trim().isEmpty) { - nameCtrl.text = place.displayName; - } - }); - }, - ), - const SizedBox(height: 12), - TextField( - controller: nameCtrl, - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - labelText: 'Custom Stop Label (Optional)', - labelStyle: const TextStyle(color: Colors.white70), - hintText: 'e.g. Shell Station / Scenic Overlook', - hintStyle: const TextStyle(color: Colors.white24), - filled: true, - fillColor: colors.surfaceContainer, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: stopType, - dropdownColor: colors.surfaceContainerHigh, - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - labelText: 'Stop Type', - filled: true, - fillColor: colors.surfaceContainer, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - items: [ - GroupRideLocationType.fuel, - GroupRideLocationType.rest, - GroupRideLocationType.food, - GroupRideLocationType.scenic, - GroupRideLocationType.waypoint, - ].map((type) { - return DropdownMenuItem( - value: type, - child: Text(type.label), - ); - }).toList(), - onChanged: (val) { - if (val != null) setDialogState(() => stopType = val); - }, - ), - const SizedBox(height: 12), - TextField( - controller: notesCtrl, - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - labelText: 'Notes (optional)', - labelStyle: const TextStyle(color: Colors.white70), - hintText: 'e.g. Park near pumps, 15 min rest', - hintStyle: const TextStyle(color: Colors.white24), - filled: true, - fillColor: colors.surfaceContainer, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ], + builder: (ctx, setDialogState) { + final theme = Theme.of(ctx); + final onSurface = theme.colorScheme.onSurface; + final onSurfaceVariant = theme.colorScheme.onSurfaceVariant; + + return AlertDialog( + backgroundColor: colors.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: colors.outlineVariant), ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('CANCEL', style: TextStyle(color: Colors.white60)), + title: Text( + existing != null ? 'Edit Intermediate Stop' : 'Add Intermediate Stop', + style: TextStyle(color: onSurface, fontSize: 16, fontWeight: FontWeight.bold), ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: colors.cyberCyan, - foregroundColor: Colors.black, + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AddressAutocompleteField( + labelText: 'Stop Address / Location *', + hintText: 'Search address or landmark...', + initialPlace: resolved, + onPlaceResolved: (place) { + setDialogState(() { + resolved = place; + if (place != null && nameCtrl.text.trim().isEmpty) { + nameCtrl.text = place.displayName; + } + }); + }, + ), + const SizedBox(height: 12), + TextField( + controller: nameCtrl, + style: TextStyle(color: onSurface), + decoration: InputDecoration( + labelText: 'Custom Stop Label (Optional)', + labelStyle: TextStyle(color: onSurfaceVariant), + hintText: 'e.g. Shell Station / Scenic Overlook', + hintStyle: TextStyle(color: onSurfaceVariant.withValues(alpha: 0.5)), + filled: true, + fillColor: colors.surfaceContainer, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: stopType, + dropdownColor: colors.surfaceContainerHigh, + style: TextStyle(color: onSurface), + decoration: InputDecoration( + labelText: 'Stop Type', + filled: true, + fillColor: colors.surfaceContainer, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + items: [ + GroupRideLocationType.fuel, + GroupRideLocationType.rest, + GroupRideLocationType.food, + GroupRideLocationType.scenic, + GroupRideLocationType.waypoint, + ].map((type) { + return DropdownMenuItem( + value: type, + child: Text( + type.label, + style: TextStyle(color: onSurface), + ), + ); + }).toList(), + onChanged: (val) { + if (val != null) setDialogState(() => stopType = val); + }, + ), + const SizedBox(height: 12), + TextField( + controller: notesCtrl, + style: TextStyle(color: onSurface), + decoration: InputDecoration( + labelText: 'Notes (optional)', + labelStyle: TextStyle(color: onSurfaceVariant), + hintText: 'e.g. Park near pumps, 15 min rest', + hintStyle: TextStyle(color: onSurfaceVariant.withValues(alpha: 0.5)), + filled: true, + fillColor: colors.surfaceContainer, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ], ), - onPressed: () { - if (resolved == null) { - ScaffoldMessenger.of(ctx).showSnackBar( - const SnackBar( - content: Text('Please select an address from search suggestions.'), - ), - ); - return; - } - final loc = GroupRideLocation( - latitude: resolved!.latitude, - longitude: resolved!.longitude, - displayName: nameCtrl.text.trim().isNotEmpty - ? nameCtrl.text.trim() - : resolved!.displayName, - address: resolved!.formattedAddress ?? '', - locationType: stopType, - notes: notesCtrl.text.trim(), - ); - setState(() { - if (editIndex != null) { - _stops[editIndex] = loc; - } else { - _stops.add(loc); - } - }); - Navigator.pop(ctx); - _triggerRouteCalculation(); - }, - child: Text(existing != null ? 'SAVE STOP' : 'ADD STOP'), ), - ], - ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text('CANCEL', style: TextStyle(color: onSurfaceVariant)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: colors.cyberCyan, + foregroundColor: Colors.black, + ), + onPressed: () { + if (resolved == null || + !RoutePlannerNotifier.isValidCoordinate( + resolved!.latitude, resolved!.longitude)) { + ScaffoldMessenger.of(ctx).showSnackBar( + const SnackBar( + content: Text('Please select a valid address with coordinates from search suggestions.'), + ), + ); + return; + } + final loc = GroupRideLocation( + latitude: resolved!.latitude, + longitude: resolved!.longitude, + displayName: nameCtrl.text.trim().isNotEmpty + ? nameCtrl.text.trim() + : resolved!.displayName, + address: resolved!.formattedAddress ?? '', + locationType: stopType, + notes: notesCtrl.text.trim(), + ); + setState(() { + if (editIndex != null) { + _stops[editIndex] = loc; + } else { + _stops.add(loc); + } + }); + Navigator.pop(ctx); + _triggerRouteCalculation(); + }, + child: Text(existing != null ? 'SAVE STOP' : 'ADD STOP'), + ), + ], + ); + }, ), ); } diff --git a/lib/features/routes/route_planner_screen.dart b/lib/features/routes/route_planner_screen.dart index 1a1d378..ff5f3b6 100644 --- a/lib/features/routes/route_planner_screen.dart +++ b/lib/features/routes/route_planner_screen.dart @@ -153,8 +153,8 @@ class _RoutePlannerScreenState extends ConsumerState { ), title: Text( existingStop != null ? 'Edit Stop' : 'Add Route Stop', - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold, ), ), @@ -193,12 +193,12 @@ class _RoutePlannerScreenState extends ConsumerState { const SizedBox(height: 12), TextField( controller: nameCtrl, - style: const TextStyle(color: Colors.white), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), decoration: InputDecoration( labelText: 'Stop Name *', - labelStyle: const TextStyle(color: Colors.white60), + labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), hintText: 'e.g. Skyline Summit, Fuel Stop', - hintStyle: const TextStyle(color: Colors.white24), + hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)), suffixIcon: IconButton( icon: Icon(Icons.search, color: colors.cyberCyan, size: 20), @@ -226,10 +226,10 @@ class _RoutePlannerScreenState extends ConsumerState { initialValue: selectedType, isExpanded: true, dropdownColor: colors.surfaceContainerHigh, - style: const TextStyle(color: Colors.white), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), decoration: InputDecoration( labelText: 'Stop Type', - labelStyle: const TextStyle(color: Colors.white60), + labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), enabledBorder: UnderlineInputBorder( borderSide: BorderSide(color: colors.outlineVariant), ), @@ -248,6 +248,7 @@ class _RoutePlannerScreenState extends ConsumerState { Expanded( child: Text( type.label, + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), overflow: TextOverflow.ellipsis, ), ), @@ -271,15 +272,15 @@ class _RoutePlannerScreenState extends ConsumerState { decimal: true, signed: true, ), - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, fontSize: 13, ), - decoration: const InputDecoration( + decoration: InputDecoration( labelText: 'Latitude *', - labelStyle: TextStyle(color: Colors.white60), + labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), hintText: '37.421998', - hintStyle: TextStyle(color: Colors.white24), + hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)), ), ), ), @@ -291,15 +292,15 @@ class _RoutePlannerScreenState extends ConsumerState { decimal: true, signed: true, ), - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, fontSize: 13, ), - decoration: const InputDecoration( + decoration: InputDecoration( labelText: 'Longitude *', - labelStyle: TextStyle(color: Colors.white60), + labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), hintText: '-122.084000', - hintStyle: TextStyle(color: Colors.white24), + hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)), ), ), ), @@ -308,13 +309,13 @@ class _RoutePlannerScreenState extends ConsumerState { const SizedBox(height: 12), TextField( controller: notesCtrl, - style: const TextStyle(color: Colors.white), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), maxLines: 2, - decoration: const InputDecoration( + decoration: InputDecoration( labelText: 'Notes (Optional)', - labelStyle: TextStyle(color: Colors.white60), + labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), hintText: 'Regroup point, scenic view, cautions...', - hintStyle: TextStyle(color: Colors.white24), + hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)), ), ), ], @@ -323,9 +324,9 @@ class _RoutePlannerScreenState extends ConsumerState { actions: [ TextButton( onPressed: () => Navigator.pop(dialogCtx), - child: const Text( + child: Text( 'CANCEL', - style: TextStyle(color: Colors.white54), + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), ), ), ElevatedButton( @@ -346,18 +347,10 @@ class _RoutePlannerScreenState extends ConsumerState { ); return; } - if (lat == null || lat < -90.0 || lat > 90.0) { + if (!RoutePlannerNotifier.isValidCoordinate(lat, lng)) { 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).'), + content: Text('Invalid coordinates: latitude [-90, 90], longitude [-180, 180], non-zero.'), ), ); return; @@ -370,8 +363,8 @@ class _RoutePlannerScreenState extends ConsumerState { notifier.updateStop( existingStop.copyWith( displayName: name, - latitude: lat, - longitude: lng, + latitude: lat!, + longitude: lng!, stopType: selectedType, notes: notesCtrl.text.trim(), ), @@ -379,8 +372,8 @@ class _RoutePlannerScreenState extends ConsumerState { } else { notifier.addStop( displayName: name, - latitude: lat, - longitude: lng, + latitude: lat!, + longitude: lng!, stopType: selectedType, notes: notesCtrl.text.trim(), ); @@ -423,10 +416,10 @@ class _RoutePlannerScreenState extends ConsumerState { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Text( + Text( 'SEARCH ADDRESS OR PLACE', style: TextStyle( - color: Colors.white, + color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold, fontSize: 14, ), @@ -570,10 +563,12 @@ class _RoutePlannerScreenState extends ConsumerState { 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); + // Center map on road geometry, first stop, or default + final mapCenter = state.geometry.isNotEmpty + ? state.geometry.first + : (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) { @@ -618,19 +613,18 @@ class _RoutePlannerScreenState extends ConsumerState { ); }).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: colors.cyberCyan, - width: 3.5, - ), - ] - : []; + // 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, + ), + ); + } return Scaffold( backgroundColor: colors.surfaceContainer, @@ -638,12 +632,13 @@ class _RoutePlannerScreenState extends ConsumerState { backgroundColor: colors.surfaceContainerHigh, elevation: 0, leading: IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), + icon: Icon(Icons.arrow_back, color: Theme.of(context).colorScheme.onSurface), onPressed: () => context.pop(), ), title: Text( widget.initialRouteId != null ? 'EDIT ROUTE' : 'ROUTE PLANNER', - style: const TextStyle( + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.w900, letterSpacing: 1.2, fontSize: 16, @@ -907,7 +902,10 @@ class _RoutePlannerScreenState extends ConsumerState { ); } - Widget _buildErrorBanner(MotorSignalThemeColors colors) { + 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), @@ -925,7 +923,7 @@ class _RoutePlannerScreenState extends ConsumerState { const SizedBox(width: 8), Expanded( child: Text( - _errorMessage!, + effectiveError, style: TextStyle( color: colors.raceRed, fontSize: 12, @@ -933,9 +931,9 @@ class _RoutePlannerScreenState extends ConsumerState { ), ), IconButton( - icon: const Icon( + icon: Icon( Icons.close, - color: Colors.white54, + color: Theme.of(context).colorScheme.onSurfaceVariant, size: 16, ), onPressed: () => setState(() => _errorMessage = null), @@ -954,6 +952,20 @@ class _RoutePlannerScreenState extends ConsumerState { 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, @@ -969,9 +981,7 @@ class _RoutePlannerScreenState extends ConsumerState { children: [ Expanded( child: Text( - state.stops.length >= 2 - ? 'Straight-line: ${state.formattedDistanceMiles} (${state.formattedDistanceKm})' - : 'Add 2+ stops for distance', + distanceText, style: TextStyle( color: colors.cyberCyan, fontSize: isCompactHeight ? 11 : 12, @@ -1007,33 +1017,38 @@ class _RoutePlannerScreenState extends ConsumerState { } Widget _buildDisclaimerText() { + final theme = Theme.of(context); return Text( - 'Straight-line preview. Navigation handoff launches in Google Maps / Waze.', + 'Calculated road route. Navigation handoff launches in Google Maps / Waze.', textAlign: TextAlign.center, style: TextStyle( - color: Colors.white.withValues(alpha: 0.65), + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.75), fontSize: 9, shadows: const [ - Shadow(color: Colors.black, blurRadius: 4), + 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, - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: onSurface, fontWeight: FontWeight.bold, fontSize: 15, ), decoration: InputDecoration( hintText: 'Route Title (e.g. Skyline Ridge Loop) *', - hintStyle: const TextStyle( - color: Colors.white30, + hintStyle: TextStyle( + color: onSurfaceVariant.withValues(alpha: 0.5), fontSize: 14, ), isDense: true, @@ -1060,10 +1075,10 @@ class _RoutePlannerScreenState extends ConsumerState { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon( + Icon( Icons.visibility_outlined, size: 14, - color: Colors.white54, + color: onSurfaceVariant, ), const SizedBox(width: 4), Text( @@ -1087,8 +1102,8 @@ class _RoutePlannerScreenState extends ConsumerState { value: v, child: Text( v.label, - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: onSurface, fontSize: 13, ), ), @@ -1164,14 +1179,17 @@ class _RoutePlannerScreenState extends ConsumerState { onPressed: () => ref .read(routePlannerNotifierProvider.notifier) .reverseStops(), - icon: const Icon( + icon: Icon( Icons.swap_vert, size: 16, - color: Colors.white70, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), - label: const Text( + label: Text( 'REVERSE', - style: TextStyle(color: Colors.white70, fontSize: 11), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 11, + ), ), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric( @@ -1192,6 +1210,10 @@ class _RoutePlannerScreenState extends ConsumerState { bool isCompactHeight, bool isVeryCompactHeight, ) { + final theme = Theme.of(context); + final onSurface = theme.colorScheme.onSurface; + final onSurfaceVariant = theme.colorScheme.onSurfaceVariant; + if (state.stops.isEmpty) { return Center( child: SingleChildScrollView( @@ -1205,22 +1227,22 @@ class _RoutePlannerScreenState extends ConsumerState { Icon( Icons.alt_route, size: isCompactHeight ? 32 : 48, - color: Colors.white.withValues(alpha: 0.15), + color: onSurfaceVariant.withValues(alpha: 0.2), ), SizedBox(height: isCompactHeight ? 6 : 12), ], - const Text( + Text( 'No stops added yet.', style: TextStyle( - color: Colors.white54, + color: onSurfaceVariant, fontSize: 14, ), ), const SizedBox(height: 4), - const Text( + Text( 'Tap "Add Stop" or "Current GPS" to begin planning.', style: TextStyle( - color: Colors.white30, + color: onSurfaceVariant.withValues(alpha: 0.6), fontSize: 12, ), ), @@ -1300,8 +1322,8 @@ class _RoutePlannerScreenState extends ConsumerState { Flexible( child: Text( stop.displayName, - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: onSurface, fontWeight: FontWeight.bold, fontSize: 14, ), @@ -1313,8 +1335,8 @@ class _RoutePlannerScreenState extends ConsumerState { const SizedBox(height: 2), Text( '${stop.latitude.toStringAsFixed(4)}, ${stop.longitude.toStringAsFixed(4)}', - style: const TextStyle( - color: Colors.white38, + style: TextStyle( + color: onSurfaceVariant, fontSize: 11, ), ), @@ -1322,8 +1344,8 @@ class _RoutePlannerScreenState extends ConsumerState { const SizedBox(height: 2), Text( stop.notes, - style: const TextStyle( - color: Colors.white70, + style: TextStyle( + color: onSurfaceVariant, fontSize: 11, fontStyle: FontStyle.italic, ), @@ -1356,9 +1378,9 @@ class _RoutePlannerScreenState extends ConsumerState { ), ), IconButton( - icon: const Icon( + icon: Icon( Icons.edit, - color: Colors.white60, + color: onSurfaceVariant, size: 18, ), tooltip: 'Edit stop', @@ -1390,9 +1412,9 @@ class _RoutePlannerScreenState extends ConsumerState { ), ), const SizedBox(width: 4), - const Icon( + Icon( Icons.drag_handle, - color: Colors.white30, + color: onSurfaceVariant.withValues(alpha: 0.4), size: 20, ), ], diff --git a/lib/features/routes/widgets/address_autocomplete_field.dart b/lib/features/routes/widgets/address_autocomplete_field.dart index 45d40e6..3414e9d 100644 --- a/lib/features/routes/widgets/address_autocomplete_field.dart +++ b/lib/features/routes/widgets/address_autocomplete_field.dart @@ -288,6 +288,9 @@ class _AddressAutocompleteFieldState @override Widget build(BuildContext context) { final colors = context.motorColors; + final theme = Theme.of(context); + final onSurface = theme.colorScheme.onSurface; + final onSurfaceVariant = theme.colorScheme.onSurfaceVariant; final isResolved = _resolvedPlace != null; return Column( @@ -299,7 +302,7 @@ class _AddressAutocompleteFieldState focusNode: _focusNode, enabled: widget.enabled && !_isResolving, autofocus: widget.autofocus, - style: const TextStyle(color: Colors.white, fontSize: 14), + style: TextStyle(color: onSurface, fontSize: 14), onChanged: _onTextChanged, validator: (val) { if (widget.isRequired && (val == null || val.trim().isEmpty)) { @@ -317,10 +320,10 @@ class _AddressAutocompleteFieldState labelText: widget.labelText, hintText: widget.hintText ?? 'Type address or place name...', labelStyle: TextStyle( - color: isResolved ? colors.launchGreen : Colors.white70, + color: isResolved ? colors.launchGreen : onSurfaceVariant, fontSize: 13, ), - hintStyle: const TextStyle(color: Colors.white24, fontSize: 13), + hintStyle: TextStyle(color: onSurfaceVariant.withValues(alpha: 0.5), fontSize: 13), filled: true, fillColor: colors.surfaceContainerHigh, prefixIcon: @@ -346,10 +349,10 @@ class _AddressAutocompleteFieldState ) : _controller.text.isNotEmpty ? IconButton( - icon: const Icon( + icon: Icon( Icons.clear, size: 18, - color: Colors.white54, + color: onSurfaceVariant, ), onPressed: _clear, tooltip: 'Clear location', @@ -391,6 +394,10 @@ class _AddressAutocompleteFieldState } Widget _buildDropdownPanel(MotorSignalThemeColors colors) { + final theme = Theme.of(context); + final onSurface = theme.colorScheme.onSurface; + final onSurfaceVariant = theme.colorScheme.onSurfaceVariant; + return Material( color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), @@ -423,9 +430,9 @@ class _AddressAutocompleteFieldState ), ), const SizedBox(width: 12), - const Text( + Text( 'Searching addresses...', - style: TextStyle(color: Colors.white70, fontSize: 12), + style: TextStyle(color: onSurfaceVariant, fontSize: 12), ), ], ), @@ -444,8 +451,8 @@ class _AddressAutocompleteFieldState Expanded( child: Text( _errorMessage!, - style: const TextStyle( - color: Colors.white70, + style: TextStyle( + color: onSurfaceVariant, fontSize: 12, ), ), @@ -465,27 +472,27 @@ class _AddressAutocompleteFieldState ), ) else if (_hasSearched && _suggestions.isEmpty) - const Padding( - padding: EdgeInsets.all(16), + Padding( + padding: const EdgeInsets.all(16), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.location_off, - color: Colors.white38, + color: onSurfaceVariant.withValues(alpha: 0.5), size: 16, ), - SizedBox(width: 8), + const SizedBox(width: 8), Text( 'No matching addresses found', - style: TextStyle(color: Colors.white54, fontSize: 12), + style: TextStyle(color: onSurfaceVariant, fontSize: 12), ), ], ), ) else for (int i = 0; i < _suggestions.length; i++) ...[ - if (i > 0) const Divider(height: 1, color: Colors.white10), + if (i > 0) Divider(height: 1, color: colors.outlineVariant), ListTile( dense: true, contentPadding: const EdgeInsets.symmetric( @@ -499,8 +506,8 @@ class _AddressAutocompleteFieldState ), title: Text( _suggestions[i].primaryText, - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: onSurface, fontSize: 13, fontWeight: FontWeight.w600, ), @@ -510,8 +517,8 @@ class _AddressAutocompleteFieldState subtitle: _suggestions[i].secondaryText.isNotEmpty ? Text( _suggestions[i].secondaryText, - style: const TextStyle( - color: Colors.white54, + style: TextStyle( + color: onSurfaceVariant, fontSize: 11, ), maxLines: 1, @@ -528,10 +535,10 @@ class _AddressAutocompleteFieldState vertical: 4, ), color: colors.surfaceContainer, - child: const Text( + child: Text( 'Search by Stadia Maps / Pelias • © OpenStreetMap contributors', style: TextStyle( - color: Colors.white38, + color: onSurfaceVariant.withValues(alpha: 0.6), fontSize: 9, letterSpacing: 0.2, ), diff --git a/lib/services/routes/route_providers.dart b/lib/services/routes/route_providers.dart index 12a4325..bbea1bd 100644 --- a/lib/services/routes/route_providers.dart +++ b/lib/services/routes/route_providers.dart @@ -64,6 +64,11 @@ class RoutePlannerState { final RouteVisibility visibility; final List stops; final double straightLineDistanceMeters; + final List geometry; + final double? estimatedDistanceMeters; + final int? estimatedDurationSeconds; + final bool isCalculatingRoute; + final String? routeCalculationError; final bool isSaving; final String? errorMessage; final bool isDirty; @@ -76,6 +81,11 @@ class RoutePlannerState { this.visibility = RouteVisibility.private, this.stops = const [], this.straightLineDistanceMeters = 0.0, + this.geometry = const [], + this.estimatedDistanceMeters, + this.estimatedDurationSeconds, + this.isCalculatingRoute = false, + this.routeCalculationError, this.isSaving = false, this.errorMessage, this.isDirty = false, @@ -84,13 +94,38 @@ class RoutePlannerState { double get distanceMiles => straightLineDistanceMeters * 0.000621371; - String get formattedDistance => - '${distanceMiles.toStringAsFixed(1)} mi (Straight-line)'; + double? get roadDistanceMiles => + estimatedDistanceMeters != null ? estimatedDistanceMeters! * 0.000621371 : null; - String get formattedDistanceMiles => '${distanceMiles.toStringAsFixed(1)} mi'; + String? get formattedRoadDistanceMiles => + roadDistanceMiles != null ? '${roadDistanceMiles!.toStringAsFixed(1)} mi' : null; + + String? get formattedRoadDistanceKm => + estimatedDistanceMeters != null ? '${(estimatedDistanceMeters! / 1000).toStringAsFixed(1)} km' : null; + + String? get formattedRoadDuration { + if (estimatedDurationSeconds == null) return null; + final mins = (estimatedDurationSeconds! / 60).round(); + if (mins < 60) return '$mins min'; + final hrs = mins ~/ 60; + final remMins = mins % 60; + return '${hrs}h ${remMins}m'; + } + + String get formattedDistance { + if (estimatedDistanceMeters != null) { + return '${roadDistanceMiles!.toStringAsFixed(1)} mi (Road Est.)'; + } + return '${distanceMiles.toStringAsFixed(1)} mi (Straight-line)'; + } + + String get formattedDistanceMiles => + roadDistanceMiles != null ? '${roadDistanceMiles!.toStringAsFixed(1)} mi' : '${distanceMiles.toStringAsFixed(1)} mi'; String get formattedDistanceKm => - '${(straightLineDistanceMeters / 1000).toStringAsFixed(1)} km'; + estimatedDistanceMeters != null + ? '${(estimatedDistanceMeters! / 1000).toStringAsFixed(1)} km' + : '${(straightLineDistanceMeters / 1000).toStringAsFixed(1)} km'; List get previewPoints => stops.map((s) => s.latLng).toList(); @@ -101,8 +136,17 @@ class RoutePlannerState { RouteVisibility? visibility, List? stops, double? straightLineDistanceMeters, + List? geometry, + double? estimatedDistanceMeters, + bool clearEstimatedDistance = false, + int? estimatedDurationSeconds, + bool clearEstimatedDuration = false, + bool? isCalculatingRoute, + String? routeCalculationError, + bool clearRouteCalculationError = false, bool? isSaving, String? errorMessage, + bool clearErrorMessage = false, bool? isDirty, DateTime? createdAt, }) { @@ -114,8 +158,19 @@ class RoutePlannerState { stops: stops ?? this.stops, straightLineDistanceMeters: straightLineDistanceMeters ?? this.straightLineDistanceMeters, + geometry: geometry ?? this.geometry, + estimatedDistanceMeters: clearEstimatedDistance + ? null + : (estimatedDistanceMeters ?? this.estimatedDistanceMeters), + estimatedDurationSeconds: clearEstimatedDuration + ? null + : (estimatedDurationSeconds ?? this.estimatedDurationSeconds), + isCalculatingRoute: isCalculatingRoute ?? this.isCalculatingRoute, + routeCalculationError: clearRouteCalculationError + ? null + : (routeCalculationError ?? this.routeCalculationError), isSaving: isSaving ?? this.isSaving, - errorMessage: errorMessage, + errorMessage: clearErrorMessage ? null : (errorMessage ?? this.errorMessage), isDirty: isDirty ?? this.isDirty, createdAt: createdAt ?? this.createdAt, ); @@ -125,6 +180,7 @@ class RoutePlannerState { /// Controller managing in-memory route editing state. class RoutePlannerNotifier extends StateNotifier { final Ref ref; + int _calculationSequence = 0; RoutePlannerNotifier(this.ref, [MotorSignalRoute? initialRoute]) : super( @@ -140,9 +196,23 @@ class RoutePlannerNotifier extends StateNotifier { createdAt: initialRoute.createdAt, ) : const RoutePlannerState(), - ); + ) { + if (initialRoute != null && initialRoute.orderedStops.length >= 2) { + _calculateRoadRoute(); + } + } + + static bool isValidCoordinate(double? lat, double? lng) { + if (lat == null || lng == null) return false; + if (lat.isNaN || lng.isNaN || lat.isInfinite || lng.isInfinite) return false; + if (lat < -90.0 || lat > 90.0) return false; + if (lng < -180.0 || lng > 180.0) return false; + if (lat == 0.0 && lng == 0.0) return false; + return true; + } void loadRoute(MotorSignalRoute route) { + _calculationSequence++; state = RoutePlannerState( routeId: route.id, title: route.title, @@ -153,9 +223,13 @@ class RoutePlannerNotifier extends StateNotifier { createdAt: route.createdAt, isDirty: false, ); + if (route.orderedStops.length >= 2) { + _calculateRoadRoute(); + } } void reset() { + _calculationSequence++; state = const RoutePlannerState(); } @@ -179,56 +253,124 @@ class RoutePlannerNotifier extends StateNotifier { RouteStopType? stopType, String? notes, }) { + final effectiveLat = stop?.latitude ?? latitude; + final effectiveLng = stop?.longitude ?? longitude; + + if (!isValidCoordinate(effectiveLat, effectiveLng)) { + state = state.copyWith( + errorMessage: 'Invalid coordinates: latitude [-90, 90], longitude [-180, 180], non-zero.', + ); + return; + } + + final effectiveType = stop?.stopType ?? stopType ?? (state.stops.isEmpty ? RouteStopType.origin : RouteStopType.waypoint); final updatedStops = List.from(state.stops); - final effectiveOrder = updatedStops.length; final newStop = stop != null - ? stop.copyWith(order: effectiveOrder, orderIndex: effectiveOrder) + ? stop.copyWith( + latitude: effectiveLat!, + longitude: effectiveLng!, + type: effectiveType, + stopType: effectiveType, + ) : RouteStop( - id: 'stop_${DateTime.now().millisecondsSinceEpoch}_$effectiveOrder', - order: effectiveOrder, - orderIndex: effectiveOrder, - type: stopType ?? RouteStopType.waypoint, - stopType: stopType ?? RouteStopType.waypoint, - displayName: displayName ?? 'Stop $effectiveOrder', - latitude: latitude ?? 0.0, - longitude: longitude ?? 0.0, + id: 'stop_${DateTime.now().millisecondsSinceEpoch}_${updatedStops.length}', + order: updatedStops.length, + orderIndex: updatedStops.length, + type: effectiveType, + stopType: effectiveType, + displayName: displayName ?? 'Stop ${updatedStops.length + 1}', + latitude: effectiveLat!, + longitude: effectiveLng!, notes: notes ?? '', ); - updatedStops.add(newStop); + // Canonical ordering: + // 1. If origin/start, goes at index 0. + // 2. If destination, goes at end. + // 3. If waypoint/intermediate, and the list already ends with a destination, + // insert BEFORE the destination stop! + if (effectiveType == RouteStopType.origin || effectiveType == RouteStopType.start) { + updatedStops.insert(0, newStop); + } else if (effectiveType == RouteStopType.destination) { + updatedStops.add(newStop); + } else { + // Waypoint / intermediate + if (updatedStops.isNotEmpty && + (updatedStops.last.stopType == RouteStopType.destination || + updatedStops.last.type == RouteStopType.destination)) { + updatedStops.insert(updatedStops.length - 1, newStop); + } else { + updatedStops.add(newStop); + } + } - final distance = - MotorSignalRoute.calculateTotalDistanceMeters(updatedStops); + // Re-index all stops + final reindexed = []; + for (int i = 0; i < updatedStops.length; i++) { + reindexed.add(updatedStops[i].copyWith(order: i, orderIndex: i)); + } + + final distance = MotorSignalRoute.calculateTotalDistanceMeters(reindexed); state = state.copyWith( - stops: updatedStops, + stops: reindexed, straightLineDistanceMeters: distance, + geometry: const [], // Invalidate stale geometry immediately! + clearEstimatedDistance: true, + clearEstimatedDuration: true, + isCalculatingRoute: reindexed.length >= 2, + clearRouteCalculationError: true, isDirty: true, - errorMessage: null, + clearErrorMessage: true, ); + + if (reindexed.length >= 2) { + _calculateRoadRoute(); + } } void updateStop(dynamic target, [RouteStop? maybeStop]) { final updatedStops = List.from(state.stops); + int targetIdx = -1; + RouteStop updated; + if (target is RouteStop) { - final idx = updatedStops.indexWhere((s) => s.id == target.id); - if (idx == -1) return; - updatedStops[idx] = target.copyWith(order: idx, orderIndex: idx); + targetIdx = updatedStops.indexWhere((s) => s.id == target.id); + if (targetIdx == -1) return; + updated = target; } else if (target is int && maybeStop != null) { if (target < 0 || target >= updatedStops.length) return; - updatedStops[target] = maybeStop.copyWith(order: target, orderIndex: target); + targetIdx = target; + updated = maybeStop; } else { return; } - final distance = - MotorSignalRoute.calculateTotalDistanceMeters(updatedStops); + if (!isValidCoordinate(updated.latitude, updated.longitude)) { + state = state.copyWith( + errorMessage: 'Invalid coordinates for updated stop.', + ); + return; + } + + updatedStops[targetIdx] = updated.copyWith(order: targetIdx, orderIndex: targetIdx); + + final distance = MotorSignalRoute.calculateTotalDistanceMeters(updatedStops); state = state.copyWith( stops: updatedStops, straightLineDistanceMeters: distance, + geometry: const [], // Invalidate stale geometry + clearEstimatedDistance: true, + clearEstimatedDuration: true, + isCalculatingRoute: updatedStops.length >= 2, + clearRouteCalculationError: true, isDirty: true, - errorMessage: null, + clearErrorMessage: true, ); + + if (updatedStops.length >= 2) { + _calculateRoadRoute(); + } } void removeStop(dynamic target) { @@ -242,7 +384,6 @@ class RoutePlannerNotifier extends StateNotifier { return; } - // Re-index remaining stops final reindexed = []; for (int i = 0; i < updatedStops.length; i++) { reindexed.add(updatedStops[i].copyWith(order: i, orderIndex: i)); @@ -252,9 +393,18 @@ class RoutePlannerNotifier extends StateNotifier { state = state.copyWith( stops: reindexed, straightLineDistanceMeters: distance, + geometry: const [], // Invalidate stale geometry + clearEstimatedDistance: true, + clearEstimatedDuration: true, + isCalculatingRoute: reindexed.length >= 2, + clearRouteCalculationError: true, isDirty: true, - errorMessage: null, + clearErrorMessage: true, ); + + if (reindexed.length >= 2) { + _calculateRoadRoute(); + } } void reorderStops(int oldIndex, int newIndex) { @@ -275,9 +425,18 @@ class RoutePlannerNotifier extends StateNotifier { state = state.copyWith( stops: reindexed, straightLineDistanceMeters: distance, + geometry: const [], // Invalidate stale geometry + clearEstimatedDistance: true, + clearEstimatedDuration: true, + isCalculatingRoute: reindexed.length >= 2, + clearRouteCalculationError: true, isDirty: true, - errorMessage: null, + clearErrorMessage: true, ); + + if (reindexed.length >= 2) { + _calculateRoadRoute(); + } } void reverseStops() { @@ -292,9 +451,65 @@ class RoutePlannerNotifier extends StateNotifier { state = state.copyWith( stops: reindexed, straightLineDistanceMeters: distance, + geometry: const [], // Invalidate stale geometry + clearEstimatedDistance: true, + clearEstimatedDuration: true, + isCalculatingRoute: reindexed.length >= 2, + clearRouteCalculationError: true, isDirty: true, - errorMessage: null, + clearErrorMessage: true, ); + + if (reindexed.length >= 2) { + _calculateRoadRoute(); + } + } + + Future recalculateRoute() => _calculateRoadRoute(); + + Future _calculateRoadRoute() async { + if (state.stops.length < 2) { + state = state.copyWith( + geometry: const [], + clearEstimatedDistance: true, + clearEstimatedDuration: true, + isCalculatingRoute: false, + clearRouteCalculationError: true, + ); + return; + } + + final seq = ++_calculationSequence; + state = state.copyWith( + isCalculatingRoute: true, + clearRouteCalculationError: true, + geometry: const [], + ); + + try { + final provider = ref.read(activeRouteCalculationProvider); + final result = await provider.calculateRoute(state.stops); + if (seq != _calculationSequence) return; + state = state.copyWith( + geometry: result.geometry, + estimatedDistanceMeters: result.estimatedDistanceMeters, + estimatedDurationSeconds: result.estimatedDurationSeconds, + isCalculatingRoute: false, + clearRouteCalculationError: true, + ); + } catch (e) { + if (seq != _calculationSequence) return; + final err = e.toString() + .replaceAll('RouteProviderException: ', '') + .replaceAll('Exception: ', ''); + state = state.copyWith( + geometry: const [], // Stale geometry cleared, no straight-line fallback + clearEstimatedDistance: true, + clearEstimatedDuration: true, + isCalculatingRoute: false, + routeCalculationError: err, + ); + } } Future saveRoute({ diff --git a/test/rfr1_routing_test.dart b/test/rfr1_routing_test.dart new file mode 100644 index 0000000..bb140d8 --- /dev/null +++ b/test/rfr1_routing_test.dart @@ -0,0 +1,396 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/features/routes/route_planner_screen.dart'; +import 'package:motorcycle_dash/services/auth/auth_providers.dart'; +import 'package:motorcycle_dash/services/auth/auth_user.dart'; +import 'package:motorcycle_dash/services/entitlements/entitlement_providers.dart'; +import 'package:motorcycle_dash/services/map/unified_map_view.dart'; +import 'package:motorcycle_dash/services/routes/route_models.dart'; +import 'package:motorcycle_dash/services/routes/route_providers.dart'; + +import 'fake_route_provider.dart'; + +void main() { + group('RFR-1: Route Ordering & Geometry Unit Tests', () { + test('Coordinate validation rejects invalid, NaN, Infinity, and (0,0)', () { + expect(RoutePlannerNotifier.isValidCoordinate(37.7749, -122.4194), isTrue); + expect(RoutePlannerNotifier.isValidCoordinate(-90.0, -180.0), isTrue); + expect(RoutePlannerNotifier.isValidCoordinate(90.0, 180.0), isTrue); + + // Invalid bounds + expect(RoutePlannerNotifier.isValidCoordinate(91.0, 0.0), isFalse); + expect(RoutePlannerNotifier.isValidCoordinate(-91.0, 0.0), isFalse); + expect(RoutePlannerNotifier.isValidCoordinate(0.0, 181.0), isFalse); + expect(RoutePlannerNotifier.isValidCoordinate(0.0, -181.0), isFalse); + + // Null / NaN / Infinity + expect(RoutePlannerNotifier.isValidCoordinate(null, -122.0), isFalse); + expect(RoutePlannerNotifier.isValidCoordinate(37.0, null), isFalse); + expect(RoutePlannerNotifier.isValidCoordinate(double.nan, -122.0), isFalse); + expect(RoutePlannerNotifier.isValidCoordinate(37.0, double.nan), isFalse); + expect(RoutePlannerNotifier.isValidCoordinate(double.infinity, -122.0), isFalse); + expect(RoutePlannerNotifier.isValidCoordinate(37.0, double.infinity), isFalse); + + // (0, 0) placeholder + expect(RoutePlannerNotifier.isValidCoordinate(0.0, 0.0), isFalse); + }); + + test('RoutePlannerNotifier: start + destination triggers road calculation', () async { + final fake = FakeRouteProvider(); + final container = ProviderContainer( + overrides: [ + activeRouteCalculationProvider.overrideWithValue(fake), + ], + ); + addTearDown(container.dispose); + + container.listen(routePlannerProvider(null), (_, _) {}); + final notifier = container.read(routePlannerProvider(null).notifier); + + final origin = RouteStop( + id: 'stop-1', + displayName: 'Start Location', + latitude: 37.7749, + longitude: -122.4194, + order: 0, + type: RouteStopType.origin, + ); + final destination = RouteStop( + id: 'stop-2', + displayName: 'Destination Location', + latitude: 37.3382, + longitude: -121.8863, + order: 1, + type: RouteStopType.destination, + ); + + notifier.addStop(stop: origin); + await Future.delayed(const Duration(milliseconds: 50)); + expect(container.read(routePlannerProvider(null)).stops.length, 1); + expect(container.read(routePlannerProvider(null)).geometry, isEmpty); + + notifier.addStop(stop: destination); + await Future.delayed(const Duration(milliseconds: 100)); + + final state = container.read(routePlannerProvider(null)); + expect(state.stops.length, 2); + expect(state.geometry.isNotEmpty, isTrue); + expect(state.geometry.length, 2); + expect(state.geometry.first.latitude, closeTo(37.7749, 0.001)); + expect(state.geometry.last.latitude, closeTo(37.3382, 0.001)); + }); + + test('RoutePlannerNotifier: start + stop + destination preserves exact canonical order', () async { + final fake = FakeRouteProvider(); + final container = ProviderContainer( + overrides: [ + activeRouteCalculationProvider.overrideWithValue(fake), + ], + ); + addTearDown(container.dispose); + + container.listen(routePlannerProvider(null), (_, _) {}); + final notifier = container.read(routePlannerProvider(null).notifier); + + final origin = RouteStop( + id: 'origin', + displayName: 'San Francisco', + latitude: 37.7749, + longitude: -122.4194, + order: 0, + type: RouteStopType.origin, + ); + final destination = RouteStop( + id: 'destination', + displayName: 'San Jose', + latitude: 37.3382, + longitude: -121.8863, + order: 1, + type: RouteStopType.destination, + ); + + notifier.addStop(stop: origin); + notifier.addStop(stop: destination); + await Future.delayed(const Duration(milliseconds: 100)); + + // Intermediate stop added: Must be placed BEFORE destination, NOT after + final intermediate = RouteStop( + id: 'mid', + displayName: 'Palo Alto', + latitude: 37.4419, + longitude: -122.1430, + order: 2, + type: RouteStopType.waypoint, + ); + + notifier.addStop(stop: intermediate); + await Future.delayed(const Duration(milliseconds: 100)); + + final state = container.read(routePlannerProvider(null)); + expect(state.stops.length, 3); + + // Canonical order: origin (0) -> intermediate (1) -> destination (2) + expect(state.stops[0].id, 'origin'); + expect(state.stops[0].order, 0); + expect(state.stops[0].type, RouteStopType.origin); + + expect(state.stops[1].id, 'mid'); + expect(state.stops[1].order, 1); + expect(state.stops[1].type, RouteStopType.waypoint); + + expect(state.stops[2].id, 'destination'); + expect(state.stops[2].order, 2); + expect(state.stops[2].type, RouteStopType.destination); + + // FakeRouteProvider received ordered stops + expect(fake.lastCapturedStops, isNotNull); + expect(fake.lastCapturedStops!.map((s) => s.id).toList(), ['origin', 'mid', 'destination']); + + // Geometry has all 3 points in order + expect(state.geometry.length, 3); + expect(state.geometry[0].latitude, closeTo(37.7749, 0.001)); + expect(state.geometry[1].latitude, closeTo(37.4419, 0.001)); + expect(state.geometry[2].latitude, closeTo(37.3382, 0.001)); + }); + + test('RoutePlannerNotifier: multiple stops preserve order', () async { + final fake = FakeRouteProvider(); + final container = ProviderContainer( + overrides: [ + activeRouteCalculationProvider.overrideWithValue(fake), + ], + ); + addTearDown(container.dispose); + + container.listen(routePlannerProvider(null), (_, _) {}); + final notifier = container.read(routePlannerProvider(null).notifier); + + notifier.addStop(stop: const RouteStop(id: 'start', displayName: 'Start', latitude: 37.0, longitude: -122.0, order: 0, type: RouteStopType.origin)); + notifier.addStop(stop: const RouteStop(id: 'end', displayName: 'End', latitude: 37.9, longitude: -122.9, order: 1, type: RouteStopType.destination)); + notifier.addStop(stop: const RouteStop(id: 'stop1', displayName: 'Stop 1', latitude: 37.2, longitude: -122.2, order: 2, type: RouteStopType.waypoint)); + notifier.addStop(stop: const RouteStop(id: 'stop2', displayName: 'Stop 2', latitude: 37.4, longitude: -122.4, order: 3, type: RouteStopType.waypoint)); + + await Future.delayed(const Duration(milliseconds: 100)); + + final stops = container.read(routePlannerProvider(null)).stops; + expect(stops.length, 4); + expect(stops.map((s) => s.id).toList(), ['start', 'stop1', 'stop2', 'end']); + for (int i = 0; i < stops.length; i++) { + expect(stops[i].order, i); + } + }); + + test('RoutePlannerNotifier: stop addition invalidates/replaces stale geometry', () async { + final fake = FakeRouteProvider(); + final container = ProviderContainer( + overrides: [ + activeRouteCalculationProvider.overrideWithValue(fake), + ], + ); + addTearDown(container.dispose); + + container.listen(routePlannerProvider(null), (_, _) {}); + final notifier = container.read(routePlannerProvider(null).notifier); + notifier.addStop(stop: const RouteStop(id: 'A', displayName: 'A', latitude: 37.0, longitude: -122.0, order: 0, type: RouteStopType.origin)); + notifier.addStop(stop: const RouteStop(id: 'B', displayName: 'B', latitude: 38.0, longitude: -122.0, order: 1, type: RouteStopType.destination)); + + await Future.delayed(const Duration(milliseconds: 100)); + expect(container.read(routePlannerProvider(null)).geometry.length, 2); + + // Adding another stop must update geometry to length 3 + notifier.addStop(stop: const RouteStop(id: 'C', displayName: 'C', latitude: 37.5, longitude: -122.0, order: 2, type: RouteStopType.waypoint)); + await Future.delayed(const Duration(milliseconds: 100)); + + expect(container.read(routePlannerProvider(null)).geometry.length, 3); + }); + + test('RoutePlannerNotifier: route calculation failure clears stale geometry and sets error', () async { + final fake = FakeRouteProvider(shouldFail: true); + final container = ProviderContainer( + overrides: [ + activeRouteCalculationProvider.overrideWithValue(fake), + ], + ); + addTearDown(container.dispose); + + container.listen(routePlannerProvider(null), (_, _) {}); + final notifier = container.read(routePlannerProvider(null).notifier); + notifier.addStop(stop: const RouteStop(id: 'A', displayName: 'A', latitude: 37.0, longitude: -122.0, order: 0, type: RouteStopType.origin)); + notifier.addStop(stop: const RouteStop(id: 'B', displayName: 'B', latitude: 38.0, longitude: -122.0, order: 1, type: RouteStopType.destination)); + + await Future.delayed(const Duration(milliseconds: 100)); + + final state = container.read(routePlannerProvider(null)); + // Stale geometry MUST be cleared on failure + expect(state.geometry, isEmpty); + expect(state.routeCalculationError, isNotNull); + expect(state.routeCalculationError, contains('Fake route not found')); + }); + + test('No fallback coordinate contamination: rejected invalid stop', () { + final fake = FakeRouteProvider(); + final container = ProviderContainer( + overrides: [ + activeRouteCalculationProvider.overrideWithValue(fake), + ], + ); + addTearDown(container.dispose); + + container.listen(routePlannerProvider(null), (_, _) {}); + final notifier = container.read(routePlannerProvider(null).notifier); + // Try adding NaN or 0,0 stop + notifier.addStop(stop: const RouteStop(id: 'bad', displayName: 'Bad Stop', latitude: 0.0, longitude: 0.0, order: 0, type: RouteStopType.waypoint)); + + expect(container.read(routePlannerProvider(null)).stops, isEmpty); + + expect(container.read(routePlannerProvider(null)).stops, isEmpty); + }); + }); + + group('RFR-1: Widget & Theme Tests', () { + testWidgets('Renderer receives road geometry polyline when route exists', (tester) async { + await tester.binding.setSurfaceSize(const Size(412, 892)); // Pixel 8a portrait + final fake = FakeRouteProvider(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWith((ref) => const AuthUser(uid: 'alice', isAnonymous: false)), + isProSubscriberProvider.overrideWith((ref) => false), + activeRouteCalculationProvider.overrideWithValue(fake), + ], + child: const MaterialApp( + home: RoutePlannerScreen(), + ), + ), + ); + await tester.pumpAndSettle(); + + // Initially no polylines rendered + final initialMap = tester.widget(find.byKey(const ValueKey('route_planner_map_compact'))); + expect(initialMap.polylines, isEmpty); + + // Add two stops + await tester.tap(find.text('ADD STOP')); + await tester.pumpAndSettle(); + + final textFields = find.descendant(of: find.byType(AlertDialog), matching: find.byType(TextField)); + await tester.enterText(textFields.at(0), 'Start Point'); + await tester.enterText(textFields.at(1), '37.7749'); + await tester.enterText(textFields.at(2), '-122.4194'); + await tester.tap(find.text('ADD')); + await tester.pumpAndSettle(); + + // Add second stop + await tester.tap(find.text('ADD STOP')); + await tester.pumpAndSettle(); + + final textFields2 = find.descendant(of: find.byType(AlertDialog), matching: find.byType(TextField)); + await tester.enterText(textFields2.at(0), 'End Point'); + await tester.enterText(textFields2.at(1), '37.3382'); + await tester.enterText(textFields2.at(2), '-121.8863'); + await tester.tap(find.text('ADD')); + await tester.pumpAndSettle(); + + // Verify road geometry polyline was passed to UnifiedMapView + final updatedMap = tester.widget(find.byKey(const ValueKey('route_planner_map_compact'))); + expect(updatedMap.polylines.length, 1); + expect(updatedMap.polylines.first.points.length, 2); + expect(updatedMap.polylines.first.points.first.latitude, closeTo(37.7749, 0.001)); + expect(updatedMap.polylines.first.points.last.latitude, closeTo(37.3382, 0.001)); + }); + + testWidgets('Light mode controls remain visible with proper contrast', (tester) async { + await tester.binding.setSurfaceSize(const Size(412, 892)); + final fake = FakeRouteProvider(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWith((ref) => const AuthUser(uid: 'alice', isAnonymous: false)), + isProSubscriberProvider.overrideWith((ref) => false), + activeRouteCalculationProvider.overrideWithValue(fake), + ], + child: MaterialApp( + theme: ThemeData.light(), + home: const RoutePlannerScreen(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('ROUTE PLANNER'), findsOneWidget); + expect(find.text('SAVE'), findsOneWidget); + expect(find.text('ADD STOP'), findsOneWidget); + expect(find.text('CURRENT GPS'), findsOneWidget); + expect(find.text('No stops added yet.'), findsOneWidget); + + // Open Add Stop dialog in light mode + await tester.tap(find.text('ADD STOP')); + await tester.pumpAndSettle(); + + expect(find.text('Add Route Stop'), findsOneWidget); + expect(find.text('CANCEL'), findsOneWidget); + expect(find.text('ADD'), findsOneWidget); + + // Cancel dialog + await tester.tap(find.text('CANCEL')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }); + + testWidgets('Dark mode controls render properly', (tester) async { + await tester.binding.setSurfaceSize(const Size(412, 892)); + final fake = FakeRouteProvider(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWith((ref) => const AuthUser(uid: 'alice', isAnonymous: false)), + isProSubscriberProvider.overrideWith((ref) => false), + activeRouteCalculationProvider.overrideWithValue(fake), + ], + child: MaterialApp( + theme: ThemeData.dark(), + home: const RoutePlannerScreen(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('ROUTE PLANNER'), findsOneWidget); + expect(find.text('SAVE'), findsOneWidget); + expect(find.text('ADD STOP'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('Pixel 8a portrait has no RenderFlex overflow with multiple stops and route info', (tester) async { + // Pixel 8a exact viewport: 412 x 892 dp + await tester.binding.setSurfaceSize(const Size(412, 892)); + final fake = FakeRouteProvider(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWith((ref) => const AuthUser(uid: 'alice', isAnonymous: false)), + isProSubscriberProvider.overrideWith((ref) => false), + activeRouteCalculationProvider.overrideWithValue(fake), + ], + child: const MaterialApp( + home: RoutePlannerScreen(), + ), + ), + ); + await tester.pumpAndSettle(); + + final titleField = find.byType(TextField).first; + await tester.enterText(titleField, 'Pixel 8a Full Test'); + await tester.pumpAndSettle(); + + expect(find.text('Pixel 8a Full Test'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + }); +}