feat: add group rides and meeting locations
This commit is contained in:
parent
0c2e1a0cac
commit
f464d52462
|
|
@ -371,4 +371,152 @@ describe('Social Architecture Security Rules', () => {
|
||||||
senderUid: 'bob',
|
senderUid: 'bob',
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Group Rides Security Rules', () => {
|
||||||
|
test('organizer can create group ride and initialize organizer participant', async () => {
|
||||||
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||||
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||||
|
|
||||||
|
const rideData = {
|
||||||
|
id: 'ride_100',
|
||||||
|
organizerUid: 'alice',
|
||||||
|
organizerHandle: 'alice_apex',
|
||||||
|
organizerDisplayName: 'Alice Apex',
|
||||||
|
title: 'Blood Mountain Run',
|
||||||
|
scheduledStartAt: '2026-09-10T09:00:00Z',
|
||||||
|
departureAt: '2026-09-10T09:30:00Z',
|
||||||
|
participantCount: 1,
|
||||||
|
visibility: 'friendsOnly',
|
||||||
|
joinPolicy: 'inviteOnly',
|
||||||
|
meetingPoint: {
|
||||||
|
latitude: 34.6,
|
||||||
|
longitude: -83.9,
|
||||||
|
displayName: 'Dahlonega Square',
|
||||||
|
locationType: 'meetup',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Alice can create ride with herself as organizer
|
||||||
|
await assertSucceeds(setDoc(doc(alice, 'group_rides/ride_100'), rideData));
|
||||||
|
|
||||||
|
// Alice creates herself as accepted organizer participant
|
||||||
|
await assertSucceeds(setDoc(doc(alice, 'group_rides/ride_100/participants/alice'), {
|
||||||
|
userId: 'alice',
|
||||||
|
role: 'organizer',
|
||||||
|
status: 'accepted',
|
||||||
|
riderHandle: 'alice_apex',
|
||||||
|
riderDisplayName: 'Alice Apex',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Bob CANNOT create a ride claiming Alice is the organizer
|
||||||
|
await assertFails(setDoc(doc(bob, 'group_rides/ride_fake'), {
|
||||||
|
...rideData,
|
||||||
|
id: 'ride_fake',
|
||||||
|
organizerUid: 'alice',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ride visibility and mutual block rules', async () => {
|
||||||
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||||
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||||
|
const charlie = testEnv.authenticatedContext('charlie').firestore();
|
||||||
|
|
||||||
|
// Seed ride as private, plus Bob as Alice's friend, and Alice blocks Charlie
|
||||||
|
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||||
|
const db = context.firestore();
|
||||||
|
await setDoc(doc(db, 'group_rides/ride_private'), {
|
||||||
|
id: 'ride_private',
|
||||||
|
organizerUid: 'alice',
|
||||||
|
title: 'Secret Run',
|
||||||
|
scheduledStartAt: '2026-09-10T09:00:00Z',
|
||||||
|
departureAt: '2026-09-10T09:30:00Z',
|
||||||
|
participantCount: 1,
|
||||||
|
visibility: 'private',
|
||||||
|
meetingPoint: { latitude: 0, longitude: 0, displayName: 'Spot' },
|
||||||
|
});
|
||||||
|
await setDoc(doc(db, 'group_rides/ride_friends'), {
|
||||||
|
id: 'ride_friends',
|
||||||
|
organizerUid: 'alice',
|
||||||
|
title: 'Friends Run',
|
||||||
|
scheduledStartAt: '2026-09-10T09:00:00Z',
|
||||||
|
departureAt: '2026-09-10T09:30:00Z',
|
||||||
|
participantCount: 1,
|
||||||
|
visibility: 'friendsOnly',
|
||||||
|
meetingPoint: { latitude: 0, longitude: 0, displayName: 'Spot' },
|
||||||
|
});
|
||||||
|
// Bob is Alice's friend
|
||||||
|
await setDoc(doc(db, 'users/alice/friends/bob'), { uid: 'bob' });
|
||||||
|
// Alice blocks Charlie
|
||||||
|
await setDoc(doc(db, 'users/alice/blocks/charlie'), { blockedUid: 'charlie' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Private ride: non-member Bob CANNOT read
|
||||||
|
await assertFails(getDoc(doc(bob, 'group_rides/ride_private')));
|
||||||
|
|
||||||
|
// Friends ride: friend Bob CAN read
|
||||||
|
await assertSucceeds(getDoc(doc(bob, 'group_rides/ride_friends')));
|
||||||
|
|
||||||
|
// Blocked user Charlie CANNOT read friends ride
|
||||||
|
await assertFails(getDoc(doc(charlie, 'group_rides/ride_friends')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invitations, acceptance, and capacity modification', async () => {
|
||||||
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||||
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||||
|
const charlie = testEnv.authenticatedContext('charlie').firestore();
|
||||||
|
|
||||||
|
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||||
|
const db = context.firestore();
|
||||||
|
await setDoc(doc(db, 'group_rides/ride_inv'), {
|
||||||
|
id: 'ride_inv',
|
||||||
|
organizerUid: 'alice',
|
||||||
|
title: 'Invite Run',
|
||||||
|
scheduledStartAt: '2026-09-10T09:00:00Z',
|
||||||
|
departureAt: '2026-09-10T09:30:00Z',
|
||||||
|
participantCount: 1,
|
||||||
|
visibility: 'friendsOnly',
|
||||||
|
meetingPoint: { latitude: 0, longitude: 0, displayName: 'Spot' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Alice invites Bob
|
||||||
|
await assertSucceeds(setDoc(doc(alice, 'group_rides/ride_inv/participants/bob'), {
|
||||||
|
userId: 'bob',
|
||||||
|
role: 'participant',
|
||||||
|
status: 'invited',
|
||||||
|
riderHandle: 'bob_rider',
|
||||||
|
riderDisplayName: 'Bob Rider',
|
||||||
|
invitedBy: 'alice',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Outsider Charlie CANNOT forge an invitation to himself
|
||||||
|
await assertFails(setDoc(doc(charlie, 'group_rides/ride_inv/participants/charlie'), {
|
||||||
|
userId: 'charlie',
|
||||||
|
role: 'participant',
|
||||||
|
status: 'invited',
|
||||||
|
invitedBy: 'alice',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Charlie CANNOT accept Bob's invite
|
||||||
|
await assertFails(updateDoc(doc(charlie, 'group_rides/ride_inv/participants/bob'), {
|
||||||
|
status: 'accepted',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Bob accepts his own invite
|
||||||
|
await assertSucceeds(updateDoc(doc(bob, 'group_rides/ride_inv/participants/bob'), {
|
||||||
|
status: 'accepted',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Bob can adjust participantCount on the ride during acceptance
|
||||||
|
await assertSucceeds(updateDoc(doc(bob, 'group_rides/ride_inv'), {
|
||||||
|
participantCount: 2,
|
||||||
|
updatedAt: '2026-09-10T09:05:00Z',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Bob CANNOT edit authority fields like title or organizerUid
|
||||||
|
await assertFails(updateDoc(doc(bob, 'group_rides/ride_inv'), {
|
||||||
|
title: 'Hijacked Title',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
105
firestore.rules
105
firestore.rules
|
|
@ -187,8 +187,109 @@ service cloud.firestore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match /groupRides/{groupRideId} {
|
// --- GROUP RIDES ---
|
||||||
allow read, write: if false;
|
// Top-level social object distinct from personal rides, accounts, and telemetry.
|
||||||
|
match /group_rides/{groupRideId} {
|
||||||
|
function isOrganizer() {
|
||||||
|
return signedIn() && resource.data.organizerUid == request.auth.uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isParticipant() {
|
||||||
|
return signedIn() && exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFriendOfOrganizer() {
|
||||||
|
return signedIn() && exists(/databases/$(database)/documents/users/$(resource.data.organizerUid)/friends/$(request.auth.uid));
|
||||||
|
}
|
||||||
|
|
||||||
|
function canReadRide() {
|
||||||
|
return signedIn() && (
|
||||||
|
// Organizer or active participant can always read if not mutually blocked
|
||||||
|
(isOrganizer() || isParticipant()) ||
|
||||||
|
(
|
||||||
|
!areMutuallyBlocked(request.auth.uid, resource.data.organizerUid) && (
|
||||||
|
resource.data.visibility == 'public' ||
|
||||||
|
(resource.data.visibility == 'friendsOnly' && isFriendOfOrganizer())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
allow read: if canReadRide();
|
||||||
|
allow create: if signedIn()
|
||||||
|
&& request.resource.data.organizerUid == request.auth.uid
|
||||||
|
&& request.resource.data.id == groupRideId
|
||||||
|
&& request.resource.data.keys().hasAll(['id', 'organizerUid', 'title', 'scheduledStartAt', 'departureAt', 'meetingPoint'])
|
||||||
|
&& request.resource.data.participantCount >= 1;
|
||||||
|
allow update: if signedIn() && (
|
||||||
|
// Organizer can update any ride authority fields
|
||||||
|
isOrganizer() ||
|
||||||
|
// Participants/invitees can atomically adjust participantCount during join/leave transactions
|
||||||
|
(
|
||||||
|
canReadRide() &&
|
||||||
|
request.resource.data.diff(resource.data).affectedKeys().hasOnly(['participantCount', 'updatedAt', 'status', 'canceledReason', 'canceledAt'])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
allow delete: if isOrganizer();
|
||||||
|
|
||||||
|
// --- PARTICIPANTS SUBCOLLECTION ---
|
||||||
|
match /participants/{participantUid} {
|
||||||
|
function rideDoc() {
|
||||||
|
return get(/databases/$(database)/documents/group_rides/$(groupRideId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rideOrganizer() {
|
||||||
|
return rideDoc().data.organizerUid;
|
||||||
|
}
|
||||||
|
|
||||||
|
allow read: if signedIn() && (
|
||||||
|
request.auth.uid == participantUid ||
|
||||||
|
request.auth.uid == rideOrganizer() ||
|
||||||
|
exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)) ||
|
||||||
|
rideDoc().data.visibility == 'public' ||
|
||||||
|
(rideDoc().data.visibility == 'friendsOnly' && exists(/databases/$(database)/documents/users/$(rideOrganizer())/friends/$(request.auth.uid)))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Organizer can invite riders; caller can request to join or join open ride; caller can initialize own organizer record on create
|
||||||
|
allow create: if signedIn() && (
|
||||||
|
// Self-organizer initialization on ride create
|
||||||
|
(request.auth.uid == participantUid && request.resource.data.role == 'organizer') ||
|
||||||
|
// Organizer inviting a non-blocked rider
|
||||||
|
(
|
||||||
|
request.auth.uid == rideOrganizer() &&
|
||||||
|
request.resource.data.status == 'invited' &&
|
||||||
|
!areMutuallyBlocked(request.auth.uid, participantUid)
|
||||||
|
) ||
|
||||||
|
// Rider requesting to join or joining open ride
|
||||||
|
(
|
||||||
|
request.auth.uid == participantUid &&
|
||||||
|
(request.resource.data.status == 'requested' || request.resource.data.status == 'accepted') &&
|
||||||
|
!areMutuallyBlocked(request.auth.uid, rideOrganizer())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// State transitions: participant can accept/decline/leave; organizer can approve/decline/remove or transfer
|
||||||
|
allow update: if signedIn() && (
|
||||||
|
// Participant responding to invite or leaving
|
||||||
|
(
|
||||||
|
request.auth.uid == participantUid &&
|
||||||
|
(
|
||||||
|
request.resource.data.status in ['accepted', 'declined', 'left'] ||
|
||||||
|
// Transfer target becoming organizer
|
||||||
|
(request.resource.data.role == 'organizer' && request.auth.uid == rideOrganizer())
|
||||||
|
)
|
||||||
|
) ||
|
||||||
|
// Organizer approving, removing, or transferring
|
||||||
|
(
|
||||||
|
request.auth.uid == rideOrganizer()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
allow delete: if signedIn() && (
|
||||||
|
request.auth.uid == participantUid ||
|
||||||
|
request.auth.uid == rideOrganizer()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match /{document=**} {
|
match /{document=**} {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ import '../features/settings/account_screen.dart';
|
||||||
import '../features/entitlements/pro_screen.dart';
|
import '../features/entitlements/pro_screen.dart';
|
||||||
import '../features/social/rider_profile_screen.dart';
|
import '../features/social/rider_profile_screen.dart';
|
||||||
import '../features/social/friends_screen.dart';
|
import '../features/social/friends_screen.dart';
|
||||||
|
import '../features/group_rides/group_rides_hub_screen.dart';
|
||||||
|
import '../features/group_rides/create_group_ride_screen.dart';
|
||||||
|
import '../features/group_rides/group_ride_detail_screen.dart';
|
||||||
|
|
||||||
// ignore: unused_element
|
// ignore: unused_element
|
||||||
final _rootNavigatorKey = GlobalKey<NavigatorState>();
|
final _rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||||
|
|
@ -165,5 +168,22 @@ final appRouter = GoRouter(
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/group-rides',
|
||||||
|
builder: (context, state) => const GroupRidesHubScreen(),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: 'create',
|
||||||
|
builder: (context, state) => const CreateGroupRideScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: ':id',
|
||||||
|
builder: (context, state) {
|
||||||
|
final id = state.pathParameters['id']!;
|
||||||
|
return GroupRideDetailScreen(rideId: id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,652 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../services/group_rides/group_ride_models.dart';
|
||||||
|
import '../../services/group_rides/group_ride_providers.dart';
|
||||||
|
import '../../services/social/social_providers.dart';
|
||||||
|
|
||||||
|
class CreateGroupRideScreen extends ConsumerStatefulWidget {
|
||||||
|
const CreateGroupRideScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<CreateGroupRideScreen> createState() =>
|
||||||
|
_CreateGroupRideScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
final _titleController = TextEditingController();
|
||||||
|
final _descriptionController = TextEditingController();
|
||||||
|
|
||||||
|
DateTime _scheduledDate = DateTime.now().add(const Duration(days: 1));
|
||||||
|
TimeOfDay _meetupTime = const TimeOfDay(hour: 9, minute: 0);
|
||||||
|
TimeOfDay _departureTime = const TimeOfDay(hour: 9, minute: 30);
|
||||||
|
|
||||||
|
// Meeting point
|
||||||
|
final _meetupNameController = TextEditingController();
|
||||||
|
final _meetupAddressController = TextEditingController();
|
||||||
|
final _meetupNotesController = TextEditingController();
|
||||||
|
final double _meetupLat = 34.5378;
|
||||||
|
final double _meetupLng = -83.9849;
|
||||||
|
|
||||||
|
// Destination
|
||||||
|
bool _hasDestination = true;
|
||||||
|
final _destNameController = TextEditingController();
|
||||||
|
final _destAddressController = TextEditingController();
|
||||||
|
final double _destLat = 34.8826;
|
||||||
|
final double _destLng = -83.9281;
|
||||||
|
|
||||||
|
// Intermediate stops
|
||||||
|
final List<GroupRideLocation> _stops = [];
|
||||||
|
|
||||||
|
// Privacy & policies
|
||||||
|
GroupRideVisibility _visibility = GroupRideVisibility.friendsOnly;
|
||||||
|
GroupRideJoinPolicy _joinPolicy = GroupRideJoinPolicy.inviteOnly;
|
||||||
|
int? _participantLimit;
|
||||||
|
|
||||||
|
bool _isSaving = false;
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_titleController.dispose();
|
||||||
|
_descriptionController.dispose();
|
||||||
|
_meetupNameController.dispose();
|
||||||
|
_meetupAddressController.dispose();
|
||||||
|
_meetupNotesController.dispose();
|
||||||
|
_destNameController.dispose();
|
||||||
|
_destAddressController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime _buildFullDateTime(DateTime date, TimeOfDay time) {
|
||||||
|
return DateTime(date.year, date.month, date.day, time.hour, time.minute);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
setState(() => _errorMessage = null);
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
final startAt = _buildFullDateTime(_scheduledDate, _meetupTime);
|
||||||
|
final depAt = _buildFullDateTime(_scheduledDate, _departureTime);
|
||||||
|
|
||||||
|
if (depAt.isBefore(startAt)) {
|
||||||
|
setState(() => _errorMessage =
|
||||||
|
'Kickstands Up (departure) cannot be earlier than Meetup time.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final organizerProfile = ref.read(myRiderProfileProvider).value;
|
||||||
|
if (organizerProfile == null) {
|
||||||
|
setState(() => _errorMessage =
|
||||||
|
'Rider profile required. Please complete your profile first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isSaving = true);
|
||||||
|
try {
|
||||||
|
final meetingPoint = GroupRideLocation(
|
||||||
|
latitude: _meetupLat,
|
||||||
|
longitude: _meetupLng,
|
||||||
|
displayName: _meetupNameController.text.trim(),
|
||||||
|
address: _meetupAddressController.text.trim(),
|
||||||
|
locationType: GroupRideLocationType.meetup,
|
||||||
|
notes: _meetupNotesController.text.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final destination = _hasDestination &&
|
||||||
|
_destNameController.text.trim().isNotEmpty
|
||||||
|
? GroupRideLocation(
|
||||||
|
latitude: _destLat,
|
||||||
|
longitude: _destLng,
|
||||||
|
displayName: _destNameController.text.trim(),
|
||||||
|
address: _destAddressController.text.trim(),
|
||||||
|
locationType: GroupRideLocationType.destination,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final service = ref.read(groupRideServiceProvider);
|
||||||
|
final created = await service.createGroupRide(
|
||||||
|
organizer: organizerProfile,
|
||||||
|
title: _titleController.text.trim(),
|
||||||
|
description: _descriptionController.text.trim(),
|
||||||
|
visibility: _visibility,
|
||||||
|
joinPolicy: _joinPolicy,
|
||||||
|
scheduledStartAt: startAt,
|
||||||
|
departureAt: depAt,
|
||||||
|
participantLimit: _participantLimit,
|
||||||
|
meetingPoint: meetingPoint,
|
||||||
|
destination: destination,
|
||||||
|
stops: _stops,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
context.pop();
|
||||||
|
context.push('/group-rides/${created.id}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
||||||
|
_isSaving = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addStopDialog() {
|
||||||
|
final nameCtrl = TextEditingController();
|
||||||
|
final notesCtrl = TextEditingController();
|
||||||
|
var stopType = GroupRideLocationType.fuel;
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => StatefulBuilder(
|
||||||
|
builder: (ctx, setDialogState) => AlertDialog(
|
||||||
|
backgroundColor: const Color(0xFF1B1E28),
|
||||||
|
title: const Text('Add Intermediate Stop',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 16)),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: nameCtrl,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Stop Name (e.g. Shell Fuel / Overlook)',
|
||||||
|
labelStyle: TextStyle(color: Colors.white70),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
DropdownButtonFormField<GroupRideLocationType>(
|
||||||
|
initialValue: stopType,
|
||||||
|
dropdownColor: const Color(0xFF1B1E28),
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
decoration: const InputDecoration(labelText: 'Stop Type'),
|
||||||
|
items: [
|
||||||
|
GroupRideLocationType.fuel,
|
||||||
|
GroupRideLocationType.rest,
|
||||||
|
GroupRideLocationType.food,
|
||||||
|
GroupRideLocationType.scenic,
|
||||||
|
GroupRideLocationType.waypoint,
|
||||||
|
].map((type) {
|
||||||
|
return DropdownMenuItem(
|
||||||
|
value: type,
|
||||||
|
child: Text(type.label),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (val) {
|
||||||
|
if (val != null) setDialogState(() => stopType = val);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
TextField(
|
||||||
|
controller: notesCtrl,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Notes (optional)',
|
||||||
|
labelStyle: TextStyle(color: Colors.white70),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('CANCEL',
|
||||||
|
style: TextStyle(color: Colors.white60)),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF00E5FF),
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
if (nameCtrl.text.trim().isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_stops.add(GroupRideLocation(
|
||||||
|
latitude: 0,
|
||||||
|
longitude: 0,
|
||||||
|
displayName: nameCtrl.text.trim(),
|
||||||
|
locationType: stopType,
|
||||||
|
notes: notesCtrl.text.trim(),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('ADD STOP'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFF0D0E11),
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: const Color(0xFF13151A),
|
||||||
|
title: const Text(
|
||||||
|
'CREATE GROUP RIDE',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 1.0,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _isSaving ? null : _submit,
|
||||||
|
child: _isSaving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text(
|
||||||
|
'PUBLISH',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(0xFF00E5FF),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
children: [
|
||||||
|
if (_errorMessage != null)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.redAccent.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.redAccent),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline,
|
||||||
|
color: Colors.redAccent, size: 20),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_errorMessage!,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.redAccent, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
_buildSectionHeader('1. RIDE DETAILS'),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
TextFormField(
|
||||||
|
controller: _titleController,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
decoration: _inputDecoration('Ride Title', 'e.g. Blood Mountain Loop'),
|
||||||
|
validator: (v) {
|
||||||
|
if (v == null || v.trim().length < 3) {
|
||||||
|
return 'Title must be at least 3 characters';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
controller: _descriptionController,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
maxLines: 3,
|
||||||
|
decoration: _inputDecoration(
|
||||||
|
'Description & Route Notes',
|
||||||
|
'Pace, skill level, gear recommendations...',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildSectionHeader('2. DATE & SCHEDULE'),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Card(
|
||||||
|
color: const Color(0xFF161922),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading:
|
||||||
|
const Icon(Icons.calendar_month, color: Color(0xFF00E5FF)),
|
||||||
|
title: const Text('Date',
|
||||||
|
style: TextStyle(color: Colors.white70, fontSize: 13)),
|
||||||
|
subtitle: Text(
|
||||||
|
'${_scheduledDate.month}/${_scheduledDate.day}/${_scheduledDate.year}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _scheduledDate,
|
||||||
|
firstDate: DateTime.now(),
|
||||||
|
lastDate:
|
||||||
|
DateTime.now().add(const Duration(days: 365)),
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
setState(() => _scheduledDate = picked);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('CHANGE'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(color: Colors.white12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Meetup Arrival',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white70, fontSize: 12)),
|
||||||
|
subtitle: Text(
|
||||||
|
_meetupTime.format(context),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
final picked = await showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: _meetupTime,
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
setState(() => _meetupTime = picked);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Kickstands Up (KSU)',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white70, fontSize: 12)),
|
||||||
|
subtitle: Text(
|
||||||
|
_departureTime.format(context),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF00E5FF),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
final picked = await showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: _departureTime,
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
setState(() => _departureTime = picked);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildSectionHeader('3. MEETING LOCATION'),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
TextFormField(
|
||||||
|
controller: _meetupNameController,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
decoration: _inputDecoration(
|
||||||
|
'Meeting Place Name',
|
||||||
|
'e.g. Dahlonega Square / Shell Station',
|
||||||
|
),
|
||||||
|
validator: (v) {
|
||||||
|
if (v == null || v.trim().isEmpty) {
|
||||||
|
return 'Please provide a meeting location name';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
controller: _meetupAddressController,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
decoration: _inputDecoration(
|
||||||
|
'Address or Landmark',
|
||||||
|
'e.g. 123 Main St, Dahlonega, GA',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
controller: _meetupNotesController,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
decoration: _inputDecoration(
|
||||||
|
'Meeting Notes (optional)',
|
||||||
|
'e.g. Park on the north side near pumps',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildSectionHeader('4. DESTINATION & STOPS'),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
activeThumbColor: const Color(0xFF00E5FF),
|
||||||
|
title: const Text('Specific Destination',
|
||||||
|
style: TextStyle(color: Colors.white)),
|
||||||
|
value: _hasDestination,
|
||||||
|
onChanged: (val) => setState(() => _hasDestination = val),
|
||||||
|
),
|
||||||
|
if (_hasDestination) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextFormField(
|
||||||
|
controller: _destNameController,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
decoration: _inputDecoration(
|
||||||
|
'Destination Name',
|
||||||
|
'e.g. Brasstown Bald Summit',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
controller: _destAddressController,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
decoration: _inputDecoration(
|
||||||
|
'Destination Address (optional)',
|
||||||
|
'e.g. Hiawassee, GA',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Intermediate Stops',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _addStopDialog,
|
||||||
|
icon: const Icon(Icons.add_location_alt, size: 16),
|
||||||
|
label: const Text('ADD STOP'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_stops.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Text(
|
||||||
|
'No intermediate stops planned. Ride goes directly from Meetup to Destination.',
|
||||||
|
style: TextStyle(color: Colors.white38, fontSize: 12),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
ReorderableListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
onReorderItem: (oldIndex, newIndex) {
|
||||||
|
setState(() {
|
||||||
|
final item = _stops.removeAt(oldIndex);
|
||||||
|
_stops.insert(newIndex, item);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < _stops.length; i++)
|
||||||
|
ListTile(
|
||||||
|
key: ValueKey('stop_$i'),
|
||||||
|
tileColor: const Color(0xFF161922),
|
||||||
|
leading: CircleAvatar(
|
||||||
|
radius: 12,
|
||||||
|
backgroundColor: const Color(0xFF00E5FF).withValues(alpha: 0.2),
|
||||||
|
child: Text(
|
||||||
|
'${i + 1}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Color(0xFF00E5FF),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
_stops[i].displayName,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 13),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
_stops[i].locationType.label,
|
||||||
|
style: const TextStyle(color: Colors.white54, fontSize: 11),
|
||||||
|
),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline,
|
||||||
|
size: 18, color: Colors.white38),
|
||||||
|
onPressed: () => setState(() => _stops.removeAt(i)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildSectionHeader('5. PRIVACY & PARTICIPATION'),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
DropdownButtonFormField<GroupRideVisibility>(
|
||||||
|
initialValue: _visibility,
|
||||||
|
dropdownColor: const Color(0xFF1B1E28),
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
decoration: _inputDecoration('Visibility', ''),
|
||||||
|
items: GroupRideVisibility.values.map((v) {
|
||||||
|
return DropdownMenuItem(value: v, child: Text(v.label));
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (val) {
|
||||||
|
if (val != null) setState(() => _visibility = val);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
DropdownButtonFormField<GroupRideJoinPolicy>(
|
||||||
|
initialValue: _joinPolicy,
|
||||||
|
dropdownColor: const Color(0xFF1B1E28),
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
decoration: _inputDecoration('Join Policy', ''),
|
||||||
|
items: GroupRideJoinPolicy.values.map((p) {
|
||||||
|
return DropdownMenuItem(value: p, child: Text(p.label));
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (val) {
|
||||||
|
if (val != null) setState(() => _joinPolicy = val);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
decoration: _inputDecoration(
|
||||||
|
'Participant Limit (optional)',
|
||||||
|
'Leave blank for no limit (min 2)',
|
||||||
|
),
|
||||||
|
onChanged: (val) {
|
||||||
|
final parsed = int.tryParse(val.trim());
|
||||||
|
setState(() => _participantLimit = parsed);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 48,
|
||||||
|
child: ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF00E5FF),
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: _isSaving ? null : _submit,
|
||||||
|
child: _isSaving
|
||||||
|
? const CircularProgressIndicator(color: Colors.black)
|
||||||
|
: const Text(
|
||||||
|
'CREATE GROUP RIDE',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 1.0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSectionHeader(String title) {
|
||||||
|
return Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF00E5FF),
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
letterSpacing: 1.0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDecoration _inputDecoration(String label, String hint) {
|
||||||
|
return InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
hintText: hint,
|
||||||
|
labelStyle: const TextStyle(color: Colors.white70, fontSize: 13),
|
||||||
|
hintStyle: const TextStyle(color: Colors.white24, fontSize: 12),
|
||||||
|
filled: true,
|
||||||
|
fillColor: const Color(0xFF161922),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: const BorderSide(color: Color(0xFF00E5FF)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,450 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../services/auth/auth_providers.dart';
|
||||||
|
import '../../services/group_rides/group_ride_models.dart';
|
||||||
|
import '../../services/group_rides/group_ride_providers.dart';
|
||||||
|
|
||||||
|
class GroupRidesHubScreen extends ConsumerStatefulWidget {
|
||||||
|
const GroupRidesHubScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<GroupRidesHubScreen> createState() =>
|
||||||
|
_GroupRidesHubScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late TabController _tabController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tabController = TabController(length: 2, vsync: this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final user = ref.watch(currentUserProvider);
|
||||||
|
final isAuthed = user != null && !user.isAnonymous;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFF0D0E11),
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: const Color(0xFF13151A),
|
||||||
|
elevation: 0,
|
||||||
|
title: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.two_wheeler, color: Color(0xFF00E5FF), size: 24),
|
||||||
|
SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
'GROUP RIDES',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
bottom: TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
indicatorColor: const Color(0xFF00E5FF),
|
||||||
|
indicatorWeight: 3,
|
||||||
|
labelColor: const Color(0xFF00E5FF),
|
||||||
|
unselectedLabelColor: Colors.white60,
|
||||||
|
labelStyle: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
tabs: const [
|
||||||
|
Tab(text: 'UPCOMING RIDES'),
|
||||||
|
Tab(text: 'MY RIDES'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (isAuthed)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add, color: Color(0xFF00E5FF)),
|
||||||
|
tooltip: 'Create Group Ride',
|
||||||
|
onPressed: () => context.push('/group-rides/create'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: !isAuthed
|
||||||
|
? _buildAccountRequiredNotice(context)
|
||||||
|
: TabBarView(
|
||||||
|
controller: _tabController,
|
||||||
|
children: [
|
||||||
|
_buildUpcomingTab(context, ref, user.uid),
|
||||||
|
_buildMyRidesTab(context, ref, user.uid),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: isAuthed
|
||||||
|
? FloatingActionButton.extended(
|
||||||
|
backgroundColor: const Color(0xFF00E5FF),
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
icon: const Icon(Icons.add_road),
|
||||||
|
label: const Text(
|
||||||
|
'NEW RIDE',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
onPressed: () => context.push('/group-rides/create'),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAccountRequiredNotice(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: const Color(0xFF00E5FF).withValues(alpha: 0.1),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.groups_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: Color(0xFF00E5FF),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
const Text(
|
||||||
|
'Rider Account Required',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const Text(
|
||||||
|
'To organize group rides, invite fellow riders, and coordinate meeting points, sign in with your permanent MotorSignal account.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.white70, fontSize: 13, height: 1.4),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF00E5FF),
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.login),
|
||||||
|
label: const Text(
|
||||||
|
'SIGN IN / CREATE ACCOUNT',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
onPressed: () => context.push('/settings/account'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildUpcomingTab(
|
||||||
|
BuildContext context, WidgetRef ref, String currentUid) {
|
||||||
|
final ridesAsync = ref.watch(upcomingGroupRidesProvider);
|
||||||
|
|
||||||
|
return ridesAsync.when(
|
||||||
|
data: (rides) {
|
||||||
|
if (rides.isEmpty) {
|
||||||
|
return _buildEmptyState(
|
||||||
|
icon: Icons.map_outlined,
|
||||||
|
title: 'No Upcoming Group Rides',
|
||||||
|
message:
|
||||||
|
'Plan a run with your crew or wait for invitations from friends.',
|
||||||
|
buttonText: 'PLAN A RIDE',
|
||||||
|
onAction: () => context.push('/group-rides/create'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: rides.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final ride = rides[index];
|
||||||
|
return _buildRideCard(context, ride, currentUid);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () => const Center(
|
||||||
|
child: CircularProgressIndicator(color: Color(0xFF00E5FF)),
|
||||||
|
),
|
||||||
|
error: (e, _) => Center(
|
||||||
|
child: Text('Error loading rides: $e',
|
||||||
|
style: const TextStyle(color: Colors.redAccent)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMyRidesTab(
|
||||||
|
BuildContext context, WidgetRef ref, String currentUid) {
|
||||||
|
final ridesAsync = ref.watch(upcomingGroupRidesProvider);
|
||||||
|
|
||||||
|
return ridesAsync.when(
|
||||||
|
data: (rides) {
|
||||||
|
final myRides =
|
||||||
|
rides.where((r) => r.organizerUid == currentUid).toList();
|
||||||
|
|
||||||
|
if (myRides.isEmpty) {
|
||||||
|
return _buildEmptyState(
|
||||||
|
icon: Icons.sports_motorsports_outlined,
|
||||||
|
title: 'You Haven\'t Created Any Rides',
|
||||||
|
message:
|
||||||
|
'Become the ride organizer: choose a meetup point, set kickstands up, and invite your friends.',
|
||||||
|
buttonText: 'CREATE FIRST RIDE',
|
||||||
|
onAction: () => context.push('/group-rides/create'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: myRides.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final ride = myRides[index];
|
||||||
|
return _buildRideCard(context, ride, currentUid);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () => const Center(
|
||||||
|
child: CircularProgressIndicator(color: Color(0xFF00E5FF)),
|
||||||
|
),
|
||||||
|
error: (e, _) => Center(
|
||||||
|
child: Text('Error: $e',
|
||||||
|
style: const TextStyle(color: Colors.redAccent)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState({
|
||||||
|
required IconData icon,
|
||||||
|
required String title,
|
||||||
|
required String message,
|
||||||
|
required String buttonText,
|
||||||
|
required VoidCallback onAction,
|
||||||
|
}) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 56, color: Colors.white38),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: Colors.white60, fontSize: 12),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
OutlinedButton(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: const Color(0xFF00E5FF),
|
||||||
|
side: const BorderSide(color: Color(0xFF00E5FF)),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: onAction,
|
||||||
|
child: Text(buttonText),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRideCard(
|
||||||
|
BuildContext context, GroupRide ride, String currentUid) {
|
||||||
|
final isOrganizer = ride.organizerUid == currentUid;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
color: const Color(0xFF161922),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
side: BorderSide(
|
||||||
|
color: isOrganizer
|
||||||
|
? const Color(0xFF00E5FF).withValues(alpha: 0.4)
|
||||||
|
: Colors.white12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
onTap: () => context.push('/group-rides/${ride.id}'),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: ride.isCanceled
|
||||||
|
? Colors.redAccent.withValues(alpha: 0.2)
|
||||||
|
: const Color(0xFF00E5FF).withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
ride.status.label.toUpperCase(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: ride.isCanceled
|
||||||
|
? Colors.redAccent
|
||||||
|
: const Color(0xFF00E5FF),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (isOrganizer)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.amber.withValues(alpha: 0.2),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'ORGANIZER',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.amber,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.people_outline,
|
||||||
|
size: 16, color: Colors.white60),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${ride.participantCount}${ride.participantLimit != null ? '/${ride.participantLimit}' : ''}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.white70,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
ride.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.schedule, size: 14, color: Color(0xFF00E5FF)),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'Meet: ${_formatDateTime(ride.scheduledStartAt)} • KSU: ${_formatTime(ride.departureAt)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.white70,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.place_outlined,
|
||||||
|
size: 14, color: Colors.white54),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
ride.meetingPoint.displayName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.white60,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (ride.destination != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.flag_outlined,
|
||||||
|
size: 14, color: Color(0xFFFFB300)),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Destination: ${ride.destination!.displayName}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.white70,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDateTime(DateTime dt) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final isToday =
|
||||||
|
dt.year == now.year && dt.month == now.month && dt.day == now.day;
|
||||||
|
final timeStr = _formatTime(dt);
|
||||||
|
if (isToday) return 'Today at $timeStr';
|
||||||
|
return '${dt.month}/${dt.day} at $timeStr';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTime(DateTime dt) {
|
||||||
|
final hour = dt.hour > 12 ? dt.hour - 12 : (dt.hour == 0 ? 12 : dt.hour);
|
||||||
|
final minute = dt.minute.toString().padLeft(2, '0');
|
||||||
|
final period = dt.hour >= 12 ? 'PM' : 'AM';
|
||||||
|
return '$hour:$minute $period';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -675,6 +675,31 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF00E5FF).withValues(alpha: 0.15),
|
||||||
|
foregroundColor: const Color(0xFF00E5FF),
|
||||||
|
side: const BorderSide(color: Color(0xFF00E5FF)),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.groups_outlined, size: 18),
|
||||||
|
label: const Text(
|
||||||
|
'GROUP RIDES',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: () => context.push('/group-rides'),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,25 @@ class AccountCloudCleanupService implements CloudAccountCleanup {
|
||||||
await firestore.collection('handles').doc(handle.toLowerCase()).delete();
|
await firestore.collection('handles').doc(handle.toLowerCase()).delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Automatically cancel active rides organized by deleted account
|
||||||
|
try {
|
||||||
|
final organizedRides = await firestore
|
||||||
|
.collection('group_rides')
|
||||||
|
.where('organizerUid', isEqualTo: user.uid)
|
||||||
|
.get();
|
||||||
|
for (final rDoc in organizedRides.docs) {
|
||||||
|
final status = rDoc.data()['status'] as String?;
|
||||||
|
if (status != 'canceled' && status != 'completed') {
|
||||||
|
await rDoc.reference.update({
|
||||||
|
'status': 'canceled',
|
||||||
|
'canceledReason': 'Organizer account deleted.',
|
||||||
|
'canceledAt': DateTime.now().toIso8601String(),
|
||||||
|
'updatedAt': DateTime.now().toIso8601String(),
|
||||||
|
}).catchError((_) {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
// Delete public profile projection
|
// Delete public profile projection
|
||||||
await firestore.collection('public_profiles').doc(user.uid).delete().catchError((_) {});
|
await firestore.collection('public_profiles').doc(user.uid).delete().catchError((_) {});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,550 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// Type of group ride location / stop.
|
||||||
|
enum GroupRideLocationType {
|
||||||
|
meetup,
|
||||||
|
fuel,
|
||||||
|
rest,
|
||||||
|
food,
|
||||||
|
scenic,
|
||||||
|
waypoint,
|
||||||
|
destination,
|
||||||
|
other;
|
||||||
|
|
||||||
|
String get label {
|
||||||
|
switch (this) {
|
||||||
|
case GroupRideLocationType.meetup:
|
||||||
|
return 'Meeting Point';
|
||||||
|
case GroupRideLocationType.fuel:
|
||||||
|
return 'Fuel Stop';
|
||||||
|
case GroupRideLocationType.rest:
|
||||||
|
return 'Rest Stop';
|
||||||
|
case GroupRideLocationType.food:
|
||||||
|
return 'Food & Drink';
|
||||||
|
case GroupRideLocationType.scenic:
|
||||||
|
return 'Scenic Overlook';
|
||||||
|
case GroupRideLocationType.waypoint:
|
||||||
|
return 'Waypoint';
|
||||||
|
case GroupRideLocationType.destination:
|
||||||
|
return 'Destination';
|
||||||
|
case GroupRideLocationType.other:
|
||||||
|
return 'Stop';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit shared coordinate and description for group meetups, stops, and destinations.
|
||||||
|
/// Never derived from or tied to private home addresses or live GPS.
|
||||||
|
@immutable
|
||||||
|
class GroupRideLocation {
|
||||||
|
final double latitude;
|
||||||
|
final double longitude;
|
||||||
|
final String displayName;
|
||||||
|
final String address;
|
||||||
|
final GroupRideLocationType locationType;
|
||||||
|
final String notes;
|
||||||
|
final int? stopDurationMinutes;
|
||||||
|
|
||||||
|
const GroupRideLocation({
|
||||||
|
required this.latitude,
|
||||||
|
required this.longitude,
|
||||||
|
required this.displayName,
|
||||||
|
this.address = '',
|
||||||
|
this.locationType = GroupRideLocationType.waypoint,
|
||||||
|
this.notes = '',
|
||||||
|
this.stopDurationMinutes,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'latitude': latitude,
|
||||||
|
'longitude': longitude,
|
||||||
|
'displayName': displayName,
|
||||||
|
'address': address,
|
||||||
|
'locationType': locationType.name,
|
||||||
|
'notes': notes,
|
||||||
|
if (stopDurationMinutes != null)
|
||||||
|
'stopDurationMinutes': stopDurationMinutes,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory GroupRideLocation.fromJson(Map<String, dynamic> json) {
|
||||||
|
return GroupRideLocation(
|
||||||
|
latitude: (json['latitude'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
longitude: (json['longitude'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
displayName: json['displayName'] as String? ?? '',
|
||||||
|
address: json['address'] as String? ?? '',
|
||||||
|
locationType: GroupRideLocationType.values.firstWhere(
|
||||||
|
(t) => t.name == (json['locationType'] as String?),
|
||||||
|
orElse: () => GroupRideLocationType.waypoint,
|
||||||
|
),
|
||||||
|
notes: json['notes'] as String? ?? '',
|
||||||
|
stopDurationMinutes: json['stopDurationMinutes'] as int?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupRideLocation copyWith({
|
||||||
|
double? latitude,
|
||||||
|
double? longitude,
|
||||||
|
String? displayName,
|
||||||
|
String? address,
|
||||||
|
GroupRideLocationType? locationType,
|
||||||
|
String? notes,
|
||||||
|
int? stopDurationMinutes,
|
||||||
|
}) {
|
||||||
|
return GroupRideLocation(
|
||||||
|
latitude: latitude ?? this.latitude,
|
||||||
|
longitude: longitude ?? this.longitude,
|
||||||
|
displayName: displayName ?? this.displayName,
|
||||||
|
address: address ?? this.address,
|
||||||
|
locationType: locationType ?? this.locationType,
|
||||||
|
notes: notes ?? this.notes,
|
||||||
|
stopDurationMinutes: stopDurationMinutes ?? this.stopDurationMinutes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is GroupRideLocation &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
latitude == other.latitude &&
|
||||||
|
longitude == other.longitude &&
|
||||||
|
displayName == other.displayName &&
|
||||||
|
address == other.address &&
|
||||||
|
locationType == other.locationType &&
|
||||||
|
notes == other.notes &&
|
||||||
|
stopDurationMinutes == other.stopDurationMinutes;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
latitude.hashCode ^
|
||||||
|
longitude.hashCode ^
|
||||||
|
displayName.hashCode ^
|
||||||
|
address.hashCode ^
|
||||||
|
locationType.hashCode ^
|
||||||
|
notes.hashCode ^
|
||||||
|
stopDurationMinutes.hashCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Visibility control for discovering and viewing a Group Ride.
|
||||||
|
enum GroupRideVisibility {
|
||||||
|
private,
|
||||||
|
friendsOnly,
|
||||||
|
public;
|
||||||
|
|
||||||
|
String get label {
|
||||||
|
switch (this) {
|
||||||
|
case GroupRideVisibility.private:
|
||||||
|
return 'Private (Invite Only)';
|
||||||
|
case GroupRideVisibility.friendsOnly:
|
||||||
|
return 'Friends Only';
|
||||||
|
case GroupRideVisibility.public:
|
||||||
|
return 'Public (Discoverable)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Policy for joining a Group Ride.
|
||||||
|
enum GroupRideJoinPolicy {
|
||||||
|
inviteOnly,
|
||||||
|
requestToJoin,
|
||||||
|
open;
|
||||||
|
|
||||||
|
String get label {
|
||||||
|
switch (this) {
|
||||||
|
case GroupRideJoinPolicy.inviteOnly:
|
||||||
|
return 'Invite Only';
|
||||||
|
case GroupRideJoinPolicy.requestToJoin:
|
||||||
|
return 'Request to Join';
|
||||||
|
case GroupRideJoinPolicy.open:
|
||||||
|
return 'Open to Join';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Status lifecycle of a Group Ride.
|
||||||
|
enum GroupRideStatus {
|
||||||
|
scheduled,
|
||||||
|
assembling,
|
||||||
|
completed,
|
||||||
|
canceled;
|
||||||
|
|
||||||
|
String get label {
|
||||||
|
switch (this) {
|
||||||
|
case GroupRideStatus.scheduled:
|
||||||
|
return 'Scheduled';
|
||||||
|
case GroupRideStatus.assembling:
|
||||||
|
return 'Assembling';
|
||||||
|
case GroupRideStatus.completed:
|
||||||
|
return 'Completed';
|
||||||
|
case GroupRideStatus.canceled:
|
||||||
|
return 'Canceled';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Role of a participant in a Group Ride.
|
||||||
|
enum GroupRideRole {
|
||||||
|
organizer,
|
||||||
|
participant,
|
||||||
|
lead,
|
||||||
|
sweep;
|
||||||
|
|
||||||
|
String get label {
|
||||||
|
switch (this) {
|
||||||
|
case GroupRideRole.organizer:
|
||||||
|
return 'Organizer';
|
||||||
|
case GroupRideRole.participant:
|
||||||
|
return 'Rider';
|
||||||
|
case GroupRideRole.lead:
|
||||||
|
return 'Ride Lead';
|
||||||
|
case GroupRideRole.sweep:
|
||||||
|
return 'Sweep';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Status of an individual participant in a Group Ride.
|
||||||
|
enum ParticipantStatus {
|
||||||
|
invited,
|
||||||
|
requested,
|
||||||
|
accepted,
|
||||||
|
declined,
|
||||||
|
left,
|
||||||
|
removed;
|
||||||
|
|
||||||
|
bool get isActiveMember => this == ParticipantStatus.accepted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Individual participant membership in a Group Ride.
|
||||||
|
@immutable
|
||||||
|
class GroupRideParticipant {
|
||||||
|
final String userId;
|
||||||
|
final GroupRideRole role;
|
||||||
|
final ParticipantStatus status;
|
||||||
|
final String riderHandle;
|
||||||
|
final String riderDisplayName;
|
||||||
|
final String? riderAvatarPreset;
|
||||||
|
final String? riderAvatarUrl;
|
||||||
|
final String? invitedBy;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
|
||||||
|
const GroupRideParticipant({
|
||||||
|
required this.userId,
|
||||||
|
required this.role,
|
||||||
|
required this.status,
|
||||||
|
required this.riderHandle,
|
||||||
|
required this.riderDisplayName,
|
||||||
|
this.riderAvatarPreset,
|
||||||
|
this.riderAvatarUrl,
|
||||||
|
this.invitedBy,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get displayHandle => '@$riderHandle';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'userId': userId,
|
||||||
|
'role': role.name,
|
||||||
|
'status': status.name,
|
||||||
|
'riderHandle': riderHandle,
|
||||||
|
'riderDisplayName': riderDisplayName,
|
||||||
|
if (riderAvatarPreset != null) 'riderAvatarPreset': riderAvatarPreset,
|
||||||
|
if (riderAvatarUrl != null) 'riderAvatarUrl': riderAvatarUrl,
|
||||||
|
if (invitedBy != null) 'invitedBy': invitedBy,
|
||||||
|
'createdAt': createdAt.toIso8601String(),
|
||||||
|
'updatedAt': updatedAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory GroupRideParticipant.fromJson(Map<String, dynamic> json) {
|
||||||
|
final created = json['createdAt'] != null
|
||||||
|
? DateTime.tryParse(json['createdAt'] as String) ?? DateTime.now()
|
||||||
|
: DateTime.now();
|
||||||
|
final updated = json['updatedAt'] != null
|
||||||
|
? DateTime.tryParse(json['updatedAt'] as String) ?? created
|
||||||
|
: created;
|
||||||
|
|
||||||
|
return GroupRideParticipant(
|
||||||
|
userId: json['userId'] as String? ?? '',
|
||||||
|
role: GroupRideRole.values.firstWhere(
|
||||||
|
(r) => r.name == (json['role'] as String?),
|
||||||
|
orElse: () => GroupRideRole.participant,
|
||||||
|
),
|
||||||
|
status: ParticipantStatus.values.firstWhere(
|
||||||
|
(s) => s.name == (json['status'] as String?),
|
||||||
|
orElse: () => ParticipantStatus.invited,
|
||||||
|
),
|
||||||
|
riderHandle: json['riderHandle'] as String? ?? '',
|
||||||
|
riderDisplayName: json['riderDisplayName'] as String? ?? '',
|
||||||
|
riderAvatarPreset: json['riderAvatarPreset'] as String?,
|
||||||
|
riderAvatarUrl: json['riderAvatarUrl'] as String?,
|
||||||
|
invitedBy: json['invitedBy'] as String?,
|
||||||
|
createdAt: created,
|
||||||
|
updatedAt: updated,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupRideParticipant copyWith({
|
||||||
|
String? userId,
|
||||||
|
GroupRideRole? role,
|
||||||
|
ParticipantStatus? status,
|
||||||
|
String? riderHandle,
|
||||||
|
String? riderDisplayName,
|
||||||
|
String? riderAvatarPreset,
|
||||||
|
String? riderAvatarUrl,
|
||||||
|
String? invitedBy,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
}) {
|
||||||
|
return GroupRideParticipant(
|
||||||
|
userId: userId ?? this.userId,
|
||||||
|
role: role ?? this.role,
|
||||||
|
status: status ?? this.status,
|
||||||
|
riderHandle: riderHandle ?? this.riderHandle,
|
||||||
|
riderDisplayName: riderDisplayName ?? this.riderDisplayName,
|
||||||
|
riderAvatarPreset: riderAvatarPreset ?? this.riderAvatarPreset,
|
||||||
|
riderAvatarUrl: riderAvatarUrl ?? this.riderAvatarUrl,
|
||||||
|
invitedBy: invitedBy ?? this.invitedBy,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is GroupRideParticipant &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
userId == other.userId &&
|
||||||
|
role == other.role &&
|
||||||
|
status == other.status &&
|
||||||
|
riderHandle == other.riderHandle &&
|
||||||
|
riderDisplayName == other.riderDisplayName &&
|
||||||
|
riderAvatarPreset == other.riderAvatarPreset &&
|
||||||
|
riderAvatarUrl == other.riderAvatarUrl &&
|
||||||
|
invitedBy == other.invitedBy;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
userId.hashCode ^
|
||||||
|
role.hashCode ^
|
||||||
|
status.hashCode ^
|
||||||
|
riderHandle.hashCode ^
|
||||||
|
riderDisplayName.hashCode ^
|
||||||
|
riderAvatarPreset.hashCode ^
|
||||||
|
riderAvatarUrl.hashCode ^
|
||||||
|
invitedBy.hashCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core Group Ride model.
|
||||||
|
@immutable
|
||||||
|
class GroupRide {
|
||||||
|
final String id;
|
||||||
|
final String organizerUid;
|
||||||
|
final String organizerHandle;
|
||||||
|
final String organizerDisplayName;
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final GroupRideVisibility visibility;
|
||||||
|
final GroupRideJoinPolicy joinPolicy;
|
||||||
|
final GroupRideStatus status;
|
||||||
|
final DateTime scheduledStartAt; // Meetup / arrival time
|
||||||
|
final DateTime departureAt; // Kickstands up (>= scheduledStartAt)
|
||||||
|
final int? participantLimit;
|
||||||
|
final int participantCount; // Authoritative active member counter
|
||||||
|
final GroupRideLocation meetingPoint;
|
||||||
|
final GroupRideLocation? destination;
|
||||||
|
final List<GroupRideLocation> stops; // Ordered waypoints
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
final DateTime? canceledAt;
|
||||||
|
final String? canceledReason;
|
||||||
|
|
||||||
|
const GroupRide({
|
||||||
|
required this.id,
|
||||||
|
required this.organizerUid,
|
||||||
|
required this.organizerHandle,
|
||||||
|
required this.organizerDisplayName,
|
||||||
|
required this.title,
|
||||||
|
this.description = '',
|
||||||
|
this.visibility = GroupRideVisibility.friendsOnly,
|
||||||
|
this.joinPolicy = GroupRideJoinPolicy.inviteOnly,
|
||||||
|
this.status = GroupRideStatus.scheduled,
|
||||||
|
required this.scheduledStartAt,
|
||||||
|
required this.departureAt,
|
||||||
|
this.participantLimit,
|
||||||
|
this.participantCount = 1,
|
||||||
|
required this.meetingPoint,
|
||||||
|
this.destination,
|
||||||
|
this.stops = const [],
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
this.canceledAt,
|
||||||
|
this.canceledReason,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isCanceled => status == GroupRideStatus.canceled;
|
||||||
|
bool get isCompleted => status == GroupRideStatus.completed;
|
||||||
|
bool get isFull =>
|
||||||
|
participantLimit != null && participantCount >= participantLimit!;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'organizerUid': organizerUid,
|
||||||
|
'organizerHandle': organizerHandle,
|
||||||
|
'organizerDisplayName': organizerDisplayName,
|
||||||
|
'title': title,
|
||||||
|
'description': description,
|
||||||
|
'visibility': visibility.name,
|
||||||
|
'joinPolicy': joinPolicy.name,
|
||||||
|
'status': status.name,
|
||||||
|
'scheduledStartAt': scheduledStartAt.toIso8601String(),
|
||||||
|
'departureAt': departureAt.toIso8601String(),
|
||||||
|
if (participantLimit != null) 'participantLimit': participantLimit,
|
||||||
|
'participantCount': participantCount,
|
||||||
|
'meetingPoint': meetingPoint.toJson(),
|
||||||
|
if (destination != null) 'destination': destination!.toJson(),
|
||||||
|
'stops': stops.map((s) => s.toJson()).toList(),
|
||||||
|
'createdAt': createdAt.toIso8601String(),
|
||||||
|
'updatedAt': updatedAt.toIso8601String(),
|
||||||
|
if (canceledAt != null) 'canceledAt': canceledAt!.toIso8601String(),
|
||||||
|
if (canceledReason != null) 'canceledReason': canceledReason,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory GroupRide.fromJson(Map<String, dynamic> json) {
|
||||||
|
final created = json['createdAt'] != null
|
||||||
|
? DateTime.tryParse(json['createdAt'] as String) ?? DateTime.now()
|
||||||
|
: DateTime.now();
|
||||||
|
final updated = json['updatedAt'] != null
|
||||||
|
? DateTime.tryParse(json['updatedAt'] as String) ?? created
|
||||||
|
: created;
|
||||||
|
final scheduled = json['scheduledStartAt'] != null
|
||||||
|
? DateTime.tryParse(json['scheduledStartAt'] as String) ?? created
|
||||||
|
: created;
|
||||||
|
final departure = json['departureAt'] != null
|
||||||
|
? DateTime.tryParse(json['departureAt'] as String) ?? scheduled
|
||||||
|
: scheduled;
|
||||||
|
final canceled = json['canceledAt'] != null
|
||||||
|
? DateTime.tryParse(json['canceledAt'] as String)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final stopsList = (json['stops'] as List<dynamic>?)
|
||||||
|
?.map((s) => GroupRideLocation.fromJson(s as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
const [];
|
||||||
|
|
||||||
|
return GroupRide(
|
||||||
|
id: json['id'] as String? ?? '',
|
||||||
|
organizerUid: json['organizerUid'] as String? ?? '',
|
||||||
|
organizerHandle: json['organizerHandle'] as String? ?? '',
|
||||||
|
organizerDisplayName: json['organizerDisplayName'] as String? ?? '',
|
||||||
|
title: json['title'] as String? ?? '',
|
||||||
|
description: json['description'] as String? ?? '',
|
||||||
|
visibility: GroupRideVisibility.values.firstWhere(
|
||||||
|
(v) => v.name == (json['visibility'] as String?),
|
||||||
|
orElse: () => GroupRideVisibility.friendsOnly,
|
||||||
|
),
|
||||||
|
joinPolicy: GroupRideJoinPolicy.values.firstWhere(
|
||||||
|
(p) => p.name == (json['joinPolicy'] as String?),
|
||||||
|
orElse: () => GroupRideJoinPolicy.inviteOnly,
|
||||||
|
),
|
||||||
|
status: GroupRideStatus.values.firstWhere(
|
||||||
|
(s) => s.name == (json['status'] as String?),
|
||||||
|
orElse: () => GroupRideStatus.scheduled,
|
||||||
|
),
|
||||||
|
scheduledStartAt: scheduled,
|
||||||
|
departureAt: departure,
|
||||||
|
participantLimit: json['participantLimit'] as int?,
|
||||||
|
participantCount: json['participantCount'] as int? ?? 1,
|
||||||
|
meetingPoint: json['meetingPoint'] != null
|
||||||
|
? GroupRideLocation.fromJson(
|
||||||
|
json['meetingPoint'] as Map<String, dynamic>)
|
||||||
|
: const GroupRideLocation(
|
||||||
|
latitude: 0,
|
||||||
|
longitude: 0,
|
||||||
|
displayName: 'TBD Meetup',
|
||||||
|
),
|
||||||
|
destination: json['destination'] != null
|
||||||
|
? GroupRideLocation.fromJson(
|
||||||
|
json['destination'] as Map<String, dynamic>)
|
||||||
|
: null,
|
||||||
|
stops: stopsList,
|
||||||
|
createdAt: created,
|
||||||
|
updatedAt: updated,
|
||||||
|
canceledAt: canceled,
|
||||||
|
canceledReason: json['canceledReason'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupRide copyWith({
|
||||||
|
String? id,
|
||||||
|
String? organizerUid,
|
||||||
|
String? organizerHandle,
|
||||||
|
String? organizerDisplayName,
|
||||||
|
String? title,
|
||||||
|
String? description,
|
||||||
|
GroupRideVisibility? visibility,
|
||||||
|
GroupRideJoinPolicy? joinPolicy,
|
||||||
|
GroupRideStatus? status,
|
||||||
|
DateTime? scheduledStartAt,
|
||||||
|
DateTime? departureAt,
|
||||||
|
int? participantLimit,
|
||||||
|
int? participantCount,
|
||||||
|
GroupRideLocation? meetingPoint,
|
||||||
|
GroupRideLocation? destination,
|
||||||
|
List<GroupRideLocation>? stops,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
DateTime? canceledAt,
|
||||||
|
String? canceledReason,
|
||||||
|
}) {
|
||||||
|
return GroupRide(
|
||||||
|
id: id ?? this.id,
|
||||||
|
organizerUid: organizerUid ?? this.organizerUid,
|
||||||
|
organizerHandle: organizerHandle ?? this.organizerHandle,
|
||||||
|
organizerDisplayName: organizerDisplayName ?? this.organizerDisplayName,
|
||||||
|
title: title ?? this.title,
|
||||||
|
description: description ?? this.description,
|
||||||
|
visibility: visibility ?? this.visibility,
|
||||||
|
joinPolicy: joinPolicy ?? this.joinPolicy,
|
||||||
|
status: status ?? this.status,
|
||||||
|
scheduledStartAt: scheduledStartAt ?? this.scheduledStartAt,
|
||||||
|
departureAt: departureAt ?? this.departureAt,
|
||||||
|
participantLimit: participantLimit ?? this.participantLimit,
|
||||||
|
participantCount: participantCount ?? this.participantCount,
|
||||||
|
meetingPoint: meetingPoint ?? this.meetingPoint,
|
||||||
|
destination: destination ?? this.destination,
|
||||||
|
stops: stops ?? this.stops,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
canceledAt: canceledAt ?? this.canceledAt,
|
||||||
|
canceledReason: canceledReason ?? this.canceledReason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is GroupRide &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
id == other.id &&
|
||||||
|
organizerUid == other.organizerUid &&
|
||||||
|
title == other.title &&
|
||||||
|
status == other.status &&
|
||||||
|
scheduledStartAt == other.scheduledStartAt &&
|
||||||
|
departureAt == other.departureAt &&
|
||||||
|
participantCount == other.participantCount &&
|
||||||
|
participantLimit == other.participantLimit;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
id.hashCode ^
|
||||||
|
organizerUid.hashCode ^
|
||||||
|
title.hashCode ^
|
||||||
|
status.hashCode ^
|
||||||
|
scheduledStartAt.hashCode ^
|
||||||
|
departureAt.hashCode ^
|
||||||
|
participantCount.hashCode ^
|
||||||
|
participantLimit.hashCode;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../auth/auth_providers.dart';
|
||||||
|
import 'group_ride_models.dart';
|
||||||
|
import 'group_ride_service.dart';
|
||||||
|
|
||||||
|
/// Provider for GroupRideService.
|
||||||
|
final groupRideServiceProvider = Provider<GroupRideService>((ref) {
|
||||||
|
return GroupRideService();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Stream of a specific Group Ride by ID.
|
||||||
|
final groupRideStreamProvider =
|
||||||
|
StreamProvider.family<GroupRide?, String>((ref, rideId) {
|
||||||
|
final service = ref.watch(groupRideServiceProvider);
|
||||||
|
return service.groupRideStream(rideId);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Stream of participants for a specific Group Ride.
|
||||||
|
final groupRideParticipantsProvider =
|
||||||
|
StreamProvider.family<List<GroupRideParticipant>, String>((ref, rideId) {
|
||||||
|
final service = ref.watch(groupRideServiceProvider);
|
||||||
|
return service.participantsStream(rideId);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Stream of upcoming Group Rides for the current user.
|
||||||
|
final upcomingGroupRidesProvider = StreamProvider<List<GroupRide>>((ref) {
|
||||||
|
final user = ref.watch(currentUserProvider);
|
||||||
|
if (user == null || user.isAnonymous) {
|
||||||
|
return Stream.value([]);
|
||||||
|
}
|
||||||
|
final service = ref.watch(groupRideServiceProvider);
|
||||||
|
return service.upcomingRidesStream(callerUid: user.uid);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,729 @@
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:motorcycle_dash/services/group_rides/group_ride_models.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('GroupRideLocation Model Tests', () {
|
||||||
|
test('serializes and deserializes meeting location correctly', () {
|
||||||
|
const loc = GroupRideLocation(
|
||||||
|
latitude: 34.5378,
|
||||||
|
longitude: -83.9849,
|
||||||
|
displayName: 'Dahlonega Square',
|
||||||
|
address: '123 Main St, Dahlonega, GA',
|
||||||
|
locationType: GroupRideLocationType.meetup,
|
||||||
|
notes: 'Meet near north corner',
|
||||||
|
stopDurationMinutes: 15,
|
||||||
|
);
|
||||||
|
|
||||||
|
final json = loc.toJson();
|
||||||
|
expect(json['latitude'], equals(34.5378));
|
||||||
|
expect(json['longitude'], equals(-83.9849));
|
||||||
|
expect(json['displayName'], equals('Dahlonega Square'));
|
||||||
|
expect(json['locationType'], equals('meetup'));
|
||||||
|
expect(json['stopDurationMinutes'], equals(15));
|
||||||
|
|
||||||
|
final restored = GroupRideLocation.fromJson(json);
|
||||||
|
expect(restored, equals(loc));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports intermediate stops and location types', () {
|
||||||
|
expect(GroupRideLocationType.fuel.label, equals('Fuel Stop'));
|
||||||
|
expect(GroupRideLocationType.scenic.label, equals('Scenic Overlook'));
|
||||||
|
expect(GroupRideLocationType.food.label, equals('Food & Drink'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('GroupRide Model Tests', () {
|
||||||
|
test('serializes and deserializes full group ride', () {
|
||||||
|
final now = DateTime(2026, 9, 3, 10, 0);
|
||||||
|
final start = DateTime(2026, 9, 10, 9, 0);
|
||||||
|
final departure = DateTime(2026, 9, 10, 9, 30);
|
||||||
|
|
||||||
|
final ride = GroupRide(
|
||||||
|
id: 'ride_101',
|
||||||
|
organizerUid: 'alice',
|
||||||
|
organizerHandle: 'alice_apex',
|
||||||
|
organizerDisplayName: 'Alice Apex',
|
||||||
|
title: 'Blood Mountain Run',
|
||||||
|
description: 'Twisty mountain loop.',
|
||||||
|
visibility: GroupRideVisibility.friendsOnly,
|
||||||
|
joinPolicy: GroupRideJoinPolicy.inviteOnly,
|
||||||
|
status: GroupRideStatus.scheduled,
|
||||||
|
scheduledStartAt: start,
|
||||||
|
departureAt: departure,
|
||||||
|
participantLimit: 8,
|
||||||
|
participantCount: 3,
|
||||||
|
meetingPoint: const GroupRideLocation(
|
||||||
|
latitude: 34.5,
|
||||||
|
longitude: -83.9,
|
||||||
|
displayName: 'Meetup Chevron',
|
||||||
|
locationType: GroupRideLocationType.meetup,
|
||||||
|
),
|
||||||
|
destination: const GroupRideLocation(
|
||||||
|
latitude: 34.9,
|
||||||
|
longitude: -83.8,
|
||||||
|
displayName: 'Brasstown Bald',
|
||||||
|
locationType: GroupRideLocationType.destination,
|
||||||
|
),
|
||||||
|
stops: const [
|
||||||
|
GroupRideLocation(
|
||||||
|
latitude: 34.7,
|
||||||
|
longitude: -83.85,
|
||||||
|
displayName: 'Vogel State Park',
|
||||||
|
locationType: GroupRideLocationType.rest,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
final json = ride.toJson();
|
||||||
|
expect(json['id'], equals('ride_101'));
|
||||||
|
expect(json['organizerUid'], equals('alice'));
|
||||||
|
expect(json['title'], equals('Blood Mountain Run'));
|
||||||
|
expect(json['visibility'], equals('friendsOnly'));
|
||||||
|
expect(json['participantLimit'], equals(8));
|
||||||
|
expect(json['participantCount'], equals(3));
|
||||||
|
expect((json['stops'] as List).length, equals(1));
|
||||||
|
|
||||||
|
final restored = GroupRide.fromJson(json);
|
||||||
|
expect(restored.id, equals(ride.id));
|
||||||
|
expect(restored.organizerUid, equals(ride.organizerUid));
|
||||||
|
expect(restored.title, equals(ride.title));
|
||||||
|
expect(restored.visibility, equals(GroupRideVisibility.friendsOnly));
|
||||||
|
expect(restored.isFull, isFalse);
|
||||||
|
expect(restored.isCanceled, isFalse);
|
||||||
|
expect(restored.stops.length, equals(1));
|
||||||
|
expect(restored.stops.first.displayName, equals('Vogel State Park'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('participant capacity check respects limit', () {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final ride = GroupRide(
|
||||||
|
id: 'ride_full',
|
||||||
|
organizerUid: 'alice',
|
||||||
|
organizerHandle: 'alice_apex',
|
||||||
|
organizerDisplayName: 'Alice',
|
||||||
|
title: 'Tight Crew',
|
||||||
|
scheduledStartAt: now,
|
||||||
|
departureAt: now,
|
||||||
|
participantLimit: 2,
|
||||||
|
participantCount: 2,
|
||||||
|
meetingPoint: const GroupRideLocation(
|
||||||
|
latitude: 0,
|
||||||
|
longitude: 0,
|
||||||
|
displayName: 'Base',
|
||||||
|
),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ride.isFull, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('GroupRideParticipant Model Tests', () {
|
||||||
|
test('serializes and deserializes participant', () {
|
||||||
|
final now = DateTime(2026, 9, 3, 12, 0);
|
||||||
|
final participant = GroupRideParticipant(
|
||||||
|
userId: 'bob',
|
||||||
|
role: GroupRideRole.participant,
|
||||||
|
status: ParticipantStatus.accepted,
|
||||||
|
riderHandle: 'bob_rider',
|
||||||
|
riderDisplayName: 'Bob Rider',
|
||||||
|
riderAvatarPreset: 'helmet_amber',
|
||||||
|
invitedBy: 'alice',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
final json = participant.toJson();
|
||||||
|
expect(json['userId'], equals('bob'));
|
||||||
|
expect(json['role'], equals('participant'));
|
||||||
|
expect(json['status'], equals('accepted'));
|
||||||
|
expect(json['riderHandle'], equals('bob_rider'));
|
||||||
|
expect(json['invitedBy'], equals('alice'));
|
||||||
|
|
||||||
|
final restored = GroupRideParticipant.fromJson(json);
|
||||||
|
expect(restored.userId, equals('bob'));
|
||||||
|
expect(restored.status.isActiveMember, isTrue);
|
||||||
|
expect(restored.displayHandle, equals('@bob_rider'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:motorcycle_dash/services/group_rides/group_ride_models.dart';
|
||||||
|
import 'package:motorcycle_dash/services/group_rides/group_ride_service.dart';
|
||||||
|
import 'package:motorcycle_dash/services/social/rider_profile_models.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('GroupRideService Offline & Validation Tests', () {
|
||||||
|
final organizer = RiderProfile(
|
||||||
|
uid: 'user_alice',
|
||||||
|
handle: 'alice_apex',
|
||||||
|
displayName: 'Alice Apex',
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
test('rejects ride creation with invalid title', () async {
|
||||||
|
final service = GroupRideService();
|
||||||
|
const meetup = GroupRideLocation(
|
||||||
|
latitude: 34.0,
|
||||||
|
longitude: -84.0,
|
||||||
|
displayName: 'Meetup',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => service.createGroupRide(
|
||||||
|
organizer: organizer,
|
||||||
|
title: 'Hi', // too short (< 3)
|
||||||
|
scheduledStartAt: DateTime.now(),
|
||||||
|
departureAt: DateTime.now().add(const Duration(minutes: 30)),
|
||||||
|
meetingPoint: meetup,
|
||||||
|
),
|
||||||
|
throwsA(isA<ArgumentError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects departure before arrival time', () async {
|
||||||
|
final service = GroupRideService();
|
||||||
|
const meetup = GroupRideLocation(
|
||||||
|
latitude: 34.0,
|
||||||
|
longitude: -84.0,
|
||||||
|
displayName: 'Meetup',
|
||||||
|
);
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
expect(
|
||||||
|
() => service.createGroupRide(
|
||||||
|
organizer: organizer,
|
||||||
|
title: 'Morning Cruise',
|
||||||
|
scheduledStartAt: now.add(const Duration(hours: 2)),
|
||||||
|
departureAt: now.add(const Duration(hours: 1)), // earlier!
|
||||||
|
meetingPoint: meetup,
|
||||||
|
),
|
||||||
|
throwsA(isA<ArgumentError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects participant limit less than 2', () async {
|
||||||
|
final service = GroupRideService();
|
||||||
|
const meetup = GroupRideLocation(
|
||||||
|
latitude: 34.0,
|
||||||
|
longitude: -84.0,
|
||||||
|
displayName: 'Meetup',
|
||||||
|
);
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
expect(
|
||||||
|
() => service.createGroupRide(
|
||||||
|
organizer: organizer,
|
||||||
|
title: 'Solo Ride',
|
||||||
|
scheduledStartAt: now,
|
||||||
|
departureAt: now.add(const Duration(minutes: 30)),
|
||||||
|
participantLimit: 1, // Invalid!
|
||||||
|
meetingPoint: meetup,
|
||||||
|
),
|
||||||
|
throwsA(isA<ArgumentError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates offline fallback ride safely when unconfigured', () async {
|
||||||
|
final service = GroupRideService();
|
||||||
|
const meetup = GroupRideLocation(
|
||||||
|
latitude: 34.5,
|
||||||
|
longitude: -83.9,
|
||||||
|
displayName: 'Dahlonega',
|
||||||
|
);
|
||||||
|
|
||||||
|
final start = DateTime.now().add(const Duration(days: 1));
|
||||||
|
final dep = start.add(const Duration(minutes: 30));
|
||||||
|
|
||||||
|
final ride = await service.createGroupRide(
|
||||||
|
organizer: organizer,
|
||||||
|
title: 'Sunday Morning Run',
|
||||||
|
description: 'Twisty mountain roads',
|
||||||
|
visibility: GroupRideVisibility.friendsOnly,
|
||||||
|
scheduledStartAt: start,
|
||||||
|
departureAt: dep,
|
||||||
|
meetingPoint: meetup,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ride.title, equals('Sunday Morning Run'));
|
||||||
|
expect(ride.organizerUid, equals('user_alice'));
|
||||||
|
expect(ride.meetingPoint.displayName, equals('Dahlonega'));
|
||||||
|
expect(ride.participantCount, equals(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cannot invite self to group ride', () async {
|
||||||
|
final service = GroupRideService();
|
||||||
|
expect(
|
||||||
|
() => service.inviteFriend(
|
||||||
|
callerUid: 'user_alice',
|
||||||
|
groupRideId: 'ride_1',
|
||||||
|
friendProfile: organizer, // same UID!
|
||||||
|
),
|
||||||
|
throwsA(isA<ArgumentError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:motorcycle_dash/features/group_rides/group_rides_hub_screen.dart';
|
||||||
|
import 'package:motorcycle_dash/features/group_rides/create_group_ride_screen.dart';
|
||||||
|
import 'package:motorcycle_dash/services/auth/auth_user.dart';
|
||||||
|
import 'package:motorcycle_dash/services/auth/auth_providers.dart';
|
||||||
|
import 'package:motorcycle_dash/services/group_rides/group_ride_models.dart';
|
||||||
|
import 'package:motorcycle_dash/services/group_rides/group_ride_providers.dart';
|
||||||
|
import 'package:motorcycle_dash/services/social/rider_profile_models.dart';
|
||||||
|
import 'package:motorcycle_dash/services/social/social_providers.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('GroupRidesHubScreen Widget Tests', () {
|
||||||
|
testWidgets('shows account required notice when signed out',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWithValue(null),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: GroupRidesHubScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Rider Account Required'), findsOneWidget);
|
||||||
|
expect(find.text('SIGN IN / CREATE ACCOUNT'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders tabs and empty states when authenticated',
|
||||||
|
(tester) async {
|
||||||
|
const user = AuthUser(
|
||||||
|
uid: 'alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
isAnonymous: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWithValue(user),
|
||||||
|
upcomingGroupRidesProvider.overrideWith(
|
||||||
|
(ref) => Stream.value([]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: GroupRidesHubScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('UPCOMING RIDES'), findsOneWidget);
|
||||||
|
expect(find.text('MY RIDES'), findsOneWidget);
|
||||||
|
expect(find.text('No Upcoming Group Rides'), findsOneWidget);
|
||||||
|
expect(find.text('NEW RIDE'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders upcoming ride card when data present',
|
||||||
|
(tester) async {
|
||||||
|
const user = AuthUser(
|
||||||
|
uid: 'alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
isAnonymous: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final ride = GroupRide(
|
||||||
|
id: 'ride_1',
|
||||||
|
organizerUid: 'bob',
|
||||||
|
organizerHandle: 'bob_fast',
|
||||||
|
organizerDisplayName: 'Bob Fast',
|
||||||
|
title: 'Sunday Morning Twisties',
|
||||||
|
scheduledStartAt: now.add(const Duration(days: 1)),
|
||||||
|
departureAt: now.add(const Duration(days: 1, minutes: 30)),
|
||||||
|
participantCount: 3,
|
||||||
|
meetingPoint: const GroupRideLocation(
|
||||||
|
latitude: 34.0,
|
||||||
|
longitude: -84.0,
|
||||||
|
displayName: 'Dahlonega Chevron',
|
||||||
|
),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWithValue(user),
|
||||||
|
upcomingGroupRidesProvider.overrideWith(
|
||||||
|
(ref) => Stream.value([ride]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: GroupRidesHubScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Sunday Morning Twisties'), findsOneWidget);
|
||||||
|
expect(find.textContaining('Dahlonega Chevron'), findsOneWidget);
|
||||||
|
expect(find.text('3'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CreateGroupRideScreen Widget Tests', () {
|
||||||
|
testWidgets('renders form fields and validates title', (tester) async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final profile = RiderProfile(
|
||||||
|
uid: 'alice',
|
||||||
|
handle: 'alice_apex',
|
||||||
|
displayName: 'Alice Apex',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
myRiderProfileProvider.overrideWith(
|
||||||
|
(ref) => Stream.value(profile),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: CreateGroupRideScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('CREATE GROUP RIDE'), findsOneWidget);
|
||||||
|
expect(find.text('1. RIDE DETAILS'), findsOneWidget);
|
||||||
|
expect(find.text('2. DATE & SCHEDULE'), findsOneWidget);
|
||||||
|
expect(find.text('3. MEETING LOCATION'), findsOneWidget);
|
||||||
|
|
||||||
|
// Attempt submit without filling title
|
||||||
|
await tester.tap(find.text('PUBLISH'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Title must be at least 3 characters'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue