730 lines
24 KiB
Dart
730 lines
24 KiB
Dart
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<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 [],
|
|
}) 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,
|
|
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<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()!);
|
|
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 = <String, dynamic>{
|
|
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<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()!);
|
|
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<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,
|
|
);
|
|
|
|
await participantRef.set(invite.toJson());
|
|
}
|
|
|
|
/// 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(),
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Requests to join a Group Ride with requestToJoin policy.
|
|
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 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<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()!);
|
|
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<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.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<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 (wasAccepted && ride.participantCount > 0) {
|
|
transaction.update(rideRef, {
|
|
'participantCount': FieldValue.increment(-1),
|
|
'updatedAt': now.toIso8601String(),
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Removes a participant from a Group Ride (organizer only).
|
|
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()!);
|
|
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<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();
|
|
});
|
|
}
|
|
|
|
/// Stream of upcoming Group Rides visible to the caller (excluding canceled/completed).
|
|
Stream<List<GroupRide>> upcomingRidesStream({required String callerUid}) {
|
|
final firestore = _firestore;
|
|
if (firestore == null) return Stream.value([]);
|
|
|
|
return firestore
|
|
.collection('group_rides')
|
|
.where('status', isEqualTo: GroupRideStatus.scheduled.name)
|
|
.limit(30)
|
|
.snapshots()
|
|
.map((snap) {
|
|
return snap.docs
|
|
.map((d) {
|
|
try {
|
|
return GroupRide.fromJson(d.data());
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
})
|
|
.whereType<GroupRide>()
|
|
.toList();
|
|
});
|
|
}
|
|
}
|