MotorSignal-App/lib/services/routes/route_providers.dart

360 lines
11 KiB
Dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_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;
final DateTime? createdAt;
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,
this.createdAt,
});
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,
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,
isSaving: isSaving ?? this.isSaving,
errorMessage: errorMessage,
isDirty: isDirty ?? this.isDirty,
createdAt: createdAt ?? this.createdAt,
);
}
}
/// 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,
createdAt: initialRoute.createdAt,
)
: 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,
createdAt: route.createdAt,
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 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),
);