import 'dart:async'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/foundation.dart'; import '../social/rider_profile_models.dart'; import 'group_ride_models.dart'; /// Service managing Group Rides, meetup locations, stops, invitations, join requests, and memberships. class GroupRideService { final FirebaseFirestore? _firestore; GroupRideService({FirebaseFirestore? firestore}) : _firestore = firestore ?? _resolveFirestore(); static FirebaseFirestore? _resolveFirestore() { try { if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance; } catch (_) {} return null; } bool get isAvailable => _firestore != null; /// Creates a new Group Ride and establishes the organizer participant record. Future createGroupRide({ required RiderProfile organizer, required String title, String description = '', GroupRideVisibility visibility = GroupRideVisibility.friendsOnly, GroupRideJoinPolicy joinPolicy = GroupRideJoinPolicy.inviteOnly, required DateTime scheduledStartAt, required DateTime departureAt, int? participantLimit, required GroupRideLocation meetingPoint, GroupRideLocation? destination, List stops = const [], List initialInviteUids = const [], String? routeId, String? routeTitle, double? routeDistanceMeters, int? routeStopCount, }) async { final cleanTitle = title.trim(); if (cleanTitle.length < 3) { throw ArgumentError('Ride title must be at least 3 characters.'); } if (cleanTitle.length > 60) { throw ArgumentError('Ride title cannot exceed 60 characters.'); } if (departureAt.isBefore(scheduledStartAt)) { throw ArgumentError('Departure time cannot be before arrival/meetup time.'); } if (participantLimit != null && participantLimit < 2) { throw ArgumentError('Participant limit must be at least 2 riders.'); } final now = DateTime.now(); final rideId = 'ride_${now.millisecondsSinceEpoch}_${organizer.uid.substring(0, organizer.uid.length.clamp(0, 6))}'; final groupRide = GroupRide( id: rideId, organizerUid: organizer.uid, organizerHandle: organizer.handle, organizerDisplayName: organizer.displayName, title: cleanTitle, description: description.trim(), visibility: visibility, joinPolicy: joinPolicy, status: GroupRideStatus.scheduled, scheduledStartAt: scheduledStartAt, departureAt: departureAt, participantLimit: participantLimit, participantCount: 1, meetingPoint: meetingPoint, destination: destination, stops: stops, routeId: routeId, routeTitle: routeTitle, routeDistanceMeters: routeDistanceMeters, routeStopCount: routeStopCount, createdAt: now, updatedAt: now, ); final organizerParticipant = GroupRideParticipant( userId: organizer.uid, role: GroupRideRole.organizer, status: ParticipantStatus.accepted, riderHandle: organizer.handle, riderDisplayName: organizer.displayName, riderAvatarPreset: organizer.avatarPreset, riderAvatarUrl: organizer.avatarUrl, createdAt: now, updatedAt: now, ); final firestore = _firestore; if (firestore == null) { // Offline fallback return groupRide; } final rideRef = firestore.collection('group_rides').doc(rideId); final participantRef = rideRef.collection('participants').doc(organizer.uid); final batch = firestore.batch(); batch.set(rideRef, groupRide.toJson()); batch.set(participantRef, organizerParticipant.toJson()); await batch.commit(); return groupRide; } /// Updates editable Group Ride details (organizer only). Future updateGroupRide({ required String callerUid, required String groupRideId, String? title, String? description, GroupRideVisibility? visibility, GroupRideJoinPolicy? joinPolicy, DateTime? scheduledStartAt, DateTime? departureAt, int? participantLimit, GroupRideLocation? meetingPoint, GroupRideLocation? destination, List? stops, }) async { final firestore = _firestore; if (firestore == null) return; final rideRef = firestore.collection('group_rides').doc(groupRideId); final snapshot = await rideRef.get(); if (!snapshot.exists || snapshot.data() == null) { throw StateError('Group ride not found.'); } final current = GroupRide.fromJson(snapshot.data()!); if (current.organizerUid != callerUid) { throw StateError('Only the organizer can modify this ride.'); } if (current.isCanceled || current.isCompleted) { throw StateError('Cannot modify a canceled or completed ride.'); } final newStart = scheduledStartAt ?? current.scheduledStartAt; final newDep = departureAt ?? current.departureAt; if (newDep.isBefore(newStart)) { throw ArgumentError('Departure time cannot be before arrival/meetup time.'); } final updates = { if (title != null && title.trim().isNotEmpty) 'title': title.trim(), if (description != null) 'description': description.trim(), if (visibility != null) 'visibility': visibility.name, if (joinPolicy != null) 'joinPolicy': joinPolicy.name, if (scheduledStartAt != null) 'scheduledStartAt': scheduledStartAt.toIso8601String(), if (departureAt != null) 'departureAt': departureAt.toIso8601String(), if (participantLimit != null) 'participantLimit': participantLimit, if (meetingPoint != null) 'meetingPoint': meetingPoint.toJson(), if (destination != null) 'destination': destination.toJson(), if (stops != null) 'stops': stops.map((s) => s.toJson()).toList(), 'updatedAt': DateTime.now().toIso8601String(), }; await rideRef.update(updates); } /// Cancels a Group Ride with an optional reason (organizer only). Future cancelGroupRide({ required String callerUid, required String groupRideId, String? reason, }) async { final firestore = _firestore; if (firestore == null) return; final rideRef = firestore.collection('group_rides').doc(groupRideId); final snapshot = await rideRef.get(); if (!snapshot.exists || snapshot.data() == null) { throw StateError('Group ride not found.'); } final current = GroupRide.fromJson(snapshot.data()!); if (current.organizerUid != callerUid) { throw StateError('Only the organizer can cancel this ride.'); } if (current.isCanceled) return; final now = DateTime.now(); await rideRef.update({ 'status': GroupRideStatus.canceled.name, 'canceledAt': now.toIso8601String(), if (reason != null && reason.trim().isNotEmpty) 'canceledReason': reason.trim(), 'updatedAt': now.toIso8601String(), }); } /// Invites a friend to a Group Ride (organizer only). Future inviteFriend({ required String callerUid, required String groupRideId, required RiderProfile friendProfile, }) async { if (callerUid == friendProfile.uid) { throw ArgumentError('Cannot invite yourself.'); } 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) { throw StateError('Group ride not found.'); } final currentRide = GroupRide.fromJson(rideSnap.data()!); if (currentRide.organizerUid != callerUid) { throw StateError('Only the organizer can send invitations.'); } if (currentRide.isCanceled || currentRide.isCompleted) { throw StateError('Cannot invite riders to a canceled or completed ride.'); } // Check mutual block final callerBlock = await firestore .collection('users') .doc(callerUid) .collection('blocks') .doc(friendProfile.uid) .get(); if (callerBlock.exists) { throw StateError('Cannot invite a blocked rider.'); } final targetBlock = await firestore .collection('users') .doc(friendProfile.uid) .collection('blocks') .doc(callerUid) .get(); if (targetBlock.exists) { throw StateError('Cannot invite this rider.'); } final participantRef = rideRef.collection('participants').doc(friendProfile.uid); final participantSnap = await participantRef.get(); if (participantSnap.exists && participantSnap.data() != null) { final p = GroupRideParticipant.fromJson(participantSnap.data()!); if (p.status == ParticipantStatus.accepted) { throw StateError('This rider is already a participant.'); } if (p.status == ParticipantStatus.invited) { return; // Idempotent } } final now = DateTime.now(); final invite = GroupRideParticipant( userId: friendProfile.uid, role: GroupRideRole.participant, status: ParticipantStatus.invited, riderHandle: friendProfile.handle, riderDisplayName: friendProfile.displayName, riderAvatarPreset: friendProfile.avatarPreset, riderAvatarUrl: friendProfile.avatarUrl, invitedBy: callerUid, createdAt: now, updatedAt: now, ); await participantRef.set(invite.toJson()); } /// Responds to a Group Ride invitation (Invitee only). Future respondToInvitation({ required String callerUid, required String groupRideId, required bool accept, }) async { final firestore = _firestore; if (firestore == null) return; final rideRef = firestore.collection('group_rides').doc(groupRideId); final participantRef = rideRef.collection('participants').doc(callerUid); await firestore.runTransaction((transaction) async { final rideDoc = await transaction.get(rideRef); if (!rideDoc.exists || rideDoc.data() == null) { throw StateError('Group ride not found.'); } final ride = GroupRide.fromJson(rideDoc.data()!); if (ride.isCanceled || ride.isCompleted) { throw StateError('Cannot join a canceled or completed ride.'); } final pDoc = await transaction.get(participantRef); if (!pDoc.exists || pDoc.data() == null) { throw StateError('Invitation not found.'); } final participant = GroupRideParticipant.fromJson(pDoc.data()!); if (participant.status != ParticipantStatus.invited) { throw StateError('Invalid invitation status: ${participant.status.name}'); } final now = DateTime.now(); if (accept) { if (ride.isFull) { throw StateError('This group ride has reached capacity.'); } transaction.update(participantRef, { 'status': ParticipantStatus.accepted.name, 'updatedAt': now.toIso8601String(), }); transaction.update(rideRef, { 'participantCount': FieldValue.increment(1), 'updatedAt': now.toIso8601String(), }); } else { transaction.update(participantRef, { 'status': ParticipantStatus.declined.name, 'updatedAt': now.toIso8601String(), }); } }); } /// Requests to join a Group Ride with requestToJoin policy. Future requestToJoin({ required RiderProfile caller, required String groupRideId, }) 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) { throw StateError('Group ride not found.'); } final ride = GroupRide.fromJson(rideSnap.data()!); if (ride.organizerUid == caller.uid) { throw ArgumentError('You are the organizer of this ride.'); } if (ride.isCanceled || ride.isCompleted) { throw StateError('Cannot request to join a canceled or completed ride.'); } if (ride.joinPolicy != GroupRideJoinPolicy.requestToJoin) { throw StateError('This ride does not accept join requests.'); } if (ride.isFull) { throw StateError('This ride is currently full.'); } // Check mutual blocks final callerBlock = await firestore .collection('users') .doc(caller.uid) .collection('blocks') .doc(ride.organizerUid) .get(); if (callerBlock.exists) throw StateError('Unable to request to join.'); final orgBlock = await firestore .collection('users') .doc(ride.organizerUid) .collection('blocks') .doc(caller.uid) .get(); if (orgBlock.exists) throw StateError('Unable to request to join.'); final participantRef = rideRef.collection('participants').doc(caller.uid); final now = DateTime.now(); final req = GroupRideParticipant( userId: caller.uid, role: GroupRideRole.participant, status: ParticipantStatus.requested, riderHandle: caller.handle, riderDisplayName: caller.displayName, riderAvatarPreset: caller.avatarPreset, riderAvatarUrl: caller.avatarUrl, createdAt: now, updatedAt: now, ); await participantRef.set(req.toJson()); } /// Responds to a join request (organizer only). Future respondToJoinRequest({ required String callerUid, required String groupRideId, required String requesterUid, required bool approve, }) async { final firestore = _firestore; if (firestore == null) return; final rideRef = firestore.collection('group_rides').doc(groupRideId); final participantRef = rideRef.collection('participants').doc(requesterUid); await firestore.runTransaction((transaction) async { final rideDoc = await transaction.get(rideRef); if (!rideDoc.exists || rideDoc.data() == null) { throw StateError('Group ride not found.'); } final ride = GroupRide.fromJson(rideDoc.data()!); if (ride.organizerUid != callerUid) { throw StateError('Only the organizer can approve requests.'); } if (ride.isCanceled || ride.isCompleted) { throw StateError('Ride is no longer active.'); } final pDoc = await transaction.get(participantRef); if (!pDoc.exists || pDoc.data() == null) { throw StateError('Request not found.'); } final participant = GroupRideParticipant.fromJson(pDoc.data()!); if (participant.status != ParticipantStatus.requested) { throw StateError('Participant is not in requested status.'); } final now = DateTime.now(); if (approve) { if (ride.isFull) { throw StateError('Ride has reached maximum capacity.'); } transaction.update(participantRef, { 'status': ParticipantStatus.accepted.name, 'updatedAt': now.toIso8601String(), }); transaction.update(rideRef, { 'participantCount': FieldValue.increment(1), 'updatedAt': now.toIso8601String(), }); } else { transaction.update(participantRef, { 'status': ParticipantStatus.declined.name, 'updatedAt': now.toIso8601String(), }); } }); } /// Directly joins an open Group Ride using transactional capacity checking. Future joinOpenRide({ required RiderProfile caller, required String groupRideId, }) async { final firestore = _firestore; if (firestore == null) return; final rideRef = firestore.collection('group_rides').doc(groupRideId); final participantRef = rideRef.collection('participants').doc(caller.uid); await firestore.runTransaction((transaction) async { final rideDoc = await transaction.get(rideRef); if (!rideDoc.exists || rideDoc.data() == null) { throw StateError('Group ride not found.'); } final ride = GroupRide.fromJson(rideDoc.data()!); if (ride.organizerUid == caller.uid) { throw ArgumentError('You are already the organizer.'); } if (ride.isCanceled || ride.isCompleted) { throw StateError('Cannot join a canceled or completed ride.'); } if (ride.joinPolicy != GroupRideJoinPolicy.open) { throw StateError('This ride is not open to direct join.'); } if (ride.isFull) { throw StateError('This ride is full.'); } final pDoc = await transaction.get(participantRef); if (pDoc.exists && pDoc.data() != null) { final existing = GroupRideParticipant.fromJson(pDoc.data()!); if (existing.status == ParticipantStatus.accepted) { return; // Already joined } } final now = DateTime.now(); final participant = GroupRideParticipant( userId: caller.uid, role: GroupRideRole.participant, status: ParticipantStatus.accepted, riderHandle: caller.handle, riderDisplayName: caller.displayName, riderAvatarPreset: caller.avatarPreset, riderAvatarUrl: caller.avatarUrl, createdAt: now, updatedAt: now, ); transaction.set(participantRef, participant.toJson()); transaction.update(rideRef, { 'participantCount': FieldValue.increment(1), 'updatedAt': now.toIso8601String(), }); }); } /// Leaves a Group Ride (non-organizer participant). /// If the organizer leaves and there are other active participants, role must be transferred first. Future leaveRide({ required String callerUid, required String groupRideId, }) async { final firestore = _firestore; if (firestore == null) return; final rideRef = firestore.collection('group_rides').doc(groupRideId); final participantRef = rideRef.collection('participants').doc(callerUid); await firestore.runTransaction((transaction) async { final rideDoc = await transaction.get(rideRef); if (!rideDoc.exists || rideDoc.data() == null) return; final ride = GroupRide.fromJson(rideDoc.data()!); final pDoc = await transaction.get(participantRef); if (!pDoc.exists || pDoc.data() == null) return; final participant = GroupRideParticipant.fromJson(pDoc.data()!); if (ride.organizerUid == callerUid) { if (ride.participantCount > 1) { throw StateError( 'Organizer must transfer leadership to another rider or cancel the ride before leaving.', ); } else { // Sole member is organizer: cancel ride automatically on departure transaction.update(rideRef, { 'status': GroupRideStatus.canceled.name, 'canceledReason': 'Organizer left the ride.', 'canceledAt': DateTime.now().toIso8601String(), 'updatedAt': DateTime.now().toIso8601String(), }); } } final wasAccepted = participant.status == ParticipantStatus.accepted; final now = DateTime.now(); transaction.update(participantRef, { 'status': ParticipantStatus.left.name, 'updatedAt': now.toIso8601String(), }); if (wasAccepted && ride.participantCount > 0) { transaction.update(rideRef, { 'participantCount': FieldValue.increment(-1), 'updatedAt': now.toIso8601String(), }); } }); } /// Removes a participant from a Group Ride (organizer only). Future removeParticipant({ required String callerUid, required String groupRideId, required String targetUid, }) async { if (callerUid == targetUid) { throw ArgumentError('Organizer cannot remove themselves. Use leaveRide.'); } final firestore = _firestore; if (firestore == null) return; final rideRef = firestore.collection('group_rides').doc(groupRideId); final participantRef = rideRef.collection('participants').doc(targetUid); await firestore.runTransaction((transaction) async { final rideDoc = await transaction.get(rideRef); if (!rideDoc.exists || rideDoc.data() == null) return; final ride = GroupRide.fromJson(rideDoc.data()!); if (ride.organizerUid != callerUid) { throw StateError('Only the organizer can remove participants.'); } final pDoc = await transaction.get(participantRef); if (!pDoc.exists || pDoc.data() == null) return; final participant = GroupRideParticipant.fromJson(pDoc.data()!); final wasAccepted = participant.status == ParticipantStatus.accepted; final now = DateTime.now(); transaction.update(participantRef, { 'status': ParticipantStatus.removed.name, 'updatedAt': now.toIso8601String(), }); if (wasAccepted && ride.participantCount > 0) { transaction.update(rideRef, { 'participantCount': FieldValue.increment(-1), 'updatedAt': now.toIso8601String(), }); } }); } /// Transfers the organizer role to another active participant (organizer only). Future transferOrganizer({ required String callerUid, required String groupRideId, required String newOrganizerUid, }) async { if (callerUid == newOrganizerUid) return; final firestore = _firestore; if (firestore == null) return; final rideRef = firestore.collection('group_rides').doc(groupRideId); final oldOrgRef = rideRef.collection('participants').doc(callerUid); final newOrgRef = rideRef.collection('participants').doc(newOrganizerUid); await firestore.runTransaction((transaction) async { final rideDoc = await transaction.get(rideRef); if (!rideDoc.exists || rideDoc.data() == null) { throw StateError('Group ride not found.'); } final ride = GroupRide.fromJson(rideDoc.data()!); if (ride.organizerUid != callerUid) { throw StateError('Only the current organizer can transfer leadership.'); } final newOrgDoc = await transaction.get(newOrgRef); if (!newOrgDoc.exists || newOrgDoc.data() == null) { throw StateError('New organizer is not a participant.'); } final newOrg = GroupRideParticipant.fromJson(newOrgDoc.data()!); if (newOrg.status != ParticipantStatus.accepted) { throw StateError('New organizer must be an accepted participant.'); } final now = DateTime.now(); // 1. Demote current organizer to participant transaction.update(oldOrgRef, { 'role': GroupRideRole.participant.name, 'updatedAt': now.toIso8601String(), }); // 2. Promote new organizer transaction.update(newOrgRef, { 'role': GroupRideRole.organizer.name, 'updatedAt': now.toIso8601String(), }); // 3. Update ride organizer metadata transaction.update(rideRef, { 'organizerUid': newOrg.userId, 'organizerHandle': newOrg.riderHandle, 'organizerDisplayName': newOrg.riderDisplayName, 'updatedAt': now.toIso8601String(), }); }); } /// Realtime stream of an individual Group Ride. Stream groupRideStream(String groupRideId) { final firestore = _firestore; if (firestore == null) return Stream.value(null); return firestore .collection('group_rides') .doc(groupRideId) .snapshots() .map((snap) { if (!snap.exists || snap.data() == null) return null; try { return GroupRide.fromJson(snap.data()!); } catch (e) { debugPrint('[GroupRideService] Parse error: $e'); return null; } }); } /// Realtime stream of participants in a Group Ride. Stream> participantsStream(String groupRideId) { final firestore = _firestore; if (firestore == null) return Stream.value([]); return firestore .collection('group_rides') .doc(groupRideId) .collection('participants') .snapshots() .map((snap) { return snap.docs .map((d) { try { return GroupRideParticipant.fromJson(d.data()); } catch (_) { return null; } }) .whereType() .toList(); }); } /// Updates the status of a Group Ride (e.g. scheduled -> assembling -> underway -> completed). /// Only the ride organizer has authority to transition ride state. Future updateRideStatus({ required String callerUid, required String groupRideId, required GroupRideStatus newStatus, }) async { final firestore = _firestore; if (firestore == null) return; final rideRef = firestore.collection('group_rides').doc(groupRideId); final snap = await rideRef.get(); if (!snap.exists || snap.data() == null) { throw StateError('Group ride not found.'); } final currentRide = GroupRide.fromJson(snap.data()!); if (currentRide.organizerUid != callerUid) { throw StateError('Only the organizer can change ride status.'); } if (currentRide.isCanceled || currentRide.isCompleted) { throw StateError('Cannot change status of a closed ride.'); } final now = DateTime.now(); await rideRef.update({ 'status': newStatus.name, 'updatedAt': now.toIso8601String(), }); } /// Publishes ephemeral live presence for an accepted participant during an eligible ride. /// Strictly isolates coordinates to the ride subcollection; never writes to user profiles. Future publishLivePresence({ required String callerUid, required String groupRideId, required double latitude, required double longitude, double? heading, double? speedMph, double? accuracyMeters, LivePresenceStatus status = LivePresenceStatus.riding, bool sharingEnabled = true, }) async { final now = DateTime.now(); final presence = LiveGroupPresence( userId: callerUid, groupRideId: groupRideId, latitude: latitude, longitude: longitude, heading: heading, speedMph: speedMph, accuracyMeters: accuracyMeters, updatedAt: now, expiresAt: now.add(const Duration(seconds: 120)), sharingEnabled: sharingEnabled, status: status, ); if (!presence.isValidCoordinates) { throw ArgumentError('Invalid physical coordinates for live presence.'); } final firestore = _firestore; if (firestore == null) return; final presenceRef = firestore .collection('group_rides') .doc(groupRideId) .collection('live_presence') .doc(callerUid); await presenceRef.set(presence.toJson()); } /// Explicitly clears or disables live presence for a rider. Future clearLivePresence({ required String callerUid, required String groupRideId, }) async { final firestore = _firestore; if (firestore == null) return; final presenceRef = firestore .collection('group_rides') .doc(groupRideId) .collection('live_presence') .doc(callerUid); try { await presenceRef.delete(); } catch (e) { debugPrint('[GroupRideService] clearLivePresence error: $e'); } } /// Realtime stream of live presence records for all participants in an eligible ride. Stream> livePresenceStream(String groupRideId) { final firestore = _firestore; if (firestore == null) return Stream.value([]); return firestore .collection('group_rides') .doc(groupRideId) .collection('live_presence') .snapshots() .map((snap) { return snap.docs .map((d) { try { return LiveGroupPresence.fromJson(d.data()); } catch (_) { return null; } }) .whereType() .where((p) => p.sharingEnabled) .toList(); }); } /// Stream of upcoming Group Rides visible to the caller (excluding canceled/completed). Stream> upcomingRidesStream({required String callerUid}) { final firestore = _firestore; if (firestore == null) return Stream.value([]); return firestore .collection('group_rides') .where('status', whereIn: [ GroupRideStatus.scheduled.name, GroupRideStatus.assembling.name, GroupRideStatus.underway.name, ]) .limit(30) .snapshots() .map((snap) { return snap.docs .map((d) { try { return GroupRide.fromJson(d.data()); } catch (_) { return null; } }) .whereType() .toList(); }); } }