1256 lines
42 KiB
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();
|
|
});
|
|
}
|
|
}
|