MotorSignal-App/lib/services/routes/route_providers.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),
);