diff --git a/firebase/emulator-tests/firestore.rules.test.js b/firebase/emulator-tests/firestore.rules.test.js index dc281b8..68afa82 100644 --- a/firebase/emulator-tests/firestore.rules.test.js +++ b/firebase/emulator-tests/firestore.rules.test.js @@ -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', + })); + }); + }); }); diff --git a/firestore.rules b/firestore.rules index 2f68d09..18d4e71 100644 --- a/firestore.rules +++ b/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=**} { diff --git a/lib/core/router.dart b/lib/core/router.dart index 17e72de..f6f428d 100644 --- a/lib/core/router.dart +++ b/lib/core/router.dart @@ -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(); @@ -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); + }, + ), + ], + ), ], ); diff --git a/lib/features/group_rides/create_group_ride_screen.dart b/lib/features/group_rides/create_group_ride_screen.dart new file mode 100644 index 0000000..e2c9155 --- /dev/null +++ b/lib/features/group_rides/create_group_ride_screen.dart @@ -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 createState() => + _CreateGroupRideScreenState(); +} + +class _CreateGroupRideScreenState extends ConsumerState { + final _formKey = GlobalKey(); + + 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 _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 _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( + 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( + 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( + 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)), + ), + ); + } +} diff --git a/lib/features/group_rides/group_ride_detail_screen.dart b/lib/features/group_rides/group_ride_detail_screen.dart new file mode 100644 index 0000000..5ce29e8 --- /dev/null +++ b/lib/features/group_rides/group_ride_detail_screen.dart @@ -0,0 +1,1163 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../../services/auth/auth_providers.dart'; +import '../../services/group_rides/group_ride_models.dart'; +import '../../services/group_rides/group_ride_providers.dart'; +import '../../services/social/social_providers.dart'; + +class GroupRideDetailScreen extends ConsumerStatefulWidget { + final String rideId; + + const GroupRideDetailScreen({super.key, required this.rideId}); + + @override + ConsumerState createState() => + _GroupRideDetailScreenState(); +} + +class _GroupRideDetailScreenState extends ConsumerState { + bool _isProcessing = false; + String? _statusMessage; + + Future _launchMapNavigation(GroupRideLocation loc) async { + final query = Uri.encodeComponent( + loc.address.isNotEmpty + ? loc.address + : '${loc.latitude},${loc.longitude}', + ); + final uri = Uri.parse('https://www.google.com/maps/search/?api=1&query=$query'); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not open map navigation application.')), + ); + } + } + } + + void _showInviteFriendsSheet(BuildContext context, GroupRide ride) { + final friendsAsync = ref.read(myFriendsProvider); + final service = ref.read(groupRideServiceProvider); + + showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF161922), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (ctx) { + return friendsAsync.when( + data: (friends) { + if (friends.isEmpty) { + return const Padding( + padding: EdgeInsets.all(32.0), + child: Center( + child: Text( + 'No friends found to invite. Add friends from the Friends screen first.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white70), + ), + ), + ); + } + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + const Text( + 'INVITE FRIENDS', + style: TextStyle( + color: Color(0xFF00E5FF), + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + ), + ), + const SizedBox(height: 12), + for (final f in friends) + ListTile( + leading: CircleAvatar( + backgroundColor: Colors.white10, + child: Text(f.displayName.isNotEmpty + ? f.displayName[0].toUpperCase() + : '?'), + ), + title: Text(f.displayName, + style: const TextStyle(color: Colors.white)), + subtitle: Text(f.displayHandle, + style: const TextStyle(color: Colors.white54)), + trailing: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00E5FF), + foregroundColor: Colors.black, + ), + onPressed: () async { + try { + final currentProfile = + ref.read(myRiderProfileProvider).value; + if (currentProfile == null) return; + + final targetProfile = await ref + .read(riderProfileServiceProvider) + .getProfile(f.uid); + if (targetProfile == null) return; + + await service.inviteFriend( + callerUid: currentProfile.uid, + groupRideId: ride.id, + friendProfile: targetProfile, + ); + + if (ctx.mounted) { + Navigator.pop(ctx); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Invited ${f.displayName}!'), + ), + ); + } + } catch (e) { + if (ctx.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } + }, + child: const Text('INVITE'), + ), + ), + ], + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final rideAsync = ref.watch(groupRideStreamProvider(widget.rideId)); + final participantsAsync = + ref.watch(groupRideParticipantsProvider(widget.rideId)); + final user = ref.watch(currentUserProvider); + final currentUid = user?.uid; + + return Scaffold( + backgroundColor: const Color(0xFF0D0E11), + appBar: AppBar( + backgroundColor: const Color(0xFF13151A), + title: const Text( + 'GROUP RIDE', + style: TextStyle( + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + fontSize: 15, + ), + ), + ), + body: rideAsync.when( + data: (ride) { + if (ride == null) { + return const Center( + child: Text('Group ride not found.', + style: TextStyle(color: Colors.white70)), + ); + } + + final isOrganizer = currentUid != null && ride.organizerUid == currentUid; + + return participantsAsync.when( + data: (participants) { + GroupRideParticipant? myParticipant; + try { + myParticipant = + participants.firstWhere((p) => p.userId == currentUid); + } catch (_) {} + + return ListView( + padding: const EdgeInsets.all(20), + children: [ + if (_statusMessage != null) + Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.cyanAccent.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.cyanAccent), + ), + child: Text(_statusMessage!, + style: const TextStyle( + color: Colors.cyanAccent, fontSize: 13)), + ), + + // Header card + _buildHeaderCard(context, ride, isOrganizer), + const SizedBox(height: 16), + + // Dynamic action banner based on membership/organizer state + _buildActionBanner( + context, + ride, + myParticipant, + isOrganizer, + currentUid, + ), + const SizedBox(height: 16), + + // Schedule card + _buildScheduleCard(ride), + const SizedBox(height: 16), + + // Meeting location & Destination + _buildLocationsCard(ride), + const SizedBox(height: 16), + + // Intermediate stops + if (ride.stops.isNotEmpty) ...[ + _buildStopsCard(ride), + const SizedBox(height: 16), + ], + + // Participants roster + _buildParticipantsCard( + ride, + participants, + isOrganizer, + currentUid, + ), + const SizedBox(height: 32), + ], + ); + }, + loading: () => const Center( + child: CircularProgressIndicator(color: Color(0xFF00E5FF))), + error: (e, _) => Center( + child: Text('Error loading participants: $e', + style: const TextStyle(color: Colors.redAccent))), + ); + }, + loading: () => const Center( + child: CircularProgressIndicator(color: Color(0xFF00E5FF))), + error: (e, _) => Center( + child: Text('Error loading ride: $e', + style: const TextStyle(color: Colors.redAccent))), + ), + ); + } + + Widget _buildHeaderCard( + BuildContext context, GroupRide ride, bool isOrganizer) { + return Card( + color: const Color(0xFF161922), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(18), + 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), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.white10, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + ride.visibility.label.toUpperCase(), + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.white70, + ), + ), + ), + const Spacer(), + if (isOrganizer && !ride.isCanceled) + PopupMenuButton( + icon: const Icon(Icons.more_vert, color: Colors.white70), + color: const Color(0xFF1B1E28), + onSelected: (val) async { + if (val == 'invite') { + _showInviteFriendsSheet(context, ride); + } else if (val == 'cancel') { + _confirmCancelRide(ride); + } + }, + itemBuilder: (ctx) => [ + const PopupMenuItem( + value: 'invite', + child: Text('Invite Friends', + style: TextStyle(color: Colors.white)), + ), + const PopupMenuItem( + value: 'cancel', + child: Text('Cancel Ride', + style: TextStyle(color: Colors.redAccent)), + ), + ], + ), + ], + ), + const SizedBox(height: 12), + Text( + ride.title, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w900, + color: Colors.white, + ), + ), + const SizedBox(height: 6), + Text( + 'Organized by @${ride.organizerHandle} (${ride.organizerDisplayName})', + style: const TextStyle(color: Color(0xFF00E5FF), fontSize: 13), + ), + if (ride.description.isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + ride.description, + style: const TextStyle( + color: Colors.white70, fontSize: 13, height: 1.4), + ), + ], + if (ride.canceledReason != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.redAccent.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: Colors.redAccent.withValues(alpha: 0.4)), + ), + child: Row( + children: [ + const Icon(Icons.info_outline, + color: Colors.redAccent, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Canceled: ${ride.canceledReason}', + style: const TextStyle( + color: Colors.redAccent, fontSize: 12), + ), + ), + ], + ), + ), + ], + ], + ), + ), + ); + } + + Widget _buildActionBanner( + BuildContext context, + GroupRide ride, + GroupRideParticipant? myParticipant, + bool isOrganizer, + String? currentUid, + ) { + if (currentUid == null) { + return Card( + color: const Color(0xFF161922), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + const Text( + 'Sign in with your MotorSignal account to join this group ride.', + style: TextStyle(color: Colors.white70, fontSize: 13), + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00E5FF), + foregroundColor: Colors.black, + ), + onPressed: () => context.push('/settings/account'), + child: const Text('SIGN IN'), + ), + ], + ), + ), + ); + } + + if (ride.isCanceled) { + return const SizedBox.shrink(); + } + + final service = ref.read(groupRideServiceProvider); + + // Case 1: Invited -> Accept or Decline + if (myParticipant != null && + myParticipant.status == ParticipantStatus.invited) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF00E5FF).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFF00E5FF)), + ), + child: Column( + children: [ + const Text( + 'You have been invited to this Group Ride!', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white70, + side: const BorderSide(color: Colors.white30), + ), + onPressed: _isProcessing + ? null + : () async { + setState(() => _isProcessing = true); + await service.respondToInvitation( + callerUid: currentUid, + groupRideId: ride.id, + accept: false, + ); + setState(() => _isProcessing = false); + }, + child: const Text('DECLINE'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00E5FF), + foregroundColor: Colors.black, + ), + onPressed: _isProcessing + ? null + : () async { + setState(() => _isProcessing = true); + await service.respondToInvitation( + callerUid: currentUid, + groupRideId: ride.id, + accept: true, + ); + setState(() => _isProcessing = false); + }, + child: const Text('ACCEPT & JOIN'), + ), + ), + ], + ), + ], + ), + ); + } + + // Case 2: Requested -> Pending approval + if (myParticipant != null && + myParticipant.status == ParticipantStatus.requested) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.amber.withValues(alpha: 0.4)), + ), + child: const Row( + children: [ + Icon(Icons.hourglass_top, color: Colors.amber, size: 20), + SizedBox(width: 10), + Expanded( + child: Text( + 'Join request pending organizer approval.', + style: TextStyle(color: Colors.amber, fontSize: 13), + ), + ), + ], + ), + ); + } + + // Case 3: Accepted participant -> Leave option + if (myParticipant != null && + myParticipant.status == ParticipantStatus.accepted && + !isOrganizer) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Row( + children: [ + Icon(Icons.check_circle, color: Color(0xFF00E5FF), size: 18), + SizedBox(width: 6), + Text( + 'You are participating in this ride', + style: TextStyle(color: Colors.white70, fontSize: 13), + ), + ], + ), + TextButton( + onPressed: () => _confirmLeaveRide(ride, currentUid), + child: const Text('LEAVE RIDE', + style: TextStyle(color: Colors.redAccent)), + ), + ], + ); + } + + // Case 4: Non-participant, open or requestable ride + if (myParticipant == null && !isOrganizer) { + if (ride.joinPolicy == GroupRideJoinPolicy.open) { + return ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00E5FF), + foregroundColor: Colors.black, + minimumSize: const Size.fromHeight(46), + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + icon: const Icon(Icons.group_add), + label: const Text('JOIN GROUP RIDE', + style: TextStyle(fontWeight: FontWeight.bold)), + onPressed: _isProcessing + ? null + : () async { + final profile = ref.read(myRiderProfileProvider).value; + if (profile == null) return; + setState(() => _isProcessing = true); + try { + await service.joinOpenRide( + caller: profile, + groupRideId: ride.id, + ); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error joining ride: $e')), + ); + } + if (mounted) setState(() => _isProcessing = false); + }, + ); + } else if (ride.joinPolicy == GroupRideJoinPolicy.requestToJoin) { + return OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF00E5FF), + side: const BorderSide(color: Color(0xFF00E5FF)), + minimumSize: const Size.fromHeight(46), + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + icon: const Icon(Icons.send), + label: const Text('REQUEST TO JOIN'), + onPressed: _isProcessing + ? null + : () async { + final profile = ref.read(myRiderProfileProvider).value; + if (profile == null) return; + setState(() => _isProcessing = true); + try { + await service.requestToJoin( + caller: profile, + groupRideId: ride.id, + ); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error requesting join: $e')), + ); + } + if (mounted) setState(() => _isProcessing = false); + }, + ); + } + } + + return const SizedBox.shrink(); + } + + Widget _buildScheduleCard(GroupRide ride) { + return Card( + color: const Color(0xFF161922), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.schedule, color: Color(0xFF00E5FF), size: 18), + SizedBox(width: 8), + Text( + 'SCHEDULE', + style: TextStyle( + color: Color(0xFF00E5FF), + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + fontSize: 12, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Meetup Arrival', + style: TextStyle(color: Colors.white60, fontSize: 12)), + const SizedBox(height: 4), + Text( + _formatTime(ride.scheduledStartAt), + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Text( + '${ride.scheduledStartAt.month}/${ride.scheduledStartAt.day}/${ride.scheduledStartAt.year}', + style: const TextStyle(color: Colors.white54, fontSize: 11), + ), + ], + ), + ), + const Icon(Icons.arrow_forward, color: Colors.white24), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const Text('Kickstands Up (KSU)', + style: TextStyle(color: Colors.white60, fontSize: 12)), + const SizedBox(height: 4), + Text( + _formatTime(ride.departureAt), + style: const TextStyle( + color: Color(0xFF00E5FF), + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Text( + 'Departure', + style: const TextStyle(color: Colors.white54, fontSize: 11), + ), + ], + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildLocationsCard(GroupRide ride) { + return Card( + color: const Color(0xFF161922), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.place_outlined, color: Color(0xFF00E5FF), size: 18), + SizedBox(width: 8), + Text( + 'MEETING POINT & DESTINATION', + style: TextStyle( + color: Color(0xFF00E5FF), + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + fontSize: 12, + ), + ), + ], + ), + const SizedBox(height: 14), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const CircleAvatar( + radius: 12, + backgroundColor: Color(0xFF00E5FF), + child: Icon(Icons.two_wheeler, size: 14, color: Colors.black), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ride.meetingPoint.displayName, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + if (ride.meetingPoint.address.isNotEmpty) + Text( + ride.meetingPoint.address, + style: const TextStyle( + color: Colors.white70, fontSize: 12), + ), + if (ride.meetingPoint.notes.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + 'Note: ${ride.meetingPoint.notes}', + style: const TextStyle( + color: Colors.white54, + fontSize: 11, + fontStyle: FontStyle.italic), + ), + ], + ], + ), + ), + IconButton( + icon: const Icon(Icons.navigation_outlined, + color: Color(0xFF00E5FF)), + tooltip: 'Open in Map App', + onPressed: () => _launchMapNavigation(ride.meetingPoint), + ), + ], + ), + if (ride.destination != null) ...[ + const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Divider(color: Colors.white12), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const CircleAvatar( + radius: 12, + backgroundColor: Color(0xFFFFB300), + child: Icon(Icons.flag, size: 14, color: Colors.black), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ride.destination!.displayName, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + if (ride.destination!.address.isNotEmpty) + Text( + ride.destination!.address, + style: const TextStyle( + color: Colors.white70, fontSize: 12), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.navigation_outlined, + color: Color(0xFFFFB300)), + tooltip: 'Open in Map App', + onPressed: () => _launchMapNavigation(ride.destination!), + ), + ], + ), + ], + ], + ), + ), + ); + } + + Widget _buildStopsCard(GroupRide ride) { + return Card( + color: const Color(0xFF161922), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.alt_route, color: Color(0xFF00E5FF), size: 18), + SizedBox(width: 8), + Text( + 'INTERMEDIATE STOPS', + style: TextStyle( + color: Color(0xFF00E5FF), + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + fontSize: 12, + ), + ), + ], + ), + const SizedBox(height: 12), + for (int i = 0; i < ride.stops.length; i++) + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + CircleAvatar( + radius: 10, + backgroundColor: Colors.white12, + child: Text( + '${i + 1}', + style: const TextStyle( + fontSize: 10, + color: Colors.white, + fontWeight: FontWeight.bold), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ride.stops[i].displayName, + style: const TextStyle( + color: Colors.white, fontSize: 13), + ), + Text( + ride.stops[i].locationType.label, + style: const TextStyle( + color: Colors.white54, fontSize: 11), + ), + ], + ), + ), + if (ride.stops[i].address.isNotEmpty) + IconButton( + icon: const Icon(Icons.navigation_outlined, + size: 16, color: Colors.white60), + onPressed: () => _launchMapNavigation(ride.stops[i]), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildParticipantsCard( + GroupRide ride, + List participants, + bool isOrganizer, + String? currentUid, + ) { + final active = + participants.where((p) => p.status == ParticipantStatus.accepted).toList(); + final pendingRequests = + participants.where((p) => p.status == ParticipantStatus.requested).toList(); + final invited = + participants.where((p) => p.status == ParticipantStatus.invited).toList(); + + final service = ref.read(groupRideServiceProvider); + + return Card( + color: const Color(0xFF161922), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon(Icons.people_alt_outlined, + color: Color(0xFF00E5FF), size: 18), + const SizedBox(width: 8), + Text( + 'PARTICIPANTS (${active.length}${ride.participantLimit != null ? '/${ride.participantLimit}' : ''})', + style: const TextStyle( + color: Color(0xFF00E5FF), + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + fontSize: 12, + ), + ), + ], + ), + if (isOrganizer && !ride.isCanceled) + TextButton.icon( + onPressed: () => _showInviteFriendsSheet(context, ride), + icon: const Icon(Icons.person_add, size: 16), + label: const Text('INVITE'), + ), + ], + ), + const SizedBox(height: 12), + + // Pending join requests (visible to organizer) + if (isOrganizer && pendingRequests.isNotEmpty) ...[ + const Text( + 'JOIN REQUESTS', + style: TextStyle( + color: Colors.amber, + fontWeight: FontWeight.bold, + fontSize: 11), + ), + const SizedBox(height: 6), + for (final req in pendingRequests) + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(req.riderDisplayName, + style: const TextStyle(color: Colors.white, fontSize: 13)), + subtitle: Text(req.displayHandle, + style: const TextStyle(color: Colors.white54, fontSize: 11)), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.close, color: Colors.redAccent), + onPressed: () async { + await service.respondToJoinRequest( + callerUid: currentUid!, + groupRideId: ride.id, + requesterUid: req.userId, + approve: false, + ); + }, + ), + IconButton( + icon: const Icon(Icons.check, color: Color(0xFF00E5FF)), + onPressed: () async { + await service.respondToJoinRequest( + callerUid: currentUid!, + groupRideId: ride.id, + requesterUid: req.userId, + approve: true, + ); + }, + ), + ], + ), + ), + const Divider(color: Colors.white12), + ], + + // Active members + for (final p in active) + ListTile( + contentPadding: EdgeInsets.zero, + leading: CircleAvatar( + backgroundColor: p.role == GroupRideRole.organizer + ? Colors.amber.withValues(alpha: 0.2) + : const Color(0xFF00E5FF).withValues(alpha: 0.2), + child: Text( + p.riderDisplayName.isNotEmpty + ? p.riderDisplayName[0].toUpperCase() + : '?', + style: TextStyle( + color: p.role == GroupRideRole.organizer + ? Colors.amber + : const Color(0xFF00E5FF), + fontWeight: FontWeight.bold, + ), + ), + ), + title: Text(p.riderDisplayName, + style: const TextStyle(color: Colors.white, fontSize: 13)), + subtitle: Text( + '${p.displayHandle} • ${p.role.label}', + style: const TextStyle(color: Colors.white54, fontSize: 11), + ), + trailing: isOrganizer && p.userId != currentUid && !ride.isCanceled + ? PopupMenuButton( + icon: const Icon(Icons.more_vert, + size: 18, color: Colors.white38), + color: const Color(0xFF1B1E28), + onSelected: (action) async { + if (action == 'remove') { + await service.removeParticipant( + callerUid: currentUid!, + groupRideId: ride.id, + targetUid: p.userId, + ); + } else if (action == 'transfer') { + await service.transferOrganizer( + callerUid: currentUid!, + groupRideId: ride.id, + newOrganizerUid: p.userId, + ); + } + }, + itemBuilder: (ctx) => [ + const PopupMenuItem( + value: 'transfer', + child: Text('Make Organizer', + style: TextStyle(color: Colors.white)), + ), + const PopupMenuItem( + value: 'remove', + child: Text('Remove Rider', + style: TextStyle(color: Colors.redAccent)), + ), + ], + ) + : null, + ), + + // Invited riders (visible to organizer) + if (isOrganizer && invited.isNotEmpty) ...[ + const SizedBox(height: 12), + const Text( + 'INVITATIONS PENDING', + style: TextStyle( + color: Colors.white60, + fontWeight: FontWeight.bold, + fontSize: 11), + ), + const SizedBox(height: 6), + for (final inv in invited) + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(inv.riderDisplayName, + style: const TextStyle(color: Colors.white70, fontSize: 12)), + subtitle: Text('${inv.displayHandle} • Invited', + style: const TextStyle(color: Colors.white38, fontSize: 10)), + ), + ], + ], + ), + ), + ); + } + + void _confirmCancelRide(GroupRide ride) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: const Color(0xFF1B1E28), + title: const Text('Cancel Group Ride?', + style: TextStyle(color: Colors.white)), + content: const Text( + 'This will cancel the group ride for all participants.', + style: TextStyle(color: Colors.white70, fontSize: 13), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: + const Text('KEEP RIDE', style: TextStyle(color: Colors.white60)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + foregroundColor: Colors.white, + ), + onPressed: () async { + Navigator.pop(ctx); + final user = ref.read(currentUserProvider); + if (user == null) return; + await ref.read(groupRideServiceProvider).cancelGroupRide( + callerUid: user.uid, + groupRideId: ride.id, + reason: 'Canceled by organizer.', + ); + }, + child: const Text('CANCEL RIDE'), + ), + ], + ), + ); + } + + void _confirmLeaveRide(GroupRide ride, String currentUid) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: const Color(0xFF1B1E28), + title: + const Text('Leave Group Ride?', style: TextStyle(color: Colors.white)), + content: const Text( + 'You will no longer be listed as a participant.', + style: TextStyle(color: Colors.white70, fontSize: 13), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('STAY', style: TextStyle(color: Colors.white60)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + foregroundColor: Colors.white, + ), + onPressed: () async { + Navigator.pop(ctx); + await ref.read(groupRideServiceProvider).leaveRide( + callerUid: currentUid, + groupRideId: ride.id, + ); + }, + child: const Text('LEAVE'), + ), + ], + ), + ); + } + + 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'; + } +} diff --git a/lib/features/group_rides/group_rides_hub_screen.dart b/lib/features/group_rides/group_rides_hub_screen.dart new file mode 100644 index 0000000..f8efb87 --- /dev/null +++ b/lib/features/group_rides/group_rides_hub_screen.dart @@ -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 createState() => + _GroupRidesHubScreenState(); +} + +class _GroupRidesHubScreenState extends ConsumerState + 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'; + } +} diff --git a/lib/features/settings/account_screen.dart b/lib/features/settings/account_screen.dart index 82ea328..0a6d0fd 100644 --- a/lib/features/settings/account_screen.dart +++ b/lib/features/settings/account_screen.dart @@ -675,6 +675,31 @@ class _AccountScreenState extends ConsumerState { ), ], ), + 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, diff --git a/lib/services/cloud/account_cloud_cleanup_service.dart b/lib/services/cloud/account_cloud_cleanup_service.dart index 82220d1..a0ae283 100644 --- a/lib/services/cloud/account_cloud_cleanup_service.dart +++ b/lib/services/cloud/account_cloud_cleanup_service.dart @@ -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((_) {}); diff --git a/lib/services/group_rides/group_ride_models.dart b/lib/services/group_rides/group_ride_models.dart new file mode 100644 index 0000000..d0fe334 --- /dev/null +++ b/lib/services/group_rides/group_ride_models.dart @@ -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 toJson() => { + 'latitude': latitude, + 'longitude': longitude, + 'displayName': displayName, + 'address': address, + 'locationType': locationType.name, + 'notes': notes, + if (stopDurationMinutes != null) + 'stopDurationMinutes': stopDurationMinutes, + }; + + factory GroupRideLocation.fromJson(Map 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 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 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 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 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 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?) + ?.map((s) => GroupRideLocation.fromJson(s as Map)) + .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) + : const GroupRideLocation( + latitude: 0, + longitude: 0, + displayName: 'TBD Meetup', + ), + destination: json['destination'] != null + ? GroupRideLocation.fromJson( + json['destination'] as Map) + : 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? 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; +} diff --git a/lib/services/group_rides/group_ride_providers.dart b/lib/services/group_rides/group_ride_providers.dart new file mode 100644 index 0000000..6c94342 --- /dev/null +++ b/lib/services/group_rides/group_ride_providers.dart @@ -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((ref) { + return GroupRideService(); +}); + +/// Stream of a specific Group Ride by ID. +final groupRideStreamProvider = + StreamProvider.family((ref, rideId) { + final service = ref.watch(groupRideServiceProvider); + return service.groupRideStream(rideId); +}); + +/// Stream of participants for a specific Group Ride. +final groupRideParticipantsProvider = + StreamProvider.family, String>((ref, rideId) { + final service = ref.watch(groupRideServiceProvider); + return service.participantsStream(rideId); +}); + +/// Stream of upcoming Group Rides for the current user. +final upcomingGroupRidesProvider = StreamProvider>((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); +}); diff --git a/lib/services/group_rides/group_ride_service.dart b/lib/services/group_rides/group_ride_service.dart new file mode 100644 index 0000000..944f077 --- /dev/null +++ b/lib/services/group_rides/group_ride_service.dart @@ -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 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 stops = const [], + List 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 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? 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 = { + 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 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 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 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 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 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 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 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 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 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 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> 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() + .toList(); + }); + } + + /// Stream of upcoming Group Rides visible to the caller (excluding canceled/completed). + Stream> 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() + .toList(); + }); + } +} diff --git a/test/group_ride_models_test.dart b/test/group_ride_models_test.dart new file mode 100644 index 0000000..27ad275 --- /dev/null +++ b/test/group_ride_models_test.dart @@ -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')); + }); + }); +} diff --git a/test/group_ride_service_test.dart b/test/group_ride_service_test.dart new file mode 100644 index 0000000..32c3489 --- /dev/null +++ b/test/group_ride_service_test.dart @@ -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()), + ); + }); + + 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()), + ); + }); + + 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()), + ); + }); + + 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()), + ); + }); + }); +} diff --git a/test/group_rides_widget_test.dart b/test/group_rides_widget_test.dart new file mode 100644 index 0000000..7b6417c --- /dev/null +++ b/test/group_rides_widget_test.dart @@ -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); + }); + }); +}