MotorSignal-App/lib/services/routes/route_service.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();
}
}