361 lines
11 KiB
Dart
361 lines
11 KiB
Dart
import 'dart:async';
|
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
import 'package:firebase_core/firebase_core.dart';
|
|
import 'route_models.dart';
|
|
|
|
/// Service managing route creation, persistence, reordering, retrieval, and attachment.
|
|
class RouteService {
|
|
final FirebaseFirestore? _firestore;
|
|
|
|
RouteService({FirebaseFirestore? firestore})
|
|
: _firestore = firestore ?? _resolveFirestore();
|
|
|
|
static FirebaseFirestore? _resolveFirestore() {
|
|
try {
|
|
if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance;
|
|
} catch (_) {}
|
|
return null;
|
|
}
|
|
|
|
bool get isAvailable => _firestore != null;
|
|
|
|
/// Technical safety bound for maximum stops in a single route.
|
|
static const int maxTechnicalStops = 25;
|
|
|
|
/// Free tier recommended stop cap.
|
|
static const int freeStopCap = 8;
|
|
|
|
/// Validates route stops invariants.
|
|
static void validateStops(List<RouteStop> stops, {bool isPro = false}) {
|
|
if (stops.length > maxTechnicalStops) {
|
|
throw ArgumentError(
|
|
'Route cannot exceed $maxTechnicalStops stops.',
|
|
);
|
|
}
|
|
if (!isPro && stops.length > freeStopCap) {
|
|
throw ArgumentError(
|
|
'Free tier routes cannot exceed $freeStopCap stops. Upgrade to Pro for up to $maxTechnicalStops stops.',
|
|
);
|
|
}
|
|
|
|
final seenIds = <String>{};
|
|
for (int i = 0; i < stops.length; i++) {
|
|
final s = stops[i];
|
|
if (s.id.isEmpty) {
|
|
throw ArgumentError('Route stop at index $i is missing an ID.');
|
|
}
|
|
if (seenIds.contains(s.id)) {
|
|
throw ArgumentError('Duplicate route stop ID: ${s.id}');
|
|
}
|
|
seenIds.add(s.id);
|
|
|
|
if (s.latitude.isNaN || s.longitude.isNaN) {
|
|
throw ArgumentError('Route stop contains invalid coordinates (NaN).');
|
|
}
|
|
if (s.latitude < -90.0 || s.latitude > 90.0) {
|
|
throw ArgumentError(
|
|
'Route stop contains invalid coordinates: latitude ${s.latitude} out of bounds.',
|
|
);
|
|
}
|
|
if (s.longitude < -180.0 || s.longitude > 180.0) {
|
|
throw ArgumentError(
|
|
'Route stop contains invalid coordinates: longitude ${s.longitude} out of bounds.',
|
|
);
|
|
}
|
|
if (s.displayName.trim().isEmpty) {
|
|
throw ArgumentError('Stop name cannot be empty.');
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Saves or creates a route (offline-safe when Firestore is unconfigured).
|
|
Future<MotorSignalRoute> saveRoute({
|
|
String? id,
|
|
required String title,
|
|
String description = '',
|
|
required String ownerUid,
|
|
String ownerHandle = '',
|
|
RouteVisibility visibility = RouteVisibility.private,
|
|
required List<RouteStop> stops,
|
|
bool isPro = false,
|
|
String? attachedGroupRideId,
|
|
}) async {
|
|
final cleanTitle = title.trim();
|
|
if (cleanTitle.length < 3 || cleanTitle.length > 80) {
|
|
throw ArgumentError('Route title must be between 3 and 80 characters.');
|
|
}
|
|
|
|
validateStops(stops, isPro: isPro);
|
|
|
|
final now = DateTime.now();
|
|
final routeId = id ??
|
|
'route_${now.millisecondsSinceEpoch}_${ownerUid.substring(0, ownerUid.length.clamp(0, 6))}';
|
|
|
|
// Re-index stops 0..N-1
|
|
final sortedStops = List<RouteStop>.from(stops);
|
|
sortedStops.sort((a, b) => a.order.compareTo(b.order));
|
|
final normalizedStops = <RouteStop>[];
|
|
for (int i = 0; i < sortedStops.length; i++) {
|
|
normalizedStops.add(sortedStops[i].copyWith(order: i, orderIndex: i));
|
|
}
|
|
|
|
final totalMeters =
|
|
MotorSignalRoute.calculateTotalDistanceMeters(normalizedStops);
|
|
|
|
final route = MotorSignalRoute(
|
|
id: routeId,
|
|
ownerUid: ownerUid,
|
|
ownerHandle: ownerHandle,
|
|
title: cleanTitle,
|
|
description: description.trim(),
|
|
visibility: visibility,
|
|
stops: normalizedStops,
|
|
straightLineDistanceMeters: totalMeters,
|
|
source: RouteSource.planner,
|
|
version: 1,
|
|
attachedGroupRideId: attachedGroupRideId,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
);
|
|
|
|
final firestore = _firestore;
|
|
if (firestore != null) {
|
|
await firestore.collection('routes').doc(routeId).set(route.toJson());
|
|
}
|
|
|
|
return route;
|
|
}
|
|
|
|
/// Legacy alias for saveRoute.
|
|
Future<MotorSignalRoute> createRoute({
|
|
required String ownerUid,
|
|
required String ownerHandle,
|
|
required String title,
|
|
String description = '',
|
|
RouteVisibility visibility = RouteVisibility.private,
|
|
required List<RouteStop> stops,
|
|
bool isPro = false,
|
|
String? attachedGroupRideId,
|
|
}) async {
|
|
return saveRoute(
|
|
ownerUid: ownerUid,
|
|
ownerHandle: ownerHandle,
|
|
title: title,
|
|
description: description,
|
|
visibility: visibility,
|
|
stops: stops,
|
|
isPro: isPro,
|
|
attachedGroupRideId: attachedGroupRideId,
|
|
);
|
|
}
|
|
|
|
/// Retrieves a route by ID.
|
|
Future<MotorSignalRoute?> getRoute(String routeId) async {
|
|
final firestore = _firestore;
|
|
if (firestore == null) return null;
|
|
|
|
final doc = await firestore.collection('routes').doc(routeId).get();
|
|
if (!doc.exists || doc.data() == null) return null;
|
|
|
|
return MotorSignalRoute.fromJson(doc.data()!);
|
|
}
|
|
|
|
/// Streams real-time updates for a single route.
|
|
Stream<MotorSignalRoute?> routeStream(String routeId) {
|
|
final firestore = _firestore;
|
|
if (firestore == null) return Stream.value(null);
|
|
|
|
return firestore.collection('routes').doc(routeId).snapshots().map((doc) {
|
|
if (!doc.exists || doc.data() == null) return null;
|
|
return MotorSignalRoute.fromJson(doc.data()!);
|
|
});
|
|
}
|
|
|
|
/// Streams saved routes owned by [ownerUid].
|
|
Stream<List<MotorSignalRoute>> userRoutesStream(String ownerUid) {
|
|
final firestore = _firestore;
|
|
if (firestore == null || ownerUid.isEmpty) return Stream.value(const []);
|
|
|
|
return firestore
|
|
.collection('routes')
|
|
.where('ownerUid', isEqualTo: ownerUid)
|
|
.snapshots()
|
|
.map((snapshot) {
|
|
final routes = snapshot.docs
|
|
.map((doc) => MotorSignalRoute.fromJson(doc.data()))
|
|
.toList();
|
|
routes.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
|
return routes;
|
|
});
|
|
}
|
|
|
|
/// Updates an existing route.
|
|
Future<MotorSignalRoute> updateRoute({
|
|
required MotorSignalRoute route,
|
|
required String callerUid,
|
|
bool isPro = false,
|
|
}) async {
|
|
if (route.ownerUid.isNotEmpty && route.ownerUid != callerUid) {
|
|
throw StateError('Only the route owner can update this route.');
|
|
}
|
|
|
|
return saveRoute(
|
|
id: route.id,
|
|
title: route.title,
|
|
description: route.description,
|
|
ownerUid: callerUid,
|
|
ownerHandle: route.ownerHandle,
|
|
visibility: route.visibility,
|
|
stops: route.stops,
|
|
isPro: isPro,
|
|
attachedGroupRideId: route.attachedGroupRideId,
|
|
);
|
|
}
|
|
|
|
/// Deletes a route safely.
|
|
///
|
|
/// If the route is attached to a Group Ride, it cleanly clears the
|
|
/// attachment reference without destructive cascades.
|
|
Future<void> deleteRoute(
|
|
dynamic routeIdOrNamed, {
|
|
String? routeId,
|
|
String? callerUid,
|
|
}) async {
|
|
final effectiveRouteId =
|
|
routeIdOrNamed is String ? routeIdOrNamed : (routeId ?? '');
|
|
if (effectiveRouteId.isEmpty) return;
|
|
|
|
final firestore = _firestore;
|
|
if (firestore == null) return;
|
|
|
|
final docRef = firestore.collection('routes').doc(effectiveRouteId);
|
|
final snapshot = await docRef.get();
|
|
if (!snapshot.exists || snapshot.data() == null) return;
|
|
|
|
final data = snapshot.data()!;
|
|
if (callerUid != null && data['ownerUid'] != callerUid) {
|
|
throw StateError('Only the route owner can delete this route.');
|
|
}
|
|
|
|
final attachedRideId = data['attachedGroupRideId'] as String?;
|
|
if (attachedRideId != null && attachedRideId.isNotEmpty) {
|
|
final rideRef = firestore.collection('group_rides').doc(attachedRideId);
|
|
final rideSnap = await rideRef.get();
|
|
if (rideSnap.exists) {
|
|
await rideRef.update({
|
|
'routeId': FieldValue.delete(),
|
|
'routeTitle': FieldValue.delete(),
|
|
'routeDistanceMeters': FieldValue.delete(),
|
|
'routeStopCount': FieldValue.delete(),
|
|
'updatedAt': DateTime.now().toIso8601String(),
|
|
});
|
|
}
|
|
}
|
|
|
|
await docRef.delete();
|
|
}
|
|
|
|
/// Attaches a saved route to a Group Ride.
|
|
///
|
|
/// Caller must be the Group Ride organizer.
|
|
Future<void> attachRouteToGroupRide({
|
|
String? routeId,
|
|
MotorSignalRoute? route,
|
|
required String groupRideId,
|
|
String? callerUid,
|
|
}) async {
|
|
final firestore = _firestore;
|
|
if (firestore == null) return;
|
|
|
|
final targetRouteId = route?.id ?? routeId;
|
|
if (targetRouteId == null || targetRouteId.isEmpty) {
|
|
throw ArgumentError('Route or routeId must be provided.');
|
|
}
|
|
|
|
final rideRef = firestore.collection('group_rides').doc(groupRideId);
|
|
final rideSnap = await rideRef.get();
|
|
if (!rideSnap.exists || rideSnap.data() == null) {
|
|
throw ArgumentError('Group Ride not found.');
|
|
}
|
|
|
|
final rideData = rideSnap.data()!;
|
|
if (callerUid != null &&
|
|
rideData['organizerUid'] != null &&
|
|
rideData['organizerUid'] != callerUid) {
|
|
throw StateError('Only the Group Ride organizer can attach a route.');
|
|
}
|
|
|
|
final effectiveRoute = route ?? await getRoute(targetRouteId);
|
|
if (effectiveRoute == null) {
|
|
throw ArgumentError('Route not found.');
|
|
}
|
|
|
|
final routeRef = firestore.collection('routes').doc(targetRouteId);
|
|
final now = DateTime.now();
|
|
final batch = firestore.batch();
|
|
|
|
// 1. Update Group Ride metadata
|
|
batch.update(rideRef, {
|
|
'routeId': effectiveRoute.id,
|
|
'routeTitle': effectiveRoute.title,
|
|
'routeDistanceMeters': effectiveRoute.straightLineDistanceMeters,
|
|
'routeStopCount': effectiveRoute.stops.length,
|
|
'updatedAt': now.toIso8601String(),
|
|
});
|
|
|
|
// 2. Update Route document attachment
|
|
batch.update(routeRef, {
|
|
'attachedGroupRideId': groupRideId,
|
|
'visibility': RouteVisibility.groupOnly.name,
|
|
'updatedAt': now.toIso8601String(),
|
|
});
|
|
|
|
await batch.commit();
|
|
}
|
|
|
|
/// Detaches an attached route from a Group Ride.
|
|
Future<void> detachRouteFromGroupRide(
|
|
String groupRideId, {
|
|
String? callerUid,
|
|
}) async {
|
|
final firestore = _firestore;
|
|
if (firestore == null) return;
|
|
|
|
final rideRef = firestore.collection('group_rides').doc(groupRideId);
|
|
final rideSnap = await rideRef.get();
|
|
if (!rideSnap.exists || rideSnap.data() == null) return;
|
|
|
|
final rideData = rideSnap.data()!;
|
|
if (callerUid != null &&
|
|
rideData['organizerUid'] != null &&
|
|
rideData['organizerUid'] != callerUid) {
|
|
throw StateError('Only the organizer can detach a route.');
|
|
}
|
|
|
|
final routeId = rideData['routeId'] as String?;
|
|
final now = DateTime.now();
|
|
|
|
final batch = firestore.batch();
|
|
batch.update(rideRef, {
|
|
'routeId': FieldValue.delete(),
|
|
'routeTitle': FieldValue.delete(),
|
|
'routeDistanceMeters': FieldValue.delete(),
|
|
'routeStopCount': FieldValue.delete(),
|
|
'updatedAt': now.toIso8601String(),
|
|
});
|
|
|
|
if (routeId != null && routeId.isNotEmpty) {
|
|
final routeRef = firestore.collection('routes').doc(routeId);
|
|
final routeSnap = await routeRef.get();
|
|
if (routeSnap.exists) {
|
|
batch.update(routeRef, {
|
|
'attachedGroupRideId': FieldValue.delete(),
|
|
'updatedAt': now.toIso8601String(),
|
|
});
|
|
}
|
|
}
|
|
|
|
await batch.commit();
|
|
}
|
|
}
|