356 lines
11 KiB
Dart
356 lines
11 KiB
Dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import '../auth/auth_providers.dart';
|
|
import '../entitlements/entitlement_providers.dart';
|
|
import '../map/map_provider_interface.dart';
|
|
import 'navigation_handoff.dart';
|
|
import 'route_models.dart';
|
|
import 'route_provider.dart';
|
|
import 'route_service.dart';
|
|
|
|
/// Provider for RouteService.
|
|
final routeServiceProvider = Provider<RouteService>((ref) {
|
|
return RouteService();
|
|
});
|
|
|
|
/// Active RouteProvider implementation (defaults to honest straight-line preview).
|
|
final activeRouteCalculationProvider = Provider<RouteProvider>((ref) {
|
|
return const StraightLineRouteProvider();
|
|
});
|
|
|
|
/// 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 bool isSaving;
|
|
final String? errorMessage;
|
|
final bool isDirty;
|
|
|
|
const RoutePlannerState({
|
|
this.routeId,
|
|
this.title = '',
|
|
this.description = '',
|
|
this.visibility = RouteVisibility.private,
|
|
this.stops = const [],
|
|
this.straightLineDistanceMeters = 0.0,
|
|
this.isSaving = false,
|
|
this.errorMessage,
|
|
this.isDirty = false,
|
|
});
|
|
|
|
double get distanceMiles => straightLineDistanceMeters * 0.000621371;
|
|
|
|
String get formattedDistance =>
|
|
'${distanceMiles.toStringAsFixed(1)} mi (Straight-line)';
|
|
|
|
String get formattedDistanceMiles => '${distanceMiles.toStringAsFixed(1)} mi';
|
|
|
|
String get formattedDistanceKm =>
|
|
'${(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,
|
|
bool? isSaving,
|
|
String? errorMessage,
|
|
bool? isDirty,
|
|
}) {
|
|
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,
|
|
isSaving: isSaving ?? this.isSaving,
|
|
errorMessage: errorMessage,
|
|
isDirty: isDirty ?? this.isDirty,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Controller managing in-memory route editing state.
|
|
class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
|
|
final Ref ref;
|
|
|
|
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,
|
|
)
|
|
: const RoutePlannerState(),
|
|
);
|
|
|
|
void loadRoute(MotorSignalRoute route) {
|
|
state = RoutePlannerState(
|
|
routeId: route.id,
|
|
title: route.title,
|
|
description: route.description,
|
|
visibility: route.visibility,
|
|
stops: route.orderedStops,
|
|
straightLineDistanceMeters: route.straightLineDistanceMeters,
|
|
isDirty: false,
|
|
);
|
|
}
|
|
|
|
void reset() {
|
|
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 updatedStops = List<RouteStop>.from(state.stops);
|
|
final effectiveOrder = updatedStops.length;
|
|
|
|
final newStop = stop != null
|
|
? stop.copyWith(order: effectiveOrder, orderIndex: effectiveOrder)
|
|
: 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,
|
|
notes: notes ?? '',
|
|
);
|
|
|
|
updatedStops.add(newStop);
|
|
|
|
final distance =
|
|
MotorSignalRoute.calculateTotalDistanceMeters(updatedStops);
|
|
state = state.copyWith(
|
|
stops: updatedStops,
|
|
straightLineDistanceMeters: distance,
|
|
isDirty: true,
|
|
errorMessage: null,
|
|
);
|
|
}
|
|
|
|
void updateStop(dynamic target, [RouteStop? maybeStop]) {
|
|
final updatedStops = List<RouteStop>.from(state.stops);
|
|
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);
|
|
} else if (target is int && maybeStop != null) {
|
|
if (target < 0 || target >= updatedStops.length) return;
|
|
updatedStops[target] = maybeStop.copyWith(order: target, orderIndex: target);
|
|
} else {
|
|
return;
|
|
}
|
|
|
|
final distance =
|
|
MotorSignalRoute.calculateTotalDistanceMeters(updatedStops);
|
|
state = state.copyWith(
|
|
stops: updatedStops,
|
|
straightLineDistanceMeters: distance,
|
|
isDirty: true,
|
|
errorMessage: null,
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Re-index remaining 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,
|
|
isDirty: true,
|
|
errorMessage: null,
|
|
);
|
|
}
|
|
|
|
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,
|
|
isDirty: true,
|
|
errorMessage: null,
|
|
);
|
|
}
|
|
|
|
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,
|
|
isDirty: true,
|
|
errorMessage: null,
|
|
);
|
|
}
|
|
|
|
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 isPro = ref.read(isProSubscriberProvider);
|
|
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,
|
|
isPro: isPro,
|
|
attachedGroupRideId: attachedGroupRideId,
|
|
);
|
|
|
|
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),
|
|
);
|