MotorSignal-App/lib/services/group_rides/group_ride_service.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();
});
}
}