MotorSignal-App/lib/services/group_rides/group_ride_service.dart

1256 lines
42 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
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 {
static const String defaultApiBaseUrl = 'https://motorsignal.app';
final FirebaseFirestore? _firestore;
final FirebaseAuth? _auth;
GroupRideService({
FirebaseFirestore? firestore,
FirebaseAuth? auth,
}) : _firestore = firestore ?? _resolveFirestore(),
_auth = auth ?? _resolveAuth();
static FirebaseFirestore? _resolveFirestore() {
try {
if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance;
} catch (_) {}
return null;
}
static FirebaseAuth? _resolveAuth() {
try {
if (Firebase.apps.isNotEmpty) return FirebaseAuth.instance;
} catch (_) {}
return null;
}
bool get isAvailable => _firestore != null;
/// Creates a new Group Ride and establishes the organizer participant record.
Future<GroupRide> 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<GroupRideLocation> stops = const [],
List<String> initialInviteUids = const [],
String? routeId,
String? routeTitle,
double? routeDistanceMeters,
int? routeStopCount,
String? clubId,
}) 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.');
}
if (clubId != null) {
if (clubId.trim().isEmpty || clubId.contains(RegExp(r'\s')) || clubId.length > 64) {
throw ArgumentError('Club ID cannot be blank, contain whitespace, or exceed 64 characters.');
}
final firestore = _firestore;
if (firestore == null) {
throw StateError('Cannot create a club ride while offline.');
}
final clubDoc = await firestore.collection('clubs').doc(clubId).get();
if (!clubDoc.exists || clubDoc.data() == null) {
throw StateError('Club not found.');
}
final clubData = clubDoc.data()!;
if (clubData['archivedAt'] != null) {
throw StateError('Cannot create a ride for an archived club.');
}
final memberDoc = await firestore
.collection('clubs')
.doc(clubId)
.collection('members')
.doc(organizer.uid)
.get();
if (!memberDoc.exists || memberDoc.data() == null) {
throw StateError('You are not a member of this club.');
}
final memberData = memberDoc.data()!;
final memberStatus = memberData['status'] as String?;
if (memberStatus != 'active') {
throw StateError('Active club membership is required to organize club rides.');
}
final memberRole = memberData['role'] as String?;
if (memberRole != 'owner' && memberRole != 'admin') {
throw StateError('Only club owners or administrators can organize club rides.');
}
}
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,
accessUids: [organizer.uid],
meetingPoint: meetingPoint,
destination: destination,
stops: stops,
routeId: routeId,
routeTitle: routeTitle,
routeDistanceMeters: routeDistanceMeters,
routeStopCount: routeStopCount,
createdAt: now,
updatedAt: now,
clubId: clubId,
);
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;
}
Future<bool> _isActiveClubAdmin({
required String clubId,
required String uid,
}) async {
final firestore = _firestore;
if (firestore == null) return false;
try {
final memberDoc = await firestore
.collection('clubs')
.doc(clubId)
.collection('members')
.doc(uid)
.get();
if (!memberDoc.exists || memberDoc.data() == null) return false;
final memberData = memberDoc.data()!;
final memberStatus = memberData['status'] as String?;
final memberRole = memberData['role'] as String?;
return memberStatus == 'active' &&
(memberRole == 'owner' || memberRole == 'admin');
} catch (_) {
return false;
}
}
/// Updates editable Group Ride details (organizer or club admin).
Future<void> 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<GroupRideLocation>? 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()!);
final isOrganizer = current.organizerUid == callerUid;
final isClubAdmin = !isOrganizer &&
current.clubId != null &&
await _isActiveClubAdmin(clubId: current.clubId!, uid: callerUid);
if (!isOrganizer && !isClubAdmin) {
throw StateError('Only the organizer or a club administrator can modify this ride.');
}
if (current.isCanceled || current.isCompleted) {
throw StateError('Cannot modify a canceled or completed ride.');
}
if (isClubAdmin && current.status != GroupRideStatus.scheduled) {
throw StateError('Club administrators can only modify scheduled rides.');
}
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 = <String, dynamic>{
if (title != null && title.trim().isNotEmpty) 'title': title.trim(),
'visibility': ?visibility?.name,
'joinPolicy': ?joinPolicy?.name,
'scheduledStartAt': ?scheduledStartAt?.toIso8601String(),
'departureAt': ?departureAt?.toIso8601String(),
'participantLimit': ?participantLimit,
'meetingPoint': ?meetingPoint?.toJson(),
'destination': ?destination?.toJson(),
'stops': ?stops?.map((s) => s.toJson()).toList(),
'updatedAt': DateTime.now().toIso8601String(),
};
if (description != null) {
updates['description'] = description.trim();
}
await rideRef.update(updates);
}
/// Cancels a Group Ride with an optional reason (organizer or club admin).
Future<void> 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()!);
final isOrganizer = current.organizerUid == callerUid;
final isClubAdmin = !isOrganizer &&
current.clubId != null &&
await _isActiveClubAdmin(clubId: current.clubId!, uid: callerUid);
if (!isOrganizer && !isClubAdmin) {
throw StateError('Only the organizer or a club administrator can cancel this ride.');
}
if (current.isCanceled) return;
if (current.isCompleted) {
throw StateError('Cannot cancel a completed ride.');
}
if (isClubAdmin &&
current.status != GroupRideStatus.scheduled &&
current.status != GroupRideStatus.assembling) {
throw StateError(
'Club administrators can only cancel scheduled or assembling rides.',
);
}
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<void> 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,
);
final batch = firestore.batch();
batch.set(participantRef, invite.toJson());
batch.update(rideRef, {
'accessUids': FieldValue.arrayUnion([friendProfile.uid]),
'updatedAt': now.toIso8601String(),
});
await batch.commit();
}
/// Responds to a Group Ride invitation (Invitee only).
Future<void> 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(),
});
transaction.update(rideRef, {
'accessUids': FieldValue.arrayRemove([callerUid]),
'updatedAt': now.toIso8601String(),
});
}
});
}
/// Requests to join a Group Ride (requester UID).
///
/// Writes the participant request document with [ParticipantStatus.requested].
/// The parent ride document is NOT modified. Capacity is strictly enforced upon approval.
Future<void> requestToJoin({
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 rideSnap = await transaction.get(rideRef);
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.status != GroupRideStatus.scheduled &&
ride.status != GroupRideStatus.assembling) {
throw StateError('Cannot request to join a ride that is underway, completed, or canceled.');
}
if (ride.joinPolicy != GroupRideJoinPolicy.requestToJoin) {
throw StateError('This ride does not accept join requests.');
}
if (ride.accessUids.contains(caller.uid)) {
throw StateError('You are already a participant in this ride.');
}
// Check official Club affiliation membership requirement
if (ride.clubId != null) {
final memberSnap = await transaction.get(
firestore
.collection('clubs')
.doc(ride.clubId!)
.collection('members')
.doc(caller.uid),
);
if (!memberSnap.exists || memberSnap.data()?['status'] != 'active') {
throw StateError('Active club membership is required to join this club ride.');
}
}
// 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 pDoc = await transaction.get(participantRef);
if (pDoc.exists && pDoc.data() != null) {
final existing = GroupRideParticipant.fromJson(pDoc.data()!);
if (existing.status == ParticipantStatus.requested) {
throw StateError('You already have a pending join request.');
}
if (existing.status == ParticipantStatus.accepted) {
throw StateError('You are already an accepted participant.');
}
if (existing.status == ParticipantStatus.declined ||
existing.status == ParticipantStatus.left ||
existing.status == ParticipantStatus.removed) {
throw StateError('Cannot request to join after ride departure or previous removal.');
}
}
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,
);
transaction.set(participantRef, req.toJson());
});
}
/// Fetches pending join requests for a group ride via the authenticated moderation API.
/// Requires caller to be the organizer or an active club administrator.
Future<List<GroupRideJoinRequest>> fetchJoinRequests({
required String groupRideId,
String? idToken,
String? baseUrl,
http.Client? client,
}) async {
final cleanRideId = groupRideId.trim();
if (cleanRideId.isEmpty) {
throw ArgumentError('Group ride ID cannot be empty.');
}
String? token = idToken;
if (token == null || token.isEmpty) {
final user = _auth?.currentUser;
if (user == null || user.isAnonymous) {
throw StateError('A registered account is required to view join requests.');
}
token = await user.getIdToken();
if (token == null || token.isEmpty) {
throw StateError('Unable to retrieve authentication token.');
}
}
final rootUrl = (baseUrl ?? defaultApiBaseUrl).replaceAll(RegExp(r'/+$'), '');
final uri = Uri.parse('$rootUrl/api/group-rides/${Uri.encodeComponent(cleanRideId)}/join-requests');
final httpClient = client ?? http.Client();
final shouldCloseClient = client == null;
try {
final response = await httpClient.get(
uri,
headers: {
'Authorization': 'Bearer $token',
'Accept': 'application/json',
},
).timeout(const Duration(seconds: 15));
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw const FormatException('Malformed response from server.');
}
final requestsList = decoded['requests'];
if (requestsList is! List) {
return const [];
}
return requestsList
.whereType<Map<String, dynamic>>()
.map((item) => GroupRideJoinRequest.fromJson(item))
.toList();
}
if (response.statusCode == 401) {
throw StateError('Your session has expired. Please sign in again.');
}
if (response.statusCode == 403) {
throw StateError('Only the organizer or club leadership can view join requests.');
}
if (response.statusCode == 404) {
throw StateError('Group ride not found.');
}
throw StateError('Unable to load join requests (HTTP ${response.statusCode}).');
} on TimeoutException {
throw TimeoutException('Network timeout while loading join requests. Please try again.');
} on http.ClientException catch (e) {
throw StateError('Network connection error: ${e.message}');
} finally {
if (shouldCloseClient) {
httpClient.close();
}
}
}
/// Responds to a join request (organizer or authorized club leadership).
Future<void> 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()!);
// Check leadership authorization: organizer or active club admin/owner for club rides
bool isAuthorized = ride.organizerUid == callerUid;
if (!isAuthorized && ride.clubId != null) {
final memberDoc = await transaction.get(
firestore
.collection('clubs')
.doc(ride.clubId!)
.collection('members')
.doc(callerUid),
);
if (memberDoc.exists &&
memberDoc.data()?['status'] == 'active' &&
(memberDoc.data()?['role'] == 'owner' ||
memberDoc.data()?['role'] == 'admin')) {
isAuthorized = true;
}
}
if (!isAuthorized) {
throw StateError('Only the organizer or club leadership can moderate requests.');
}
// Lifecycle status checks
if (approve) {
if (ride.status != GroupRideStatus.scheduled &&
ride.status != GroupRideStatus.assembling) {
throw StateError('Cannot approve requests for a ride that is underway, completed, or canceled.');
}
} else {
if (ride.isCanceled || ride.isCompleted) {
throw StateError('Ride is no longer active.');
}
}
// Mutual block check between organizer and requester (approval only; decline must always be permitted)
if (approve) {
final orgBlock = await firestore
.collection('users')
.doc(ride.organizerUid)
.collection('blocks')
.doc(requesterUid)
.get();
if (orgBlock.exists) throw StateError('Cannot approve mutually blocked requester.');
final reqBlock = await firestore
.collection('users')
.doc(requesterUid)
.collection('blocks')
.doc(ride.organizerUid)
.get();
if (reqBlock.exists) throw StateError('Cannot approve mutually blocked requester.');
}
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.');
}
// If official club ride, verify requester remains an active club member
if (approve && ride.clubId != null) {
final reqMemberDoc = await transaction.get(
firestore
.collection('clubs')
.doc(ride.clubId!)
.collection('members')
.doc(requesterUid),
);
if (!reqMemberDoc.exists || reqMemberDoc.data()?['status'] != 'active') {
throw StateError('Requester is no longer an active club member.');
}
}
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, {
'accessUids': FieldValue.arrayUnion([requesterUid]),
'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<void> 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.status != GroupRideStatus.scheduled &&
ride.status != GroupRideStatus.assembling) {
throw StateError('Cannot join a ride that is underway, completed, or canceled.');
}
if (ride.joinPolicy != GroupRideJoinPolicy.open) {
throw StateError('This ride is not open to direct join.');
}
if (ride.isFull) {
throw StateError('This ride is full.');
}
// Check official Club affiliation membership requirement
if (ride.clubId != null) {
final memberSnap = await transaction.get(
firestore
.collection('clubs')
.doc(ride.clubId!)
.collection('members')
.doc(caller.uid),
);
if (!memberSnap.exists || memberSnap.data()?['status'] != 'active') {
throw StateError('Active club membership is required to join this club ride.');
}
}
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, {
'accessUids': FieldValue.arrayUnion([caller.uid]),
'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<void> 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 (ride.organizerUid != callerUid && wasAccepted) {
final rideUpdates = <String, dynamic>{
'accessUids': FieldValue.arrayRemove([callerUid]),
'updatedAt': now.toIso8601String(),
};
if (ride.participantCount > 0) {
rideUpdates['participantCount'] = FieldValue.increment(-1);
}
transaction.update(rideRef, rideUpdates);
}
});
}
/// Removes a participant from a Group Ride (organizer or authorized club leadership).
Future<void> 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()!);
bool isAuthorized = ride.organizerUid == callerUid;
if (!isAuthorized && ride.clubId != null) {
final memberDoc = await transaction.get(
firestore
.collection('clubs')
.doc(ride.clubId!)
.collection('members')
.doc(callerUid),
);
if (memberDoc.exists &&
memberDoc.data()?['status'] == 'active' &&
(memberDoc.data()?['role'] == 'owner' ||
memberDoc.data()?['role'] == 'admin')) {
isAuthorized = true;
}
}
if (!isAuthorized) {
throw StateError('Only the organizer or club leadership 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) {
final rideUpdates = <String, dynamic>{
'accessUids': FieldValue.arrayRemove([targetUid]),
'updatedAt': now.toIso8601String(),
};
if (ride.participantCount > 0) {
rideUpdates['participantCount'] = FieldValue.increment(-1);
}
transaction.update(rideRef, rideUpdates);
}
});
}
/// Transfers the organizer role to another active participant (organizer only).
Future<void> 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<GroupRide?> 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<List<GroupRideParticipant>> 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<GroupRideParticipant>()
.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<void> 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<void> 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<void> 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 a single participant's membership document.
Stream<GroupRideParticipant?> participantDocStream({
required String groupRideId,
required String participantUid,
}) {
final firestore = _firestore;
if (firestore == null) return Stream.value(null);
return firestore
.collection('group_rides')
.doc(groupRideId)
.collection('participants')
.doc(participantUid)
.snapshots()
.map((snap) {
if (!snap.exists || snap.data() == null) return null;
try {
return GroupRideParticipant.fromJson(snap.data()!);
} catch (_) {
return null;
}
});
}
/// Realtime single-document stream for a specific participant's live presence.
Stream<LiveGroupPresence?> livePresenceDocStream({
required String groupRideId,
required String targetUid,
}) {
final firestore = _firestore;
if (firestore == null) return Stream.value(null);
return firestore
.collection('group_rides')
.doc(groupRideId)
.collection('live_presence')
.doc(targetUid)
.snapshots()
.map((snap) {
if (!snap.exists || snap.data() == null) return null;
try {
final presence = LiveGroupPresence.fromJson(snap.data()!);
return presence.sharingEnabled ? presence : null;
} catch (_) {
return null;
}
});
}
/// Stream of upcoming Group Rides visible to the caller (personal schedule via accessUids).
Stream<List<GroupRide>> upcomingRidesStream({required String callerUid}) {
final firestore = _firestore;
if (firestore == null) return Stream.value([]);
return firestore
.collection('group_rides')
.where('accessUids', arrayContains: callerUid)
.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 (e) {
debugPrint('[GroupRideService] upcomingRidesStream parse error: $e');
return null;
}
})
.whereType<GroupRide>()
.toList();
});
}
/// Stream of Group Rides organized by the caller.
Stream<List<GroupRide>> myRidesStream({required String callerUid}) {
final firestore = _firestore;
if (firestore == null) return Stream.value([]);
return firestore
.collection('group_rides')
.where('organizerUid', isEqualTo: callerUid)
.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 (e) {
debugPrint('[GroupRideService] myRidesStream parse error: $e');
return null;
}
})
.whereType<GroupRide>()
.toList();
});
}
}