feat: add group rides and meeting locations

This commit is contained in:
Developer 2026-09-03 20:19:51 -04:00
parent 0c2e1a0cac
commit f464d52462
14 changed files with 4311 additions and 2 deletions

View File

@ -371,4 +371,152 @@ describe('Social Architecture Security Rules', () => {
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',
}));
});
});
});

View File

@ -187,8 +187,109 @@ service cloud.firestore {
}
}
match /groupRides/{groupRideId} {
allow read, write: if false;
// --- GROUP RIDES ---
// 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=**} {

View File

@ -19,6 +19,9 @@ import '../features/settings/account_screen.dart';
import '../features/entitlements/pro_screen.dart';
import '../features/social/rider_profile_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
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);
},
),
],
),
],
);

View File

@ -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

View File

@ -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';
}
}

View File

@ -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),
SizedBox(
width: double.infinity,

View File

@ -126,6 +126,25 @@ class AccountCloudCleanupService implements CloudAccountCleanup {
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
await firestore.collection('public_profiles').doc(user.uid).delete().catchError((_) {});

View File

@ -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;
}

View File

@ -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);
});

View File

@ -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();
});
}
}

View File

@ -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'));
});
});
}

View File

@ -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>()),
);
});
});
}

View File

@ -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);
});
});
}