import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; import '../auth/auth_providers.dart'; import '../map/map_provider_interface.dart'; import 'aws_location_route_provider.dart'; import 'navigation_handoff.dart'; import 'route_models.dart'; import 'route_provider.dart'; import 'route_service.dart'; /// One cached location read per planner visit; never starts GPS or asks permission. final routePlannerLocationProvider = FutureProvider.autoDispose((ref) async { try { final position = await Geolocator.getLastKnownPosition(); if (position == null || !RoutePlannerNotifier.isValidCoordinate(position.latitude, position.longitude)) { return null; } return AppLatLng(position.latitude, position.longitude); } catch (_) { return null; } }); /// Provider for RouteService. final routeServiceProvider = Provider((ref) { return RouteService(); }); /// Provider for AwsLocationRouteProvider. final awsLocationRouteProviderInstance = Provider((ref) { return AwsLocationRouteProvider(); }); /// Active RouteProvider implementation backed by Amazon Location Service. final activeRouteCalculationProvider = Provider((ref) { return ref.watch(awsLocationRouteProviderInstance); }); /// Provider for NavigationHandoffService. final navigationHandoffServiceProvider = Provider((ref) { return const NavigationHandoffService(); }); /// Realtime stream provider for a single route by ID. final routeByIdProvider = StreamProvider.family((ref, routeId) { final service = ref.watch(routeServiceProvider); return service.routeStream(routeId); }); /// Realtime stream provider for routes owned by a user. final userRoutesProvider = StreamProvider.family, String>((ref, uid) { final service = ref.watch(routeServiceProvider); return service.userRoutesStream(uid); }); /// Alias for routeByIdProvider. final routeStreamProvider = routeByIdProvider; /// Realtime stream of routes created by current user. final myRoutesStreamProvider = StreamProvider>((ref) { final user = ref.watch(currentUserProvider); if (user == null || user.uid.isEmpty) { return Stream.value(const []); } final service = ref.watch(routeServiceProvider); return service.userRoutesStream(user.uid); }); /// State of an active route planning session. class RoutePlannerState { final String? routeId; final String title; final String description; 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; final DateTime? createdAt; const RoutePlannerState({ this.routeId, this.title = '', this.description = '', 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, this.createdAt, }); double get distanceMiles => straightLineDistanceMeters * 0.000621371; double? get roadDistanceMiles => estimatedDistanceMeters != null ? estimatedDistanceMeters! * 0.000621371 : null; 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 => estimatedDistanceMeters != null ? '${(estimatedDistanceMeters! / 1000).toStringAsFixed(1)} km' : '${(straightLineDistanceMeters / 1000).toStringAsFixed(1)} km'; List get previewPoints => stops.map((s) => s.latLng).toList(); RoutePlannerState copyWith({ String? routeId, String? title, String? description, 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, }) { return RoutePlannerState( routeId: routeId ?? this.routeId, title: title ?? this.title, description: description ?? this.description, visibility: visibility ?? this.visibility, 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: clearErrorMessage ? null : (errorMessage ?? this.errorMessage), isDirty: isDirty ?? this.isDirty, createdAt: createdAt ?? this.createdAt, ); } } /// Controller managing in-memory route editing state. class RoutePlannerNotifier extends StateNotifier { final Ref ref; int _calculationSequence = 0; RoutePlannerNotifier(this.ref, [MotorSignalRoute? initialRoute]) : super( initialRoute != null ? RoutePlannerState( routeId: initialRoute.id, title: initialRoute.title, description: initialRoute.description, visibility: initialRoute.visibility, stops: initialRoute.orderedStops, straightLineDistanceMeters: initialRoute.straightLineDistanceMeters, 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, description: route.description, visibility: route.visibility, stops: route.orderedStops, straightLineDistanceMeters: route.straightLineDistanceMeters, createdAt: route.createdAt, isDirty: false, ); if (route.orderedStops.length >= 2) { _calculateRoadRoute(); } } void reset() { _calculationSequence++; state = const RoutePlannerState(); } void setTitle(String title) { state = state.copyWith(title: title, isDirty: true); } void setDescription(String desc) { state = state.copyWith(description: desc, isDirty: true); } void setVisibility(RouteVisibility visibility) { state = state.copyWith(visibility: visibility, isDirty: true); } void addStop({ RouteStop? stop, String? displayName, double? latitude, double? longitude, 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 newStop = stop != null ? stop.copyWith( latitude: effectiveLat!, longitude: effectiveLng!, type: effectiveType, stopType: effectiveType, ) : RouteStop( 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 ?? '', ); // 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); } } // 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: reindexed, straightLineDistanceMeters: distance, geometry: const [], // Invalidate stale geometry immediately! clearEstimatedDistance: true, clearEstimatedDuration: true, isCalculatingRoute: reindexed.length >= 2, clearRouteCalculationError: true, isDirty: true, 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) { 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; targetIdx = target; updated = maybeStop; } else { return; } 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, clearErrorMessage: true, ); if (updatedStops.length >= 2) { _calculateRoadRoute(); } } void removeStop(dynamic target) { final updatedStops = List.from(state.stops); if (target is int) { if (target < 0 || target >= updatedStops.length) return; updatedStops.removeAt(target); } else if (target is String) { updatedStops.removeWhere((s) => s.id == target); } else { return; } 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: reindexed, straightLineDistanceMeters: distance, geometry: const [], // Invalidate stale geometry clearEstimatedDistance: true, clearEstimatedDuration: true, isCalculatingRoute: reindexed.length >= 2, clearRouteCalculationError: true, isDirty: true, clearErrorMessage: true, ); if (reindexed.length >= 2) { _calculateRoadRoute(); } } void reorderStops(int oldIndex, int newIndex) { if (oldIndex < 0 || oldIndex >= state.stops.length) return; if (newIndex < 0 || newIndex > state.stops.length) return; final updatedStops = List.from(state.stops); if (newIndex > oldIndex) newIndex -= 1; final item = updatedStops.removeAt(oldIndex); updatedStops.insert(newIndex, item); 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: reindexed, straightLineDistanceMeters: distance, geometry: const [], // Invalidate stale geometry clearEstimatedDistance: true, clearEstimatedDuration: true, isCalculatingRoute: reindexed.length >= 2, clearRouteCalculationError: true, isDirty: true, clearErrorMessage: true, ); if (reindexed.length >= 2) { _calculateRoadRoute(); } } void reverseStops() { if (state.stops.length < 2) return; final reversed = state.stops.reversed.toList(); final reindexed = []; for (int i = 0; i < reversed.length; i++) { reindexed.add(reversed[i].copyWith(order: i, orderIndex: i)); } final distance = MotorSignalRoute.calculateTotalDistanceMeters(reindexed); state = state.copyWith( stops: reindexed, straightLineDistanceMeters: distance, geometry: const [], // Invalidate stale geometry clearEstimatedDistance: true, clearEstimatedDuration: true, isCalculatingRoute: reindexed.length >= 2, clearRouteCalculationError: true, isDirty: true, 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({ String? title, String? description, String? ownerUid, RouteVisibility? visibility, String? attachedGroupRideId, }) async { final effectiveTitle = (title ?? state.title).trim(); if (effectiveTitle.length < 3) { throw ArgumentError('Route title must be at least 3 characters.'); } if (state.stops.isEmpty) { throw ArgumentError('Please add at least one stop to the route.'); } final user = ref.read(currentUserProvider); final effectiveUid = ownerUid ?? user?.uid ?? ''; if (effectiveUid.isEmpty) { throw StateError('Sign-in required to save routes.'); } final service = ref.read(routeServiceProvider); state = state.copyWith(isSaving: true, errorMessage: null); try { final saved = await service.saveRoute( id: state.routeId, title: effectiveTitle, description: (description ?? state.description).trim(), ownerUid: effectiveUid, ownerHandle: user?.email?.split('@').first ?? 'rider', visibility: visibility ?? state.visibility, stops: state.stops, attachedGroupRideId: attachedGroupRideId, createdAt: state.createdAt, ); state = state.copyWith( routeId: saved.id, isSaving: false, isDirty: false, ); return saved; } catch (e) { state = state.copyWith( isSaving: false, errorMessage: 'Failed to save route: $e', ); rethrow; } } } /// Global notifier provider for route planner state. final routePlannerNotifierProvider = StateNotifierProvider((ref) { return RoutePlannerNotifier(ref); }); /// Backward-compatible family alias. final routePlannerProvider = StateNotifierProvider.autoDispose .family( (ref, initialRoute) => RoutePlannerNotifier(ref, initialRoute), );