597 lines
19 KiB
Dart
597 lines
19 KiB
Dart
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<AppLatLng?>((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<RouteService>((ref) {
|
|
return RouteService();
|
|
});
|
|
|
|
/// Provider for AwsLocationRouteProvider.
|
|
final awsLocationRouteProviderInstance =
|
|
Provider<AwsLocationRouteProvider>((ref) {
|
|
return AwsLocationRouteProvider();
|
|
});
|
|
|
|
/// Active RouteProvider implementation backed by Amazon Location Service.
|
|
final activeRouteCalculationProvider = Provider<RouteProvider>((ref) {
|
|
return ref.watch(awsLocationRouteProviderInstance);
|
|
});
|
|
|
|
|
|
/// Provider for NavigationHandoffService.
|
|
final navigationHandoffServiceProvider =
|
|
Provider<NavigationHandoffService>((ref) {
|
|
return const NavigationHandoffService();
|
|
});
|
|
|
|
/// Realtime stream provider for a single route by ID.
|
|
final routeByIdProvider =
|
|
StreamProvider.family<MotorSignalRoute?, String>((ref, routeId) {
|
|
final service = ref.watch(routeServiceProvider);
|
|
return service.routeStream(routeId);
|
|
});
|
|
|
|
/// Realtime stream provider for routes owned by a user.
|
|
final userRoutesProvider =
|
|
StreamProvider.family<List<MotorSignalRoute>, 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<List<MotorSignalRoute>>((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<RouteStop> stops;
|
|
final double straightLineDistanceMeters;
|
|
final List<AppLatLng> 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<AppLatLng> get previewPoints => stops.map((s) => s.latLng).toList();
|
|
|
|
RoutePlannerState copyWith({
|
|
String? routeId,
|
|
String? title,
|
|
String? description,
|
|
RouteVisibility? visibility,
|
|
List<RouteStop>? stops,
|
|
double? straightLineDistanceMeters,
|
|
List<AppLatLng>? 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<RoutePlannerState> {
|
|
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<RouteStop>.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 = <RouteStop>[];
|
|
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<RouteStop>.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<RouteStop>.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 = <RouteStop>[];
|
|
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<RouteStop>.from(state.stops);
|
|
if (newIndex > oldIndex) newIndex -= 1;
|
|
final item = updatedStops.removeAt(oldIndex);
|
|
updatedStops.insert(newIndex, item);
|
|
|
|
final reindexed = <RouteStop>[];
|
|
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 = <RouteStop>[];
|
|
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<void> recalculateRoute() => _calculateRoadRoute();
|
|
|
|
Future<void> _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<MotorSignalRoute> 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<RoutePlannerNotifier, RoutePlannerState>((ref) {
|
|
return RoutePlannerNotifier(ref);
|
|
});
|
|
|
|
/// Backward-compatible family alias.
|
|
final routePlannerProvider = StateNotifierProvider.autoDispose
|
|
.family<RoutePlannerNotifier, RoutePlannerState, MotorSignalRoute?>(
|
|
(ref, initialRoute) => RoutePlannerNotifier(ref, initialRoute),
|
|
);
|