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

427 lines
14 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;
/// Validates route stops invariants.
///
/// Note on stop validation:
/// Client-side and domain validation strictly enforces unique stop IDs and sequential 0..N-1 ordering.
/// Firestore Security Rules validate stop schema, bounding, and technical capacity (<= 25),
/// but exhaustive O(N^2) duplicate-ID comparison is impractical in Firestore Rules for embedded arrays.
static void validateStops(List<RouteStop> stops) {
if (stops.length > maxTechnicalStops) {
throw ArgumentError(
'Route cannot exceed $maxTechnicalStops stops.',
);
}
final seenIds = <String>{};
for (int i = 0; i < stops.length; i++) {
final s = stops[i];
if (s.id.trim().isEmpty) {
throw ArgumentError('Route stop at index $i is missing an ID.');
}
if (s.id.length > 64) {
throw ArgumentError('Route stop ID exceeds 64 characters.');
}
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.');
}
if (s.displayName.length > 80) {
throw ArgumentError('Stop name cannot exceed 80 characters.');
}
if (s.address.length > 200) {
throw ArgumentError('Stop address cannot exceed 200 characters.');
}
if (s.notes.length > 500) {
throw ArgumentError('Stop notes cannot exceed 500 characters.');
}
}
}
/// Saves or creates a route in persistent cloud storage.
///
/// Fails explicitly if persistence is unavailable.
Future<MotorSignalRoute> saveRoute({
String? id,
required String title,
String description = '',
required String ownerUid,
String ownerHandle = '',
RouteVisibility visibility = RouteVisibility.private,
required List<RouteStop> stops,
String? attachedGroupRideId,
DateTime? createdAt,
}) async {
final firestore = _firestore;
if (firestore == null) {
throw StateError(
'Route persistence unavailable: cloud database service is not configured.',
);
}
if (ownerUid.trim().isEmpty) {
throw StateError('Sign-in required to save routes.');
}
final cleanTitle = title.trim();
if (cleanTitle.length < 3 || cleanTitle.length > 80) {
throw ArgumentError('Route title must be between 3 and 80 characters.');
}
if (visibility == RouteVisibility.groupOnly &&
(attachedGroupRideId == null || attachedGroupRideId.isEmpty)) {
throw ArgumentError(
'Group-only route visibility requires an attached group ride ID.',
);
}
validateStops(stops);
final now = DateTime.now();
final effectiveCreatedAt = createdAt ?? now;
final effectiveUpdatedAt = 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: effectiveCreatedAt,
updatedAt: effectiveUpdatedAt,
);
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,
String? attachedGroupRideId,
}) async {
return saveRoute(
ownerUid: ownerUid,
ownerHandle: ownerHandle,
title: title,
description: description,
visibility: visibility,
stops: stops,
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, preserving original creation provenance.
Future<MotorSignalRoute> updateRoute({
required MotorSignalRoute route,
required String callerUid,
}) async {
if (callerUid.trim().isEmpty) {
throw StateError('Caller identity required to update route.');
}
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: route.ownerUid.isNotEmpty ? route.ownerUid : callerUid,
ownerHandle: route.ownerHandle,
visibility: route.visibility,
stops: route.stops,
attachedGroupRideId: route.attachedGroupRideId,
createdAt: route.createdAt,
);
}
/// Deletes a route safely and atomically.
///
/// Caller must be the route owner. If the route is attached to a Group Ride:
/// - If caller is also the Group Ride organizer, clears attachment and deletes atomically.
/// - If caller is not the organizer, refuses deletion and requires organizer detach first.
Future<void> deleteRoute(
dynamic routeIdOrNamed, {
String? routeId,
required String callerUid,
}) async {
if (callerUid.trim().isEmpty) {
throw StateError('Caller identity required to delete route.');
}
final effectiveRouteId =
routeIdOrNamed is String ? routeIdOrNamed : (routeId ?? '');
if (effectiveRouteId.isEmpty) return;
final firestore = _firestore;
if (firestore == null) {
throw StateError(
'Route persistence unavailable: cloud database service is not configured.',
);
}
final docRef = firestore.collection('routes').doc(effectiveRouteId);
final snapshot = await docRef.get();
if (!snapshot.exists || snapshot.data() == null) return;
final data = snapshot.data()!;
if (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 && rideSnap.data() != null) {
final rideData = rideSnap.data()!;
if (rideData['organizerUid'] != callerUid) {
throw StateError(
'Cannot delete a route attached to a Group Ride you do not organize. Organizer must detach the route first.',
);
}
final batch = firestore.batch();
batch.update(rideRef, {
'routeId': FieldValue.delete(),
'routeTitle': FieldValue.delete(),
'routeDistanceMeters': FieldValue.delete(),
'routeStopCount': FieldValue.delete(),
'updatedAt': DateTime.now().toIso8601String(),
});
batch.delete(docRef);
await batch.commit();
return;
}
}
await docRef.delete();
}
/// Attaches a saved route to a Group Ride.
///
/// Caller must be the Group Ride organizer AND own the route being attached.
Future<void> attachRouteToGroupRide({
String? routeId,
MotorSignalRoute? route,
required String groupRideId,
required String callerUid,
}) async {
if (callerUid.trim().isEmpty) {
throw StateError('Caller identity required to attach route.');
}
final firestore = _firestore;
if (firestore == null) {
throw StateError(
'Route persistence unavailable: cloud database service is not configured.',
);
}
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 (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.');
}
if (effectiveRoute.ownerUid != callerUid) {
throw StateError('Organizer can only attach a route they own.');
}
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.
///
/// Caller must be the Group Ride organizer.
/// Detaching clears the Group Ride route metadata and restores route visibility to private.
Future<void> detachRouteFromGroupRide(
String groupRideId, {
required String callerUid,
}) async {
if (callerUid.trim().isEmpty) {
throw StateError('Caller identity required to detach route.');
}
final firestore = _firestore;
if (firestore == null) {
throw StateError(
'Route persistence unavailable: cloud database service is not configured.',
);
}
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 (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(),
'visibility': RouteVisibility.private.name,
'updatedAt': now.toIso8601String(),
});
}
}
await batch.commit();
}
}