feat(group-rides): add mobile join request moderation UI and service (Phase 3B)

Connects mobile group ride details to the Phase 3A moderation server endpoint (GET /api/group-rides/[rideId]/join-requests).
This commit is contained in:
Developer 2026-09-24 14:17:19 -04:00
parent c1aa68ac43
commit 46ce052e0c
7 changed files with 1160 additions and 57 deletions

View File

@ -26,6 +26,7 @@ class GroupRideDetailScreen extends ConsumerStatefulWidget {
class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
bool _isProcessing = false;
String? _statusMessage;
final Set<String> _moderatingUids = {};
Future<void> _launchMapNavigation(GroupRideLocation loc) async {
final query = Uri.encodeComponent(
@ -353,6 +354,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
ride,
participants,
isOrganizer,
isClubAdmin,
currentUid,
colors,
),
@ -433,6 +435,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
ride,
participants,
isOrganizer,
isClubAdmin,
currentUid,
colors,
),
@ -1597,19 +1600,16 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
GroupRide ride,
List<GroupRideParticipant> participants,
bool isOrganizer,
bool isClubAdmin,
String? currentUid,
MotorSignalThemeColors colors,
) {
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(
@ -1665,57 +1665,101 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
),
const SizedBox(height: 12),
// Pending join requests (visible to organizer)
if (isOrganizer && pendingRequests.isNotEmpty) ...[
// Pending join requests (visible to organizer and club leadership)
if ((isOrganizer || isClubAdmin) &&
!ride.isCanceled &&
!ride.isCompleted) ...[
Builder(
builder: (context) {
final joinRequestsAsync = ref.watch(groupRideJoinRequestsProvider(ride.id));
return joinRequestsAsync.when(
data: (requests) {
if (requests.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'JOIN REQUESTS',
'JOIN REQUESTS (${requests.length})',
style: TextStyle(
color: colors.warningAmber,
fontWeight: FontWeight.bold,
fontSize: 11,
letterSpacing: 0.8,
),
),
IconButton(
icon: const Icon(Icons.refresh, size: 14),
tooltip: 'Refresh join requests',
color: colors.warningAmber,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () =>
ref.invalidate(groupRideJoinRequestsProvider(ride.id)),
),
],
),
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,
for (final req in requests)
_buildJoinRequestTile(ride, req, currentUid, colors),
const Divider(color: Colors.white12),
],
);
},
loading: () => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
IconButton(
icon: Icon(Icons.close, color: colors.raceRed),
onPressed: () async {
await service.respondToJoinRequest(
callerUid: currentUid!,
groupRideId: ride.id,
requesterUid: req.userId,
approve: false,
);
},
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: colors.warningAmber,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Checking for pending join requests...',
style: TextStyle(color: colors.warningAmber, fontSize: 11),
overflow: TextOverflow.ellipsis,
),
IconButton(
icon: Icon(Icons.check, color: colors.cyberCyan),
onPressed: () async {
await service.respondToJoinRequest(
callerUid: currentUid!,
groupRideId: ride.id,
requesterUid: req.userId,
approve: true,
);
},
),
],
),
),
const Divider(color: Colors.white12),
error: (err, _) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 4,
children: [
Text(
'Unable to load join requests.',
style: TextStyle(color: colors.raceRed, fontSize: 11),
),
TextButton(
onPressed: () =>
ref.invalidate(groupRideJoinRequestsProvider(ride.id)),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
child: Text('Retry',
style: TextStyle(color: colors.cyberCyan, fontSize: 11)),
),
],
),
),
);
},
),
],
// Active members
@ -1821,6 +1865,140 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
);
}
Widget _buildJoinRequestTile(
GroupRide ride,
GroupRideJoinRequest req,
String? currentUid,
MotorSignalThemeColors colors,
) {
final service = ref.read(groupRideServiceProvider);
final isModerating = _moderatingUids.contains(req.userId);
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
radius: 16,
backgroundColor: colors.surfaceContainer,
child: Text(
req.displayName.isNotEmpty ? req.displayName[0].toUpperCase() : '?',
style: TextStyle(
color: colors.cyberCyan,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
title: Text(
req.displayName,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
subtitle: Text(
req.displayHandle,
style: const TextStyle(color: Colors.white54, fontSize: 11),
),
trailing: isModerating
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colors.cyberCyan,
),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(Icons.close, color: colors.raceRed),
tooltip: 'Decline request',
onPressed: () async {
if (currentUid == null) return;
setState(() => _moderatingUids.add(req.userId));
try {
await service.respondToJoinRequest(
callerUid: currentUid,
groupRideId: ride.id,
requesterUid: req.userId,
approve: false,
);
if (mounted) {
ref.invalidate(groupRideJoinRequestsProvider(ride.id));
ref.invalidate(groupRideStreamProvider(ride.id));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Declined join request from ${req.displayName}.',
),
backgroundColor: colors.raceRed,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to decline request: $e'),
backgroundColor: colors.raceRed,
),
);
}
} finally {
if (mounted) {
setState(() => _moderatingUids.remove(req.userId));
}
}
},
),
IconButton(
icon: Icon(Icons.check, color: colors.cyberCyan),
tooltip: 'Approve request',
onPressed: () async {
if (currentUid == null) return;
setState(() => _moderatingUids.add(req.userId));
try {
await service.respondToJoinRequest(
callerUid: currentUid,
groupRideId: ride.id,
requesterUid: req.userId,
approve: true,
);
if (mounted) {
ref.invalidate(groupRideJoinRequestsProvider(ride.id));
ref.invalidate(groupRideStreamProvider(ride.id));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Approved ${req.displayName} to join the ride.',
),
backgroundColor: colors.cyberCyan,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to approve request: $e'),
backgroundColor: colors.raceRed,
),
);
}
} finally {
if (mounted) {
setState(() => _moderatingUids.remove(req.userId));
}
}
},
),
],
),
);
}
void _confirmCancelRide(GroupRide ride, MotorSignalThemeColors colors) {
showDialog(
context: context,

View File

@ -340,6 +340,93 @@ class GroupRideParticipant {
invitedBy.hashCode;
}
/// Pending join request for a Group Ride returned by the moderation endpoint.
@immutable
class GroupRideJoinRequest {
final String userId;
final String? riderDisplayName;
final String? riderHandle;
final String? riderAvatarPreset;
final String? riderAvatarUrl;
final DateTime createdAt;
const GroupRideJoinRequest({
required this.userId,
this.riderDisplayName,
this.riderHandle,
this.riderAvatarPreset,
this.riderAvatarUrl,
required this.createdAt,
});
String get displayName =>
(riderDisplayName != null && riderDisplayName!.trim().isNotEmpty)
? riderDisplayName!.trim()
: 'Rider';
String get displayHandle =>
(riderHandle != null && riderHandle!.trim().isNotEmpty)
? '@${riderHandle!.trim()}'
: '@rider';
Map<String, dynamic> toJson() => {
'userId': userId,
if (riderDisplayName != null) 'riderDisplayName': riderDisplayName,
if (riderHandle != null) 'riderHandle': riderHandle,
if (riderAvatarPreset != null) 'riderAvatarPreset': riderAvatarPreset,
if (riderAvatarUrl != null) 'riderAvatarUrl': riderAvatarUrl,
'createdAt': createdAt.toIso8601String(),
};
factory GroupRideJoinRequest.fromJson(Map<String, dynamic> json) {
return GroupRideJoinRequest(
userId: json['userId'] as String? ?? '',
riderDisplayName: json['riderDisplayName'] as String?,
riderHandle: json['riderHandle'] as String?,
riderAvatarPreset: json['riderAvatarPreset'] as String?,
riderAvatarUrl: json['riderAvatarUrl'] as String?,
createdAt: json['createdAt'] != null
? DateTime.tryParse(json['createdAt'] as String) ?? DateTime.now()
: DateTime.now(),
);
}
GroupRideParticipant toParticipant() {
return GroupRideParticipant(
userId: userId,
role: GroupRideRole.participant,
status: ParticipantStatus.requested,
riderHandle: riderHandle ?? '',
riderDisplayName: displayName,
riderAvatarPreset: riderAvatarPreset,
riderAvatarUrl: riderAvatarUrl,
createdAt: createdAt,
updatedAt: createdAt,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is GroupRideJoinRequest &&
runtimeType == other.runtimeType &&
userId == other.userId &&
riderDisplayName == other.riderDisplayName &&
riderHandle == other.riderHandle &&
riderAvatarPreset == other.riderAvatarPreset &&
riderAvatarUrl == other.riderAvatarUrl &&
createdAt == other.createdAt;
@override
int get hashCode =>
userId.hashCode ^
riderDisplayName.hashCode ^
riderHandle.hashCode ^
riderAvatarPreset.hashCode ^
riderAvatarUrl.hashCode ^
createdAt.hashCode;
}
/// Core Group Ride model.
@immutable
class GroupRide {

View File

@ -108,6 +108,18 @@ final groupRideParticipantsProvider =
return controller.stream;
});
/// FutureProvider fetching pending join requests for a GroupRide (organizer or club leadership only).
final groupRideJoinRequestsProvider =
FutureProvider.family.autoDispose<List<GroupRideJoinRequest>, String>((ref, rideId) async {
final user = ref.watch(currentUserProvider);
if (user == null || user.isAnonymous) {
return const [];
}
final authService = ref.watch(authServiceProvider);
final token = await authService.getIdToken();
final service = ref.watch(groupRideServiceProvider);
return service.fetchJoinRequests(groupRideId: rideId, idToken: token);
});
/// Stream of upcoming Group Rides for the current user.
final upcomingGroupRidesProvider = StreamProvider<List<GroupRide>>((ref) {

View File

@ -1,16 +1,25 @@
import 'dart:async';
import 'dart:convert';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
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;
static const String defaultApiBaseUrl = 'https://motorsignal.app';
GroupRideService({FirebaseFirestore? firestore})
: _firestore = firestore ?? _resolveFirestore();
final FirebaseFirestore? _firestore;
final FirebaseAuth? _auth;
GroupRideService({
FirebaseFirestore? firestore,
FirebaseAuth? auth,
}) : _firestore = firestore ?? _resolveFirestore(),
_auth = auth ?? _resolveAuth();
static FirebaseFirestore? _resolveFirestore() {
try {
@ -19,6 +28,13 @@ class GroupRideService {
return null;
}
static FirebaseAuth? _resolveAuth() {
try {
if (Firebase.apps.isNotEmpty) return FirebaseAuth.instance;
} catch (_) {}
return null;
}
bool get isAvailable => _firestore != null;
/// Creates a new Group Ride and establishes the organizer participant record.
@ -531,6 +547,86 @@ class GroupRideService {
});
}
/// Fetches pending join requests for a group ride via the authenticated moderation API.
/// Requires caller to be the organizer or an active club administrator.
Future<List<GroupRideJoinRequest>> fetchJoinRequests({
required String groupRideId,
String? idToken,
String? baseUrl,
http.Client? client,
}) async {
final cleanRideId = groupRideId.trim();
if (cleanRideId.isEmpty) {
throw ArgumentError('Group ride ID cannot be empty.');
}
String? token = idToken;
if (token == null || token.isEmpty) {
final user = _auth?.currentUser;
if (user == null || user.isAnonymous) {
throw StateError('A registered account is required to view join requests.');
}
token = await user.getIdToken();
if (token == null || token.isEmpty) {
throw StateError('Unable to retrieve authentication token.');
}
}
final rootUrl = (baseUrl ?? defaultApiBaseUrl).replaceAll(RegExp(r'/+$'), '');
final uri = Uri.parse('$rootUrl/api/group-rides/${Uri.encodeComponent(cleanRideId)}/join-requests');
final httpClient = client ?? http.Client();
final shouldCloseClient = client == null;
try {
final response = await httpClient.get(
uri,
headers: {
'Authorization': 'Bearer $idToken',
'Accept': 'application/json',
},
).timeout(const Duration(seconds: 15));
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw const FormatException('Malformed response from server.');
}
final requestsList = decoded['requests'];
if (requestsList is! List) {
return const [];
}
return requestsList
.whereType<Map<String, dynamic>>()
.map((item) => GroupRideJoinRequest.fromJson(item))
.toList();
}
if (response.statusCode == 401) {
throw StateError('Your session has expired. Please sign in again.');
}
if (response.statusCode == 403) {
throw StateError('Only the organizer or club leadership can view join requests.');
}
if (response.statusCode == 404) {
throw StateError('Group ride not found.');
}
throw StateError('Unable to load join requests (HTTP ${response.statusCode}).');
} on TimeoutException {
throw TimeoutException('Network timeout while loading join requests. Please try again.');
} on http.ClientException catch (e) {
throw StateError('Network connection error: ${e.message}');
} finally {
if (shouldCloseClient) {
httpClient.close();
}
}
}
/// Responds to a join request (organizer or authorized club leadership).
Future<void> respondToJoinRequest({
required String callerUid,

View File

@ -577,4 +577,96 @@ void main() {
expect(disabled.isStale, isTrue);
});
});
group('GroupRideJoinRequest Model Tests', () {
test('serializes and deserializes correctly', () {
final now = DateTime(2026, 9, 24, 12, 0, 0);
final req = GroupRideJoinRequest(
userId: 'rider_99',
riderDisplayName: 'Mountain Rider',
riderHandle: 'mountain_rider',
riderAvatarPreset: 'helmet_red',
riderAvatarUrl: 'https://example.com/avatar.jpg',
createdAt: now,
);
final json = req.toJson();
expect(json['userId'], equals('rider_99'));
expect(json['riderDisplayName'], equals('Mountain Rider'));
expect(json['riderHandle'], equals('mountain_rider'));
expect(json['riderAvatarPreset'], equals('helmet_red'));
expect(json['riderAvatarUrl'], equals('https://example.com/avatar.jpg'));
expect(json['createdAt'], equals(now.toIso8601String()));
final restored = GroupRideJoinRequest.fromJson(json);
expect(restored, equals(req));
expect(restored.displayName, equals('Mountain Rider'));
expect(restored.displayHandle, equals('@mountain_rider'));
});
test('falls back gracefully on empty or null values', () {
final req = GroupRideJoinRequest(
userId: 'u1',
riderDisplayName: ' ',
riderHandle: null,
createdAt: DateTime(2026, 1, 1),
);
expect(req.displayName, equals('Rider'));
expect(req.displayHandle, equals('@rider'));
final fromEmptyJson = GroupRideJoinRequest.fromJson(const {});
expect(fromEmptyJson.userId, isEmpty);
expect(fromEmptyJson.displayName, equals('Rider'));
expect(fromEmptyJson.displayHandle, equals('@rider'));
});
test('converts to GroupRideParticipant accurately', () {
final now = DateTime(2026, 9, 24, 10, 0, 0);
final req = GroupRideJoinRequest(
userId: 'u42',
riderDisplayName: 'Speed Demon',
riderHandle: 'speedy',
riderAvatarPreset: 'visor_dark',
createdAt: now,
);
final participant = req.toParticipant();
expect(participant.userId, equals('u42'));
expect(participant.role, equals(GroupRideRole.participant));
expect(participant.status, equals(ParticipantStatus.requested));
expect(participant.riderDisplayName, equals('Speed Demon'));
expect(participant.riderHandle, equals('speedy'));
expect(participant.riderAvatarPreset, equals('visor_dark'));
expect(participant.createdAt, equals(now));
expect(participant.updatedAt, equals(now));
});
test('equality and hashCode contracts hold', () {
final now = DateTime(2026, 9, 24, 10, 0, 0);
final r1 = GroupRideJoinRequest(
userId: 'u1',
riderDisplayName: 'Alice',
riderHandle: 'alice',
createdAt: now,
);
final r2 = GroupRideJoinRequest(
userId: 'u1',
riderDisplayName: 'Alice',
riderHandle: 'alice',
createdAt: now,
);
final r3 = GroupRideJoinRequest(
userId: 'u2',
riderDisplayName: 'Bob',
riderHandle: 'bob',
createdAt: now,
);
expect(r1, equals(r2));
expect(r1.hashCode, equals(r2.hashCode));
expect(r1, isNot(equals(r3)));
});
});
}

View File

@ -0,0 +1,411 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:motorcycle_dash/core/theme/theme.dart';
import 'package:motorcycle_dash/features/group_rides/group_ride_detail_screen.dart';
import 'package:motorcycle_dash/features/records/records_service.dart';
import 'package:motorcycle_dash/services/auth/auth_providers.dart';
import 'package:motorcycle_dash/services/auth/auth_service.dart';
import 'package:motorcycle_dash/services/auth/auth_state.dart';
import 'package:motorcycle_dash/services/auth/auth_user.dart';
import 'package:motorcycle_dash/services/clubs/club_models.dart';
import 'package:motorcycle_dash/services/clubs/club_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/group_rides/group_ride_service.dart';
import 'package:motorcycle_dash/services/social/rider_profile_models.dart';
import 'package:motorcycle_dash/services/social/social_providers.dart';
class _FakeAuth implements AuthService {
final AuthUser? user;
_FakeAuth({this.user});
@override
Stream<AuthState> get authStateChanges => Stream.value(
user != null
? AuthState.authenticated(user!, isFirebaseConfigured: true)
: const AuthState.unauthenticated(isFirebaseConfigured: true),
);
@override
AuthState get currentState => user != null
? AuthState.authenticated(user!, isFirebaseConfigured: true)
: const AuthState.unauthenticated(isFirebaseConfigured: true);
@override
AuthUser? get currentUser => user;
@override
bool get isConfigured => true;
@override
bool get isEmailVerified => true;
@override
Future<String?> getIdToken([bool forceRefresh = false]) async => 'mock-id-token';
@override
Future<void> signInWithEmailAndPassword(String email, String password) async {}
@override
Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {}
@override
Future<void> signInWithGoogle() async {}
@override
Future<void> signInAnonymously() async {}
@override
Future<void> ensureBackendIdentity() async {}
@override
Future<void> sendPasswordResetEmail(String email) async {}
@override
Future<void> signOut() async {}
@override
void dispose() {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _RecordingGroupRideService extends Fake implements GroupRideService {
String? lastModeratedCallerUid;
String? lastModeratedGroupRideId;
String? lastModeratedRequesterUid;
bool? lastModeratedApprove;
@override
Future<void> respondToJoinRequest({
required String callerUid,
required String groupRideId,
required String requesterUid,
required bool approve,
}) async {
lastModeratedCallerUid = callerUid;
lastModeratedGroupRideId = groupRideId;
lastModeratedRequesterUid = requesterUid;
lastModeratedApprove = approve;
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late SharedPreferences prefs;
final now = DateTime(2026, 9, 24, 10, 0);
final organizerUser = const AuthUser(
uid: 'organizer_1',
email: 'organizer@example.com',
displayName: 'Alice Apex',
isAnonymous: false,
);
final adminUser = const AuthUser(
uid: 'admin_1',
email: 'admin@example.com',
displayName: 'Charlie Admin',
isAnonymous: false,
);
final regularUser = const AuthUser(
uid: 'rider_1',
email: 'rider@example.com',
displayName: 'Bob Rider',
isAnonymous: false,
);
final testRide = GroupRide(
id: 'ride_101',
organizerUid: 'organizer_1',
organizerHandle: 'alice_apex',
organizerDisplayName: 'Alice Apex',
title: 'Blue Ridge Twisties',
description: 'Scenic group ride through Blood Mountain.',
status: GroupRideStatus.scheduled,
joinPolicy: GroupRideJoinPolicy.requestToJoin,
scheduledStartAt: now.add(const Duration(days: 1)),
departureAt: now.add(const Duration(days: 1, minutes: 30)),
participantLimit: 10,
participantCount: 1,
accessUids: const ['organizer_1'],
meetingPoint: const GroupRideLocation(
latitude: 34.5,
longitude: -84.0,
displayName: 'Dahlonega Square',
),
clubId: 'club_77',
createdAt: now,
updatedAt: now,
);
final organizerParticipant = GroupRideParticipant(
userId: 'organizer_1',
role: GroupRideRole.organizer,
status: ParticipantStatus.accepted,
riderDisplayName: 'Alice Apex',
riderHandle: 'alice_apex',
createdAt: now,
updatedAt: now,
);
final testProfile = RiderProfile(
uid: 'organizer_1',
handle: 'alice_apex',
displayName: 'Alice Apex',
createdAt: now,
updatedAt: now,
);
final pendingRequest = GroupRideJoinRequest(
userId: 'requester_42',
riderDisplayName: 'Dave Curve',
riderHandle: 'dave_curves',
createdAt: now,
);
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
});
Widget buildScreen({
required AuthUser user,
required GroupRide ride,
required _RecordingGroupRideService mockService,
AsyncValue<List<GroupRideJoinRequest>>? joinRequestsValue,
ClubMember? clubMember,
}) {
return ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
authServiceProvider.overrideWithValue(_FakeAuth(user: user)),
currentUserProvider.overrideWithValue(user),
groupRideServiceProvider.overrideWithValue(mockService),
groupRideStreamProvider(ride.id).overrideWith((ref) => Stream.value(ride)),
groupRideParticipantsProvider(ride.id).overrideWith((ref) => Stream.value([organizerParticipant])),
groupRideLivePresenceProvider(ride.id).overrideWith((ref) => Stream.value(const [])),
myRiderProfileProvider.overrideWith((ref) => Stream.value(testProfile)),
myFriendsProvider.overrideWith((ref) => Stream.value(const [])),
if (ride.clubId != null)
currentMemberProvider(ride.clubId!).overrideWith((ref) => Stream.value(clubMember)),
if (joinRequestsValue != null)
groupRideJoinRequestsProvider(ride.id).overrideWith((ref) => joinRequestsValue.value ?? const [])
else
groupRideJoinRequestsProvider(ride.id).overrideWith((ref) => [pendingRequest]),
],
child: MaterialApp(
theme: AppTheme.darkTheme,
home: GroupRideDetailScreen(rideId: ride.id),
),
);
}
group('GroupRideDetailScreen Join Request Moderation UI Tests', () {
testWidgets('organizer sees JOIN REQUESTS section and pending request details', (tester) async {
final mockService = _RecordingGroupRideService();
await tester.pumpWidget(
buildScreen(
user: organizerUser,
ride: testRide,
mockService: mockService,
),
);
await tester.pumpAndSettle();
expect(find.text('JOIN REQUESTS (1)'), findsOneWidget);
expect(find.text('Dave Curve'), findsOneWidget);
expect(find.text('@dave_curves'), findsOneWidget);
expect(find.byIcon(Icons.check), findsOneWidget);
expect(find.byIcon(Icons.close), findsOneWidget);
expect(find.byIcon(Icons.refresh), findsOneWidget);
});
testWidgets('organizer approves join request invokes service and triggers feedback', (tester) async {
final mockService = _RecordingGroupRideService();
await tester.pumpWidget(
buildScreen(
user: organizerUser,
ride: testRide,
mockService: mockService,
),
);
await tester.pumpAndSettle();
final approveButton = find.byTooltip('Approve request');
expect(approveButton, findsOneWidget);
await tester.tap(approveButton);
await tester.pump();
expect(mockService.lastModeratedCallerUid, equals('organizer_1'));
expect(mockService.lastModeratedGroupRideId, equals('ride_101'));
expect(mockService.lastModeratedRequesterUid, equals('requester_42'));
expect(mockService.lastModeratedApprove, isTrue);
await tester.pumpAndSettle();
expect(find.text('Approved Dave Curve to join the ride.'), findsOneWidget);
});
testWidgets('organizer declines join request invokes service with approve: false', (tester) async {
final mockService = _RecordingGroupRideService();
await tester.pumpWidget(
buildScreen(
user: organizerUser,
ride: testRide,
mockService: mockService,
),
);
await tester.pumpAndSettle();
final declineButton = find.byTooltip('Decline request');
expect(declineButton, findsOneWidget);
await tester.tap(declineButton);
await tester.pump();
expect(mockService.lastModeratedCallerUid, equals('organizer_1'));
expect(mockService.lastModeratedGroupRideId, equals('ride_101'));
expect(mockService.lastModeratedRequesterUid, equals('requester_42'));
expect(mockService.lastModeratedApprove, isFalse);
await tester.pumpAndSettle();
expect(find.text('Declined join request from Dave Curve.'), findsOneWidget);
});
testWidgets('club admin who is not organizer sees moderation controls', (tester) async {
final mockService = _RecordingGroupRideService();
final adminMember = ClubMember(
uid: adminUser.uid,
clubId: testRide.clubId!,
status: ClubMemberStatus.active,
role: ClubRole.admin,
joinedAt: now,
updatedAt: now,
);
await tester.pumpWidget(
buildScreen(
user: adminUser,
ride: testRide,
mockService: mockService,
clubMember: adminMember,
),
);
await tester.pumpAndSettle();
expect(find.text('JOIN REQUESTS (1)'), findsOneWidget);
expect(find.text('Dave Curve'), findsOneWidget);
expect(find.byTooltip('Approve request'), findsOneWidget);
expect(find.byTooltip('Decline request'), findsOneWidget);
});
testWidgets('regular rider does not see JOIN REQUESTS section', (tester) async {
final mockService = _RecordingGroupRideService();
final regularMember = ClubMember(
uid: regularUser.uid,
clubId: testRide.clubId!,
status: ClubMemberStatus.active,
role: ClubRole.member,
joinedAt: now,
updatedAt: now,
);
await tester.pumpWidget(
buildScreen(
user: regularUser,
ride: testRide,
mockService: mockService,
clubMember: regularMember,
),
);
await tester.pumpAndSettle();
expect(find.textContaining('JOIN REQUESTS'), findsNothing);
expect(find.byTooltip('Approve request'), findsNothing);
expect(find.byTooltip('Decline request'), findsNothing);
});
testWidgets('empty requests list does not render section header', (tester) async {
final mockService = _RecordingGroupRideService();
await tester.pumpWidget(
buildScreen(
user: organizerUser,
ride: testRide,
mockService: mockService,
joinRequestsValue: const AsyncValue.data([]),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('JOIN REQUESTS'), findsNothing);
});
testWidgets('displays error message and retry button on fetch failure', (tester) async {
final mockService = _RecordingGroupRideService();
await tester.pumpWidget(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
authServiceProvider.overrideWithValue(_FakeAuth(user: organizerUser)),
currentUserProvider.overrideWithValue(organizerUser),
groupRideServiceProvider.overrideWithValue(mockService),
groupRideStreamProvider(testRide.id).overrideWith((ref) => Stream.value(testRide)),
groupRideParticipantsProvider(testRide.id).overrideWith((ref) => Stream.value([organizerParticipant])),
groupRideLivePresenceProvider(testRide.id).overrideWith((ref) => Stream.value(const [])),
myRiderProfileProvider.overrideWith((ref) => Stream.value(testProfile)),
myFriendsProvider.overrideWith((ref) => Stream.value(const [])),
groupRideJoinRequestsProvider(testRide.id).overrideWith(
(ref) => Future.error(StateError('Server connection error')),
),
],
child: MaterialApp(
theme: AppTheme.darkTheme,
home: GroupRideDetailScreen(rideId: testRide.id),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Unable to load join requests.'), findsOneWidget);
expect(find.text('Retry'), findsOneWidget);
});
testWidgets('canceled ride hides JOIN REQUESTS section even for organizer', (tester) async {
final mockService = _RecordingGroupRideService();
final canceledRide = testRide.copyWith(status: GroupRideStatus.canceled);
await tester.pumpWidget(
buildScreen(
user: organizerUser,
ride: canceledRide,
mockService: mockService,
),
);
await tester.pumpAndSettle();
expect(find.textContaining('JOIN REQUESTS'), findsNothing);
});
});
}

View File

@ -1,7 +1,10 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:motorcycle_dash/core/theme/app_colors.dart';
import 'package:motorcycle_dash/core/theme/theme.dart';
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
@ -1684,6 +1687,230 @@ void main() {
expect(partSnap.data()!['status'], equals('declined'));
});
});
group('fetchJoinRequests Moderation API Tests', () {
test('rejects empty or whitespace groupRideId', () async {
final service = GroupRideService();
expect(
() => service.fetchJoinRequests(groupRideId: ''),
throwsA(isA<ArgumentError>()),
);
expect(
() => service.fetchJoinRequests(groupRideId: ' '),
throwsA(isA<ArgumentError>()),
);
});
test('throws StateError when unauthenticated (no token provided and no Firebase user)', () async {
final service = GroupRideService();
expect(
() => service.fetchJoinRequests(groupRideId: 'ride_1'),
throwsA(isA<StateError>().having(
(e) => e.message,
'message',
contains('registered account is required'),
)),
);
});
test('fetches and parses join requests successfully (HTTP 200)', () async {
final mockClient = MockClient((request) async {
expect(request.method, equals('GET'));
expect(
request.url.toString(),
equals('https://motorsignal.app/api/group-rides/ride_123/join-requests'),
);
expect(request.headers['Authorization'], equals('Bearer valid-token'));
expect(request.headers['Accept'], equals('application/json'));
final responseBody = jsonEncode({
'requests': [
{
'userId': 'rider_1',
'riderDisplayName': 'Mountain Explorer',
'riderHandle': 'mtn_rider',
'riderAvatarPreset': 'helmet_red',
'riderAvatarUrl': 'https://example.com/avatar.jpg',
'createdAt': '2026-09-24T12:00:00.000Z',
},
{
'userId': 'rider_2',
'riderDisplayName': 'Track Master',
'riderHandle': 'track_pro',
'createdAt': '2026-09-24T12:05:00.000Z',
},
]
});
return http.Response(responseBody, 200, headers: {
'content-type': 'application/json',
});
});
final service = GroupRideService();
final result = await service.fetchJoinRequests(
groupRideId: 'ride_123',
idToken: 'valid-token',
client: mockClient,
);
expect(result.length, equals(2));
expect(result[0].userId, equals('rider_1'));
expect(result[0].displayName, equals('Mountain Explorer'));
expect(result[0].displayHandle, equals('@mtn_rider'));
expect(result[0].riderAvatarPreset, equals('helmet_red'));
expect(result[0].riderAvatarUrl, equals('https://example.com/avatar.jpg'));
expect(result[0].createdAt, equals(DateTime.parse('2026-09-24T12:00:00.000Z')));
expect(result[1].userId, equals('rider_2'));
expect(result[1].displayName, equals('Track Master'));
expect(result[1].displayHandle, equals('@track_pro'));
expect(result[1].riderAvatarPreset, isNull);
});
test('returns empty list when requests array is empty or missing (HTTP 200)', () async {
final mockClient = MockClient((request) async {
return http.Response(jsonEncode({'requests': []}), 200);
});
final service = GroupRideService();
final result = await service.fetchJoinRequests(
groupRideId: 'ride_empty',
idToken: 'token',
client: mockClient,
);
expect(result, isEmpty);
final mockClient2 = MockClient((request) async {
return http.Response(jsonEncode({}), 200);
});
final result2 = await service.fetchJoinRequests(
groupRideId: 'ride_empty',
idToken: 'token',
client: mockClient2,
);
expect(result2, isEmpty);
});
test('respects custom baseUrl and properly encodes groupRideId', () async {
final mockClient = MockClient((request) async {
expect(
request.url.toString(),
equals('https://staging.motorsignal.app/api/group-rides/ride%2Fspecial%231/join-requests'),
);
return http.Response(jsonEncode({'requests': []}), 200);
});
final service = GroupRideService();
final result = await service.fetchJoinRequests(
groupRideId: 'ride/special#1',
baseUrl: 'https://staging.motorsignal.app/',
idToken: 'token',
client: mockClient,
);
expect(result, isEmpty);
});
test('throws StateError on session expired (HTTP 401)', () async {
final mockClient = MockClient((request) async {
return http.Response(jsonEncode({'error': 'Unauthorized'}), 401);
});
final service = GroupRideService();
expect(
() => service.fetchJoinRequests(
groupRideId: 'ride_1',
idToken: 'expired-token',
client: mockClient,
),
throwsA(isA<StateError>().having(
(e) => e.message,
'message',
contains('session has expired'),
)),
);
});
test('throws StateError on forbidden (HTTP 403)', () async {
final mockClient = MockClient((request) async {
return http.Response(jsonEncode({'error': 'Forbidden'}), 403);
});
final service = GroupRideService();
expect(
() => service.fetchJoinRequests(
groupRideId: 'ride_1',
idToken: 'rider-token',
client: mockClient,
),
throwsA(isA<StateError>().having(
(e) => e.message,
'message',
contains('organizer or club leadership'),
)),
);
});
test('throws StateError on ride not found (HTTP 404)', () async {
final mockClient = MockClient((request) async {
return http.Response(jsonEncode({'error': 'Not Found'}), 404);
});
final service = GroupRideService();
expect(
() => service.fetchJoinRequests(
groupRideId: 'nonexistent',
idToken: 'token',
client: mockClient,
),
throwsA(isA<StateError>().having(
(e) => e.message,
'message',
contains('Group ride not found'),
)),
);
});
test('throws StateError on generic server error (HTTP 500)', () async {
final mockClient = MockClient((request) async {
return http.Response('Internal Server Error', 500);
});
final service = GroupRideService();
expect(
() => service.fetchJoinRequests(
groupRideId: 'ride_1',
idToken: 'token',
client: mockClient,
),
throwsA(isA<StateError>().having(
(e) => e.message,
'message',
contains('HTTP 500'),
)),
);
});
test('handles client exception gracefully', () async {
final mockClient = MockClient((request) async {
throw http.ClientException('Connection failed');
});
final service = GroupRideService();
expect(
() => service.fetchJoinRequests(
groupRideId: 'ride_1',
idToken: 'token',
client: mockClient,
),
throwsA(isA<StateError>().having(
(e) => e.message,
'message',
contains('Network connection error'),
)),
);
});
});
}
class _FakeGroupRideLifecycleService implements GroupRideService {