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',
|
||||
}));
|
||||
});
|
||||
|
||||
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} {
|
||||
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=**} {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
|
|
|
|||
|
|
@ -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((_) {});
|
||||
|
||||
|
|
|
|||
|
|
@ -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