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:
parent
c1aa68ac43
commit
46ce052e0c
|
|
@ -26,6 +26,7 @@ class GroupRideDetailScreen extends ConsumerStatefulWidget {
|
||||||
class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
||||||
bool _isProcessing = false;
|
bool _isProcessing = false;
|
||||||
String? _statusMessage;
|
String? _statusMessage;
|
||||||
|
final Set<String> _moderatingUids = {};
|
||||||
|
|
||||||
Future<void> _launchMapNavigation(GroupRideLocation loc) async {
|
Future<void> _launchMapNavigation(GroupRideLocation loc) async {
|
||||||
final query = Uri.encodeComponent(
|
final query = Uri.encodeComponent(
|
||||||
|
|
@ -353,6 +354,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
||||||
ride,
|
ride,
|
||||||
participants,
|
participants,
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
|
isClubAdmin,
|
||||||
currentUid,
|
currentUid,
|
||||||
colors,
|
colors,
|
||||||
),
|
),
|
||||||
|
|
@ -433,6 +435,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
||||||
ride,
|
ride,
|
||||||
participants,
|
participants,
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
|
isClubAdmin,
|
||||||
currentUid,
|
currentUid,
|
||||||
colors,
|
colors,
|
||||||
),
|
),
|
||||||
|
|
@ -1597,19 +1600,16 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
||||||
GroupRide ride,
|
GroupRide ride,
|
||||||
List<GroupRideParticipant> participants,
|
List<GroupRideParticipant> participants,
|
||||||
bool isOrganizer,
|
bool isOrganizer,
|
||||||
|
bool isClubAdmin,
|
||||||
String? currentUid,
|
String? currentUid,
|
||||||
MotorSignalThemeColors colors,
|
MotorSignalThemeColors colors,
|
||||||
) {
|
) {
|
||||||
final active = participants
|
final active = participants
|
||||||
.where((p) => p.status == ParticipantStatus.accepted)
|
.where((p) => p.status == ParticipantStatus.accepted)
|
||||||
.toList();
|
.toList();
|
||||||
final pendingRequests = participants
|
|
||||||
.where((p) => p.status == ParticipantStatus.requested)
|
|
||||||
.toList();
|
|
||||||
final invited = participants
|
final invited = participants
|
||||||
.where((p) => p.status == ParticipantStatus.invited)
|
.where((p) => p.status == ParticipantStatus.invited)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
final service = ref.read(groupRideServiceProvider);
|
final service = ref.read(groupRideServiceProvider);
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
|
|
@ -1665,57 +1665,101 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Pending join requests (visible to organizer)
|
// Pending join requests (visible to organizer and club leadership)
|
||||||
if (isOrganizer && pendingRequests.isNotEmpty) ...[
|
if ((isOrganizer || isClubAdmin) &&
|
||||||
Text(
|
!ride.isCanceled &&
|
||||||
'JOIN REQUESTS',
|
!ride.isCompleted) ...[
|
||||||
style: TextStyle(
|
Builder(
|
||||||
color: colors.warningAmber,
|
builder: (context) {
|
||||||
fontWeight: FontWeight.bold,
|
final joinRequestsAsync = ref.watch(groupRideJoinRequestsProvider(ride.id));
|
||||||
fontSize: 11,
|
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 (${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 requests)
|
||||||
|
_buildJoinRequestTile(ride, req, currentUid, colors),
|
||||||
|
const Divider(color: Colors.white12),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
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: Icon(Icons.close, color: colors.raceRed),
|
|
||||||
onPressed: () async {
|
|
||||||
await service.respondToJoinRequest(
|
|
||||||
callerUid: currentUid!,
|
|
||||||
groupRideId: ride.id,
|
|
||||||
requesterUid: req.userId,
|
|
||||||
approve: false,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
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),
|
|
||||||
],
|
],
|
||||||
|
|
||||||
// Active members
|
// 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) {
|
void _confirmCancelRide(GroupRide ride, MotorSignalThemeColors colors) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
|
||||||
|
|
@ -340,6 +340,93 @@ class GroupRideParticipant {
|
||||||
invitedBy.hashCode;
|
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.
|
/// Core Group Ride model.
|
||||||
@immutable
|
@immutable
|
||||||
class GroupRide {
|
class GroupRide {
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,18 @@ final groupRideParticipantsProvider =
|
||||||
return controller.stream;
|
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.
|
/// Stream of upcoming Group Rides for the current user.
|
||||||
final upcomingGroupRidesProvider = StreamProvider<List<GroupRide>>((ref) {
|
final upcomingGroupRidesProvider = StreamProvider<List<GroupRide>>((ref) {
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,25 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
import '../social/rider_profile_models.dart';
|
import '../social/rider_profile_models.dart';
|
||||||
import 'group_ride_models.dart';
|
import 'group_ride_models.dart';
|
||||||
|
|
||||||
/// Service managing Group Rides, meetup locations, stops, invitations, join requests, and memberships.
|
/// Service managing Group Rides, meetup locations, stops, invitations, join requests, and memberships.
|
||||||
class GroupRideService {
|
class GroupRideService {
|
||||||
final FirebaseFirestore? _firestore;
|
static const String defaultApiBaseUrl = 'https://motorsignal.app';
|
||||||
|
|
||||||
GroupRideService({FirebaseFirestore? firestore})
|
final FirebaseFirestore? _firestore;
|
||||||
: _firestore = firestore ?? _resolveFirestore();
|
final FirebaseAuth? _auth;
|
||||||
|
|
||||||
|
GroupRideService({
|
||||||
|
FirebaseFirestore? firestore,
|
||||||
|
FirebaseAuth? auth,
|
||||||
|
}) : _firestore = firestore ?? _resolveFirestore(),
|
||||||
|
_auth = auth ?? _resolveAuth();
|
||||||
|
|
||||||
static FirebaseFirestore? _resolveFirestore() {
|
static FirebaseFirestore? _resolveFirestore() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -19,6 +28,13 @@ class GroupRideService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static FirebaseAuth? _resolveAuth() {
|
||||||
|
try {
|
||||||
|
if (Firebase.apps.isNotEmpty) return FirebaseAuth.instance;
|
||||||
|
} catch (_) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
bool get isAvailable => _firestore != null;
|
bool get isAvailable => _firestore != null;
|
||||||
|
|
||||||
/// Creates a new Group Ride and establishes the organizer participant record.
|
/// 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).
|
/// Responds to a join request (organizer or authorized club leadership).
|
||||||
Future<void> respondToJoinRequest({
|
Future<void> respondToJoinRequest({
|
||||||
required String callerUid,
|
required String callerUid,
|
||||||
|
|
|
||||||
|
|
@ -577,4 +577,96 @@ void main() {
|
||||||
expect(disabled.isStale, isTrue);
|
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)));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/app_colors.dart';
|
||||||
import 'package:motorcycle_dash/core/theme/theme.dart';
|
import 'package:motorcycle_dash/core/theme/theme.dart';
|
||||||
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
|
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
|
||||||
|
|
@ -1684,6 +1687,230 @@ void main() {
|
||||||
expect(partSnap.data()!['status'], equals('declined'));
|
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 {
|
class _FakeGroupRideLifecycleService implements GroupRideService {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue