feat: add secure club membership lifecycle

This commit is contained in:
Developer 2026-09-06 21:46:06 -04:00
parent a805497f6a
commit 2e76561f67
13 changed files with 3923 additions and 460 deletions

File diff suppressed because it is too large Load Diff

View File

@ -853,6 +853,40 @@ service cloud.firestore {
)
);
function isValidOpenReentry() {
return request.auth.uid == memberUid
&& resource.data.status in ['left', 'declined', 'removed']
&& request.resource.data.status == 'active'
&& request.resource.data.role == 'member'
&& request.resource.data.uid == memberUid
&& request.resource.data.clubId == clubId
&& !isBanned(memberUid)
&& getClubDoc().data.admissionPolicy == 'open'
&& getClubDoc().data.archivedAt == null
&& !areMutuallyBlocked(request.auth.uid, getClubDoc().data.ownerUid)
&& request.resource.data.keys().hasOnly(['uid', 'clubId', 'status', 'role', 'joinedAt', 'updatedAt'])
&& (!('joinedAt' in request.resource.data) || request.resource.data.joinedAt is timestamp)
&& (!('updatedAt' in request.resource.data) || request.resource.data.updatedAt is timestamp)
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == getClubDoc().data.memberCount + 1;
}
function isValidRequestReentry() {
return request.auth.uid == memberUid
&& resource.data.status in ['left', 'declined', 'removed']
&& request.resource.data.status == 'requested'
&& request.resource.data.role == 'member'
&& request.resource.data.uid == memberUid
&& request.resource.data.clubId == clubId
&& !isBanned(memberUid)
&& getClubDoc().data.admissionPolicy == 'request'
&& getClubDoc().data.archivedAt == null
&& !areMutuallyBlocked(request.auth.uid, getClubDoc().data.ownerUid)
&& request.resource.data.keys().hasOnly(['uid', 'clubId', 'status', 'role', 'requestedAt', 'updatedAt'])
&& (!('requestedAt' in request.resource.data) || request.resource.data.requestedAt is timestamp)
&& (!('updatedAt' in request.resource.data) || request.resource.data.updatedAt is timestamp)
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == getClubDoc().data.memberCount;
}
// Transitions
allow update: if isRegisteredUser() && (
// Invitee accepts invite (invited -> active)
@ -876,6 +910,10 @@ service cloud.firestore {
) ||
// Requestor cancels request (requested -> left)
(request.auth.uid == memberUid && resource.data.status == 'requested' && request.resource.data.status == 'left') ||
// Re-entry to open club (left/declined/removed -> active)
isValidOpenReentry() ||
// Re-entry request to request-policy club (left/declined/removed -> requested)
isValidRequestReentry() ||
// Admin/Owner approves request (requested -> active)
(
isClubAdmin(request.auth.uid)

View File

@ -21,7 +21,7 @@ import '../features/social/rider_profile_screen.dart';
import '../features/social/friends_screen.dart';
import '../features/social/clubs/clubs_hub_screen.dart';
import '../features/social/clubs/create_club_screen.dart';
import '../features/social/clubs/club_preview_screen.dart';
import '../features/social/clubs/club_detail_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';
@ -175,7 +175,7 @@ final appRouter = GoRouter(
path: ':id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ClubPreviewScreen(clubId: id);
return ClubDetailScreen(clubId: id);
},
),
],

View File

@ -0,0 +1,939 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/responsive/responsive_layout.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/theme/theme_extensions.dart';
import '../../../services/auth/auth_providers.dart';
import '../../../services/clubs/club_models.dart';
import '../../../services/clubs/club_providers.dart';
import 'club_membership_controller.dart';
import 'club_membership_presentation.dart';
import 'widgets/club_monogram.dart';
/// Authoritative detail and membership lifecycle screen for a MotorSignal Club.
/// Enables riders to discover, join, request admission, withdraw requests,
/// accept/decline invitations, and leave active memberships.
class ClubDetailScreen extends ConsumerWidget {
final String clubId;
const ClubDetailScreen({
super.key,
required this.clubId,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = context.motorColors;
final theme = Theme.of(context);
final user = ref.watch(currentUserProvider);
final isAuthenticated = user != null && !user.isAnonymous;
final currentUserId = user?.uid;
final clubAsync = ref.watch(clubDetailProvider(clubId));
final memberAsync = ref.watch(currentMemberProvider(clubId));
final actionState = ref.watch(clubMembershipControllerProvider(clubId));
final isMutating = actionState.isSubmitting;
// Listen for feedback snackbars
ref.listen<ClubMembershipActionState>(clubMembershipControllerProvider(clubId), (prev, next) {
if (next.error != null && next.error != prev?.error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(next.error!),
backgroundColor: colors.raceRed,
behavior: SnackBarBehavior.floating,
),
);
}
if (next.success != null && next.success != prev?.success) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(next.success!),
backgroundColor: colors.launchGreen,
behavior: SnackBarBehavior.floating,
),
);
}
});
return Scaffold(
appBar: AppBar(
title: const Text(
'Club',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontWeight: FontWeight.w700,
),
),
),
body: SafeArea(
child: clubAsync.when(
data: (club) {
if (club == null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.shield_outlined, size: 48, color: colors.warningAmber),
const SizedBox(height: 16),
const Text(
'Club Not Found',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'This club may have been archived or does not exist.',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 13,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => context.go('/social/clubs'),
icon: const Icon(Icons.arrow_back, size: 16),
label: const Text('Back to Clubs'),
style: ElevatedButton.styleFrom(
backgroundColor: colors.surfaceContainerHigh,
foregroundColor: colors.cyberCyan,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: colors.outlineVariant),
),
),
),
],
),
),
);
}
if (club.isArchived) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.archive_outlined, size: 48, color: colors.warningAmber),
const SizedBox(height: 16),
const Text(
'Club Archived',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'This club has been archived by leadership and is no longer active.',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 13,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => context.go('/social/clubs'),
icon: const Icon(Icons.arrow_back, size: 16),
label: const Text('Back to Clubs'),
style: ElevatedButton.styleFrom(
backgroundColor: colors.surfaceContainerHigh,
foregroundColor: colors.cyberCyan,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: colors.outlineVariant),
),
),
),
],
),
),
);
}
final myMembership = memberAsync.valueOrNull;
return ResponsiveLayout(
builder: (context, responsive) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 750),
child: SingleChildScrollView(
padding: const EdgeInsets.all(18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Club Hero Identity Card
_buildHeroCard(context, club, myMembership, colors, theme),
const SizedBox(height: 16),
// Interactive Membership Lifecycle Action Card
_buildMembershipActionCard(
context,
ref,
club,
memberAsync,
currentUserId,
isAuthenticated,
isMutating,
colors,
theme,
),
const SizedBox(height: 18),
// Description Card
_buildDescriptionCard(context, club, colors, theme),
const SizedBox(height: 18),
// Metadata Card (Region, Focus, Dates)
_buildMetadataCard(context, club, colors, theme),
],
),
),
),
);
},
);
},
loading: () => Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(colors.cyberCyan),
),
),
error: (err, _) => Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.shield_outlined, size: 36, color: colors.warningAmber),
const SizedBox(height: 12),
const Text(
'Club Unavailable',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'Unable to display this club at this time.',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 13,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => context.go('/social/clubs'),
icon: const Icon(Icons.arrow_back, size: 16),
label: const Text('Back to Clubs'),
style: ElevatedButton.styleFrom(
backgroundColor: colors.surfaceContainerHigh,
foregroundColor: colors.cyberCyan,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: colors.outlineVariant),
),
),
),
],
),
),
),
),
),
);
}
Widget _buildHeroCard(
BuildContext context,
Club club,
ClubMember? member,
MotorSignalThemeColors colors,
ThemeData theme,
) {
return Container(
padding: const EdgeInsets.all(20.0),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colors.outlineVariant, width: 0.8),
),
child: Column(
children: [
ClubMonogram(name: club.name, size: 68, fontSize: 26),
const SizedBox(height: 14),
Text(
club.name,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 20,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Text(
'@${club.slug}',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 14,
fontWeight: FontWeight.w600,
color: colors.cyberCyan,
),
),
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 6,
alignment: WrapAlignment.center,
children: [
_buildPill(
icon: Icons.people_outline,
label: '${club.memberCount} ${club.memberCount == 1 ? 'member' : 'members'}',
colors: colors,
theme: theme,
),
_buildPill(
icon: club.admissionPolicy == ClubAdmissionPolicy.open
? Icons.lock_open_outlined
: Icons.how_to_reg_outlined,
label: club.admissionPolicy.label,
colors: colors,
theme: theme,
tintColor: colors.cyberCyan,
),
_buildPill(
icon: club.visibility == ClubVisibility.public
? Icons.public
: Icons.visibility_off_outlined,
label: club.visibility.label,
colors: colors,
theme: theme,
),
if (member != null &&
member.isActive &&
(member.role == ClubRole.owner || member.role == ClubRole.admin))
_buildPill(
icon: Icons.verified_user_outlined,
label: 'ROLE: ${member.role.label.toUpperCase()}',
colors: colors,
theme: theme,
tintColor: colors.launchGreen,
),
],
),
],
),
);
}
Widget _buildMembershipActionCard(
BuildContext context,
WidgetRef ref,
Club club,
AsyncValue<ClubMember?> memberAsync,
String? currentUserId,
bool isAuthenticated,
bool isMutating,
MotorSignalThemeColors colors,
ThemeData theme,
) {
if (isAuthenticated && memberAsync.isLoading) {
return Container(
padding: const EdgeInsets.all(18.0),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: colors.outlineVariant, width: 0.8),
),
child: Row(
children: [
SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(colors.cyberCyan),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Checking Membership Status...',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
'Verifying status with club registry.',
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
);
}
if (isAuthenticated && memberAsync.hasError) {
return Container(
padding: const EdgeInsets.all(18.0),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: colors.warningAmber.withValues(alpha: 0.6), width: 0.8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.warning_amber_rounded, size: 18, color: colors.warningAmber),
const SizedBox(width: 8),
const Text(
'Membership Status Unavailable',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 6),
Text(
'Could not load your membership record. Check connection and retry.',
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => ref.invalidate(currentMemberProvider(club.id)),
icon: const Icon(Icons.refresh, size: 16),
label: const Text('Retry'),
style: OutlinedButton.styleFrom(
foregroundColor: colors.cyberCyan,
side: BorderSide(color: colors.cyberCyan.withValues(alpha: 0.6)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
),
),
],
),
);
}
final myMembership = memberAsync.valueOrNull;
final presentation = ClubMembershipPresentation.resolve(
club: club,
member: myMembership,
currentUserId: currentUserId,
isAuthenticated: isAuthenticated,
);
Color badgeBgColor;
Color badgeTextColor;
switch (presentation.badgeType) {
case PresentationBadgeType.green:
badgeBgColor = colors.launchGreen.withValues(alpha: 0.15);
badgeTextColor = colors.launchGreen;
break;
case PresentationBadgeType.amber:
badgeBgColor = colors.warningAmber.withValues(alpha: 0.15);
badgeTextColor = colors.warningAmber;
break;
case PresentationBadgeType.cyan:
badgeBgColor = colors.cyberCyan.withValues(alpha: 0.15);
badgeTextColor = colors.cyberCyan;
break;
case PresentationBadgeType.neutral:
badgeBgColor = colors.surfaceContainerHighest.withValues(alpha: 0.5);
badgeTextColor = theme.colorScheme.onSurfaceVariant;
break;
}
return Container(
padding: const EdgeInsets.all(18.0),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: presentation.badgeType == PresentationBadgeType.amber
? colors.warningAmber.withValues(alpha: 0.6)
: colors.outlineVariant,
width: 0.8,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
decoration: BoxDecoration(
color: badgeBgColor,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: badgeTextColor.withValues(alpha: 0.6), width: 0.8),
),
child: Text(
presentation.statusBadge,
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 11,
fontWeight: FontWeight.w700,
color: badgeTextColor,
letterSpacing: 0.4,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
presentation.statusHeadline,
style: const TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: 10),
Text(
presentation.statusDescription,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 12.5,
color: theme.colorScheme.onSurfaceVariant,
height: 1.35,
),
),
const SizedBox(height: 14),
if (isMutating)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(colors.cyberCyan),
),
),
),
)
else ...[
if (presentation.state == ClubMembershipPresentationState.invited)
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
ref
.read(clubMembershipControllerProvider(club.id).notifier)
.declineInvitation(club.id);
},
style: OutlinedButton.styleFrom(
foregroundColor: colors.raceRed,
side: BorderSide(color: colors.raceRed.withValues(alpha: 0.6)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text(
'Decline',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
onPressed: () {
ref
.read(clubMembershipControllerProvider(club.id).notifier)
.acceptInvitation(club.id);
},
style: FilledButton.styleFrom(
backgroundColor: colors.launchGreen,
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
icon: const Icon(Icons.check, size: 18),
label: const Text(
'Accept',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontWeight: FontWeight.w700,
),
),
),
),
],
)
else if (presentation.primaryAction != null)
SizedBox(
width: double.infinity,
child: _buildActionButton(
context,
ref,
club,
presentation,
colors,
),
),
],
],
),
);
}
Widget _buildActionButton(
BuildContext context,
WidgetRef ref,
Club club,
ClubMembershipPresentation presentation,
MotorSignalThemeColors colors,
) {
final action = presentation.primaryAction!;
final label = presentation.primaryActionLabel ?? '';
final icon = presentation.primaryActionIcon;
if (action == ClubMembershipAction.signIn) {
return FilledButton.icon(
onPressed: () => context.push('/settings/account'),
style: FilledButton.styleFrom(
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
icon: Icon(icon, size: 18),
label: Text(
label,
style: const TextStyle(
fontFamily: AppTypography.fontHeadline,
fontWeight: FontWeight.w700,
),
),
);
}
if (action == ClubMembershipAction.leaveClub) {
return OutlinedButton.icon(
onPressed: () => _confirmLeaveClub(context, ref, club, colors),
style: OutlinedButton.styleFrom(
foregroundColor: colors.raceRed,
side: BorderSide(color: colors.raceRed.withValues(alpha: 0.6)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
icon: Icon(icon, size: 18),
label: Text(
label,
style: const TextStyle(
fontFamily: AppTypography.fontHeadline,
fontWeight: FontWeight.w600,
),
),
);
}
if (action == ClubMembershipAction.withdrawRequest) {
return OutlinedButton.icon(
onPressed: () {
ref.read(clubMembershipControllerProvider(club.id).notifier).withdrawJoinRequest(club.id);
},
style: OutlinedButton.styleFrom(
foregroundColor: colors.warningAmber,
side: BorderSide(color: colors.warningAmber.withValues(alpha: 0.6)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
icon: Icon(icon, size: 18),
label: Text(
label,
style: const TextStyle(
fontFamily: AppTypography.fontHeadline,
fontWeight: FontWeight.w600,
),
),
);
}
// Default: FilledButton (Join Club or Request to Join)
return FilledButton.icon(
onPressed: () {
if (action == ClubMembershipAction.joinOpen) {
ref.read(clubMembershipControllerProvider(club.id).notifier).joinOpenClub(club.id);
} else if (action == ClubMembershipAction.requestToJoin) {
ref.read(clubMembershipControllerProvider(club.id).notifier).requestToJoinClub(club.id);
}
},
style: FilledButton.styleFrom(
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
icon: Icon(icon, size: 18),
label: Text(
label,
style: const TextStyle(
fontFamily: AppTypography.fontHeadline,
fontWeight: FontWeight.w700,
),
),
);
}
void _confirmLeaveClub(
BuildContext context,
WidgetRef ref,
Club club,
MotorSignalThemeColors colors,
) {
showDialog(
context: context,
builder: (dialogCtx) => AlertDialog(
scrollable: true,
title: Text('Leave ${club.name}?'),
content: const Text(
'Are you sure you want to leave this club? You will lose access to member-only rides and updates.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: colors.raceRed,
foregroundColor: Colors.white,
),
onPressed: () {
Navigator.of(dialogCtx).pop();
ref.read(clubMembershipControllerProvider(club.id).notifier).leaveClub(club.id);
},
child: const Text('Leave Club'),
),
],
),
);
}
Widget _buildDescriptionCard(
BuildContext context,
Club club,
MotorSignalThemeColors colors,
ThemeData theme,
) {
return Container(
padding: const EdgeInsets.all(18.0),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: colors.outlineVariant, width: 0.8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'About the Club',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 13,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
color: colors.cyberCyan,
),
),
const SizedBox(height: 10),
Text(
club.description.isNotEmpty
? club.description
: 'No club description provided by leadership.',
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 13.5,
color: club.description.isNotEmpty
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant,
height: 1.4,
),
),
],
),
);
}
Widget _buildMetadataCard(
BuildContext context,
Club club,
MotorSignalThemeColors colors,
ThemeData theme,
) {
return Container(
padding: const EdgeInsets.all(18.0),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: colors.outlineVariant, width: 0.8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Club Details',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 13,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
color: colors.cyberCyan,
),
),
const SizedBox(height: 12),
_buildDetailRow(
icon: Icons.location_on_outlined,
title: 'Home Region',
value: club.homeRegion?.trim().isNotEmpty == true ? club.homeRegion! : 'Global / Not Specified',
colors: colors,
theme: theme,
),
const Divider(height: 18),
_buildDetailRow(
icon: Icons.two_wheeler_outlined,
title: 'Motorcycle Focus',
value: club.motorcycleFocus?.trim().isNotEmpty == true
? club.motorcycleFocus!
: 'All Motorcycles & Disciplines',
colors: colors,
theme: theme,
),
const Divider(height: 18),
_buildDetailRow(
icon: Icons.calendar_today_outlined,
title: 'Established',
value: '${club.createdAt.year}-${club.createdAt.month.toString().padLeft(2, '0')}-${club.createdAt.day.toString().padLeft(2, '0')}',
colors: colors,
theme: theme,
),
],
),
);
}
Widget _buildDetailRow({
required IconData icon,
required String title,
required String value,
required MotorSignalThemeColors colors,
required ThemeData theme,
}) {
return Row(
children: [
Icon(icon, size: 18, color: colors.cyberCyan),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 11.5,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
value,
style: const TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 13.5,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
);
}
Widget _buildPill({
required IconData icon,
required String label,
required MotorSignalThemeColors colors,
required ThemeData theme,
Color? tintColor,
}) {
final effectiveColor = tintColor ?? theme.colorScheme.onSurfaceVariant;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.6), width: 0.6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 13, color: effectiveColor),
const SizedBox(width: 5),
Text(
label,
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 11.5,
fontWeight: FontWeight.w600,
color: effectiveColor,
),
),
],
),
);
}
}

View File

@ -0,0 +1,242 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../services/auth/auth_providers.dart';
import '../../../services/clubs/club_providers.dart';
import '../../../services/clubs/club_service.dart';
/// State of active membership mutations (join, request, withdraw, accept, decline, leave).
@immutable
class ClubMembershipActionState {
final bool isSubmitting;
final String? error;
final String? success;
final String? activeClubId;
const ClubMembershipActionState({
this.isSubmitting = false,
this.error,
this.success,
this.activeClubId,
});
ClubMembershipActionState copyWith({
bool? isSubmitting,
String? error,
String? success,
String? activeClubId,
bool clearError = false,
bool clearSuccess = false,
}) {
return ClubMembershipActionState(
isSubmitting: isSubmitting ?? this.isSubmitting,
error: clearError ? null : (error ?? this.error),
success: clearSuccess ? null : (success ?? this.success),
activeClubId: activeClubId ?? this.activeClubId,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ClubMembershipActionState &&
runtimeType == other.runtimeType &&
isSubmitting == other.isSubmitting &&
error == other.error &&
success == other.success &&
activeClubId == other.activeClubId;
@override
int get hashCode =>
isSubmitting.hashCode ^
error.hashCode ^
success.hashCode ^
activeClubId.hashCode;
}
/// Controller coordinating membership lifecycle operations.
/// Interacts with authoritative ClubService and synchronizes Riverpod streams.
class ClubMembershipController extends StateNotifier<ClubMembershipActionState> {
final Ref _ref;
final String clubId;
ClubMembershipController(this._ref, this.clubId)
: super(ClubMembershipActionState(activeClubId: clubId));
void clearMessages() {
state = state.copyWith(clearError: true, clearSuccess: true);
}
/// Direct self-join for open admission clubs.
Future<bool> joinOpenClub([String? targetClubId]) async {
final effectiveClubId = targetClubId ?? clubId;
return _performMutation(
clubId: effectiveClubId,
mutation: (service, uid) => service.joinOpenClub(effectiveClubId, callerUid: uid),
successMessage: 'Successfully joined club!',
onSuccessInvalidate: () {
_ref.invalidate(currentMemberProvider(effectiveClubId));
_ref.invalidate(clubDetailProvider(effectiveClubId));
_ref.invalidate(myClubsProvider);
},
);
}
/// Request admission for request-policy clubs.
Future<bool> requestToJoinClub([String? targetClubId]) async {
final effectiveClubId = targetClubId ?? clubId;
return _performMutation(
clubId: effectiveClubId,
mutation: (service, uid) => service.requestToJoinClub(effectiveClubId, callerUid: uid),
successMessage: 'Join request submitted to club leadership.',
onSuccessInvalidate: () {
_ref.invalidate(currentMemberProvider(effectiveClubId));
},
);
}
/// Withdraw an existing pending request.
Future<bool> withdrawJoinRequest([String? targetClubId]) async {
final effectiveClubId = targetClubId ?? clubId;
return _performMutation(
clubId: effectiveClubId,
mutation: (service, uid) => service.withdrawJoinRequest(effectiveClubId, callerUid: uid),
successMessage: 'Join request withdrawn.',
onSuccessInvalidate: () {
_ref.invalidate(currentMemberProvider(effectiveClubId));
},
);
}
/// Accept an incoming leadership invitation.
Future<bool> acceptInvitation([String? targetClubId]) async {
final effectiveClubId = targetClubId ?? clubId;
return _performMutation(
clubId: effectiveClubId,
mutation: (service, uid) =>
service.respondToInvitation(effectiveClubId, accept: true, callerUid: uid),
successMessage: 'Invitation accepted! Welcome to the club.',
onSuccessInvalidate: () {
_ref.invalidate(currentMemberProvider(effectiveClubId));
_ref.invalidate(clubDetailProvider(effectiveClubId));
_ref.invalidate(myClubsProvider);
_ref.invalidate(myClubInvitesProvider);
},
);
}
/// Decline an incoming leadership invitation.
Future<bool> declineInvitation([String? targetClubId]) async {
final effectiveClubId = targetClubId ?? clubId;
return _performMutation(
clubId: effectiveClubId,
mutation: (service, uid) =>
service.respondToInvitation(effectiveClubId, accept: false, callerUid: uid),
successMessage: 'Invitation declined.',
onSuccessInvalidate: () {
_ref.invalidate(currentMemberProvider(effectiveClubId));
_ref.invalidate(myClubInvitesProvider);
},
);
}
/// Voluntarily leave an active club membership.
/// Enforces invariant: Owner cannot leave.
Future<bool> leaveClub([String? targetClubId]) async {
final effectiveClubId = targetClubId ?? clubId;
return _performMutation(
clubId: effectiveClubId,
mutation: (service, uid) => service.leaveClub(effectiveClubId, callerUid: uid),
successMessage: 'You have left the club.',
onSuccessInvalidate: () {
_ref.invalidate(currentMemberProvider(effectiveClubId));
_ref.invalidate(clubDetailProvider(effectiveClubId));
_ref.invalidate(myClubsProvider);
},
);
}
Future<bool> _performMutation({
required String clubId,
required Future<void> Function(ClubService service, String uid) mutation,
required String successMessage,
required VoidCallback onSuccessInvalidate,
}) async {
// Concurrency guard: reject duplicate or overlapping mutations in-flight
if (state.isSubmitting) {
return false;
}
final user = _ref.read(currentUserProvider);
if (user == null || user.isAnonymous) {
state = state.copyWith(
error: 'You must be signed in with a registered account to perform this action.',
clearSuccess: true,
);
return false;
}
state = ClubMembershipActionState(
isSubmitting: true,
activeClubId: clubId,
);
try {
final service = _ref.read(clubServiceProvider);
await mutation(service, user.uid);
// Perform minimal, surgical invalidations for this exact lifecycle transition
onSuccessInvalidate();
state = ClubMembershipActionState(
isSubmitting: false,
success: successMessage,
activeClubId: clubId,
);
return true;
} on ClubOperationException catch (e) {
String message;
switch (e.code) {
case 'banned':
message = 'Membership is unavailable for this club.';
break;
case 'owner-cannot-leave':
message = 'Club owner cannot leave without transferring ownership first.';
break;
case 'policy-mismatch':
message = 'This club does not allow open joining. Please submit a request.';
break;
case 'not-found':
message = 'Club or membership record was not found.';
break;
case 'unavailable':
message = 'Club services are temporarily unavailable. Please try again later.';
break;
default:
message = e.message.isNotEmpty
? e.message
: 'Unable to update club membership right now. Please try again.';
break;
}
state = ClubMembershipActionState(
isSubmitting: false,
error: message,
activeClubId: clubId,
);
return false;
} catch (e) {
state = ClubMembershipActionState(
isSubmitting: false,
error: 'Unable to update club membership right now. Please try again.',
activeClubId: clubId,
);
return false;
}
}
}
/// Family provider for ClubMembershipController scoped per club.
final clubMembershipControllerProvider = StateNotifierProvider.autoDispose
.family<ClubMembershipController, ClubMembershipActionState, String>((ref, clubId) {
return ClubMembershipController(ref, clubId);
});

View File

@ -0,0 +1,244 @@
import 'package:flutter/material.dart';
import '../../../services/clubs/club_models.dart';
/// Semantic state representing the viewer's relationship to a Club.
enum ClubMembershipPresentationState {
unauthenticated,
outsiderOpen,
outsiderRequest,
outsiderInviteOnly,
invited,
requested,
activeMember,
activeAdmin,
activeOwner,
archived,
unavailable,
}
/// Action intent triggered by the rider on a Club.
enum ClubMembershipAction {
joinOpen,
requestToJoin,
withdrawRequest,
acceptInvite,
declineInvite,
leaveClub,
signIn,
}
/// Badge color categorization for automotive UI styling.
enum PresentationBadgeType {
cyan,
green,
amber,
neutral,
}
/// Pure presentation model mapping Club metadata, membership state, and auth context
/// into authoritative, unambiguous UX components.
@immutable
class ClubMembershipPresentation {
final ClubMembershipPresentationState state;
final String statusBadge;
final PresentationBadgeType badgeType;
final String statusHeadline;
final String statusDescription;
final ClubMembershipAction? primaryAction;
final String? primaryActionLabel;
final IconData? primaryActionIcon;
final bool isPrimaryDestructive;
final ClubMembershipAction? secondaryAction;
final String? secondaryActionLabel;
final IconData? secondaryActionIcon;
final bool isSecondaryDestructive;
final bool canLeave;
final bool isActionable;
const ClubMembershipPresentation({
required this.state,
required this.statusBadge,
required this.badgeType,
required this.statusHeadline,
required this.statusDescription,
this.primaryAction,
this.primaryActionLabel,
this.primaryActionIcon,
this.isPrimaryDestructive = false,
this.secondaryAction,
this.secondaryActionLabel,
this.secondaryActionIcon,
this.isSecondaryDestructive = false,
this.canLeave = false,
this.isActionable = false,
});
/// Resolves the pure presentation state from domain models and auth context.
static ClubMembershipPresentation resolve({
required Club? club,
required ClubMember? member,
required String? currentUserId,
required bool isAuthenticated,
}) {
if (club == null) {
return const ClubMembershipPresentation(
state: ClubMembershipPresentationState.unavailable,
statusBadge: 'UNAVAILABLE',
badgeType: PresentationBadgeType.neutral,
statusHeadline: 'Club Not Found',
statusDescription: 'This club could not be loaded or does not exist.',
isActionable: false,
);
}
if (club.isArchived) {
return const ClubMembershipPresentation(
state: ClubMembershipPresentationState.archived,
statusBadge: 'ARCHIVED',
badgeType: PresentationBadgeType.amber,
statusHeadline: 'Club Archived',
statusDescription:
'This club has been archived by leadership and is no longer accepting members.',
canLeave: false,
isActionable: false,
);
}
if (!isAuthenticated || currentUserId == null || currentUserId.isEmpty) {
return const ClubMembershipPresentation(
state: ClubMembershipPresentationState.unauthenticated,
statusBadge: 'GUEST',
badgeType: PresentationBadgeType.neutral,
statusHeadline: 'Sign in to Join',
statusDescription:
'Sign in with a registered account to join this club and ride with members.',
primaryAction: ClubMembershipAction.signIn,
primaryActionLabel: 'Sign In / Register',
primaryActionIcon: Icons.login,
isActionable: true,
);
}
// Authenticated user with an active membership
if (member != null && member.status == ClubMemberStatus.active) {
final isOwner = member.role == ClubRole.owner || club.ownerUid == currentUserId;
if (isOwner) {
return const ClubMembershipPresentation(
state: ClubMembershipPresentationState.activeOwner,
statusBadge: 'OWNER',
badgeType: PresentationBadgeType.green,
statusHeadline: 'Club Owner',
statusDescription:
'You are the owner of this club. Ownership must be transferred before leaving.',
canLeave: false,
isActionable: false,
);
}
if (member.role == ClubRole.admin) {
return const ClubMembershipPresentation(
state: ClubMembershipPresentationState.activeAdmin,
statusBadge: 'ADMIN',
badgeType: PresentationBadgeType.cyan,
statusHeadline: 'Club Administrator',
statusDescription: 'You are an active administrator of this club.',
primaryAction: ClubMembershipAction.leaveClub,
primaryActionLabel: 'Leave Club',
primaryActionIcon: Icons.logout,
isPrimaryDestructive: true,
canLeave: true,
isActionable: true,
);
}
return const ClubMembershipPresentation(
state: ClubMembershipPresentationState.activeMember,
statusBadge: 'MEMBER',
badgeType: PresentationBadgeType.cyan,
statusHeadline: 'Active Member',
statusDescription: 'You are an active member of this club.',
primaryAction: ClubMembershipAction.leaveClub,
primaryActionLabel: 'Leave Club',
primaryActionIcon: Icons.logout,
isPrimaryDestructive: true,
canLeave: true,
isActionable: true,
);
}
// Authenticated user with an incoming invitation
if (member != null && member.status == ClubMemberStatus.invited) {
return const ClubMembershipPresentation(
state: ClubMembershipPresentationState.invited,
statusBadge: 'INVITED',
badgeType: PresentationBadgeType.green,
statusHeadline: 'You Are Invited',
statusDescription: 'You have been invited to join this club by club leadership.',
primaryAction: ClubMembershipAction.acceptInvite,
primaryActionLabel: 'Accept Invitation',
primaryActionIcon: Icons.check_circle_outline,
secondaryAction: ClubMembershipAction.declineInvite,
secondaryActionLabel: 'Decline',
secondaryActionIcon: Icons.close,
isSecondaryDestructive: true,
isActionable: true,
);
}
// Authenticated user with a pending join request
if (member != null && member.status == ClubMemberStatus.requested) {
return const ClubMembershipPresentation(
state: ClubMembershipPresentationState.requested,
statusBadge: 'REQUEST PENDING',
badgeType: PresentationBadgeType.amber,
statusHeadline: 'Membership Requested',
statusDescription: 'Your request to join is awaiting approval from club leadership.',
primaryAction: ClubMembershipAction.withdrawRequest,
primaryActionLabel: 'Withdraw Request',
primaryActionIcon: Icons.cancel_outlined,
isPrimaryDestructive: true,
isActionable: true,
);
}
// Outsider (no member doc or historical doc: left, declined, removed)
switch (club.admissionPolicy) {
case ClubAdmissionPolicy.open:
return const ClubMembershipPresentation(
state: ClubMembershipPresentationState.outsiderOpen,
statusBadge: 'OPEN ADMISSION',
badgeType: PresentationBadgeType.cyan,
statusHeadline: 'Open Membership',
statusDescription: 'This club is open to all riders. Join now to participate in club rides.',
primaryAction: ClubMembershipAction.joinOpen,
primaryActionLabel: 'Join Club',
primaryActionIcon: Icons.group_add,
isActionable: true,
);
case ClubAdmissionPolicy.request:
return const ClubMembershipPresentation(
state: ClubMembershipPresentationState.outsiderRequest,
statusBadge: 'REQUEST REQUIRED',
badgeType: PresentationBadgeType.cyan,
statusHeadline: 'Request Admission',
statusDescription: 'Membership requires review and approval from club leadership.',
primaryAction: ClubMembershipAction.requestToJoin,
primaryActionLabel: 'Request to Join',
primaryActionIcon: Icons.send_outlined,
isActionable: true,
);
case ClubAdmissionPolicy.inviteOnly:
return const ClubMembershipPresentation(
state: ClubMembershipPresentationState.outsiderInviteOnly,
statusBadge: 'INVITE ONLY',
badgeType: PresentationBadgeType.neutral,
statusHeadline: 'Invite-Only Club',
statusDescription:
'This club is invite-only. You must receive an invitation from leadership to join.',
isActionable: false,
);
}
}
}

View File

@ -1,433 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/responsive/responsive_layout.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/theme/theme_extensions.dart';
import '../../../services/clubs/club_models.dart';
import '../../../services/clubs/club_providers.dart';
import 'widgets/club_monogram.dart';
import 'club_detail_screen.dart';
/// Read-only preview destination for a MotorSignal Club.
/// Displays authoritative identity and metadata without CLUB-2C membership mutations.
class ClubPreviewScreen extends ConsumerWidget {
final String clubId;
export 'club_detail_screen.dart';
const ClubPreviewScreen({
super.key,
required this.clubId,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = context.motorColors;
final theme = Theme.of(context);
final clubAsync = ref.watch(clubDetailProvider(clubId));
final memberAsync = ref.watch(currentMemberProvider(clubId));
return Scaffold(
appBar: AppBar(
title: const Text(
'Club',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontWeight: FontWeight.w700,
),
),
),
body: SafeArea(
child: clubAsync.when(
data: (club) {
if (club == null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.shield_outlined, size: 48, color: colors.warningAmber),
const SizedBox(height: 16),
const Text(
'Club Not Found',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'This club may have been archived or does not exist.',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 13,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
if (club.isArchived) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.archive_outlined, size: 48, color: colors.warningAmber),
const SizedBox(height: 16),
const Text(
'Club Archived',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'This club has been archived by leadership and is no longer active.',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 13,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
final myMembership = memberAsync.value;
return ResponsiveLayout(
builder: (context, responsive) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 750),
child: SingleChildScrollView(
padding: const EdgeInsets.all(18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Club Hero Identity Card
_buildHeroCard(context, club, myMembership, colors, theme),
const SizedBox(height: 18),
// Description Card
_buildDescriptionCard(context, club, colors, theme),
const SizedBox(height: 18),
// Metadata Card (Region, Focus, Dates)
_buildMetadataCard(context, club, colors, theme),
],
),
),
),
);
},
);
},
loading: () => Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(colors.cyberCyan),
),
),
error: (err, _) => Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.shield_outlined, size: 36, color: colors.warningAmber),
const SizedBox(height: 12),
const Text(
'Club Unavailable',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'Unable to display this club at this time.',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 13,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
),
),
);
}
Widget _buildHeroCard(
BuildContext context,
Club club,
ClubMember? member,
MotorSignalThemeColors colors,
ThemeData theme,
) {
return Container(
padding: const EdgeInsets.all(20.0),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colors.outlineVariant, width: 0.8),
),
child: Column(
children: [
ClubMonogram(name: club.name, size: 68, fontSize: 26),
const SizedBox(height: 14),
Text(
club.name,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 20,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Text(
'@${club.slug}',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 14,
fontWeight: FontWeight.w600,
color: colors.cyberCyan,
),
),
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 6,
alignment: WrapAlignment.center,
children: [
_buildPill(
icon: Icons.people_outline,
label: '${club.memberCount} ${club.memberCount == 1 ? 'member' : 'members'}',
colors: colors,
theme: theme,
),
_buildPill(
icon: club.admissionPolicy == ClubAdmissionPolicy.open
? Icons.lock_open_outlined
: Icons.how_to_reg_outlined,
label: club.admissionPolicy.label,
colors: colors,
theme: theme,
tintColor: colors.cyberCyan,
),
_buildPill(
icon: club.visibility == ClubVisibility.public
? Icons.public
: Icons.visibility_off_outlined,
label: club.visibility.label,
colors: colors,
theme: theme,
),
if (member != null &&
member.isActive &&
(member.role == ClubRole.owner || member.role == ClubRole.admin))
_buildPill(
icon: Icons.verified_user_outlined,
label: 'ROLE: ${member.role.label.toUpperCase()}',
colors: colors,
theme: theme,
tintColor: colors.launchGreen,
),
],
),
],
),
);
}
Widget _buildDescriptionCard(
BuildContext context,
Club club,
MotorSignalThemeColors colors,
ThemeData theme,
) {
return Container(
padding: const EdgeInsets.all(18.0),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: colors.outlineVariant, width: 0.8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'About the Club',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 13,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
color: colors.cyberCyan,
),
),
const SizedBox(height: 10),
Text(
club.description.isNotEmpty
? club.description
: 'No club description provided by leadership.',
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 13.5,
color: club.description.isNotEmpty
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant,
height: 1.4,
),
),
],
),
);
}
Widget _buildMetadataCard(
BuildContext context,
Club club,
MotorSignalThemeColors colors,
ThemeData theme,
) {
return Container(
padding: const EdgeInsets.all(18.0),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: colors.outlineVariant, width: 0.8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Club Details',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 13,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
color: colors.cyberCyan,
),
),
const SizedBox(height: 12),
_buildDetailRow(
icon: Icons.location_on_outlined,
title: 'Home Region',
value: club.homeRegion?.trim().isNotEmpty == true ? club.homeRegion! : 'Global / Not Specified',
colors: colors,
theme: theme,
),
const Divider(height: 18),
_buildDetailRow(
icon: Icons.two_wheeler_outlined,
title: 'Motorcycle Focus',
value: club.motorcycleFocus?.trim().isNotEmpty == true
? club.motorcycleFocus!
: 'All Motorcycles & Disciplines',
colors: colors,
theme: theme,
),
const Divider(height: 18),
_buildDetailRow(
icon: Icons.calendar_today_outlined,
title: 'Established',
value: '${club.createdAt.year}-${club.createdAt.month.toString().padLeft(2, '0')}-${club.createdAt.day.toString().padLeft(2, '0')}',
colors: colors,
theme: theme,
),
],
),
);
}
Widget _buildDetailRow({
required IconData icon,
required String title,
required String value,
required MotorSignalThemeColors colors,
required ThemeData theme,
}) {
return Row(
children: [
Icon(icon, size: 18, color: colors.cyberCyan),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 11.5,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
value,
style: const TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 13.5,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
);
}
Widget _buildPill({
required IconData icon,
required String label,
required MotorSignalThemeColors colors,
required ThemeData theme,
Color? tintColor,
}) {
final effectiveColor = tintColor ?? theme.colorScheme.onSurfaceVariant;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.6), width: 0.6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 13, color: effectiveColor),
const SizedBox(width: 5),
Text(
label,
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 11.5,
fontWeight: FontWeight.w600,
color: effectiveColor,
),
),
],
),
);
}
}
/// Backwards compatibility alias for ClubDetailScreen.
typedef ClubPreviewScreen = ClubDetailScreen;

View File

@ -9,6 +9,7 @@ import '../../../services/clubs/club_models.dart';
import '../../../services/clubs/club_providers.dart';
import '../../../services/clubs/club_slug_validator.dart';
import 'widgets/club_card.dart';
import 'widgets/club_invitation_card.dart';
import 'widgets/club_member_card.dart';
/// Authoritative Hub for MotorSignal Clubs.
@ -248,36 +249,20 @@ class _ClubsHubScreenState extends ConsumerState<ClubsHubScreen>
Widget _buildMyClubsTab(BuildContext context, MotorSignalThemeColors colors) {
final myClubsAsync = ref.watch(myClubsProvider);
final myInvitesAsync = ref.watch(myClubInvitesProvider);
return myClubsAsync.when(
data: (memberships) {
if (memberships.isEmpty) {
return _buildEmptyMyClubs(context, colors);
}
return RefreshIndicator(
onRefresh: () async {
ref.invalidate(myClubsProvider);
},
child: ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: memberships.length,
itemBuilder: (context, index) {
final member = memberships[index];
return ClubMemberCard(
key: ValueKey('my_club_${member.clubId}'),
member: member,
);
},
),
);
},
loading: () => Center(
// If both are loading, show loading spinner
if (myClubsAsync.isLoading && myInvitesAsync.isLoading) {
return Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(colors.cyberCyan),
),
),
error: (err, _) => Center(
);
}
// If both failed, show error
if (myClubsAsync.hasError && myInvitesAsync.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
@ -294,12 +279,97 @@ class _ClubsHubScreenState extends ConsumerState<ClubsHubScreen>
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: () => ref.invalidate(myClubsProvider),
onPressed: () {
ref.invalidate(myClubsProvider);
ref.invalidate(myClubInvitesProvider);
},
child: const Text('Retry'),
),
],
),
),
);
}
final memberships = myClubsAsync.value ?? const [];
final invites = myInvitesAsync.value ?? const [];
if (memberships.isEmpty && invites.isEmpty) {
return _buildEmptyMyClubs(context, colors);
}
return RefreshIndicator(
onRefresh: () async {
ref.invalidate(myClubsProvider);
ref.invalidate(myClubInvitesProvider);
},
child: ListView(
padding: const EdgeInsets.all(16.0),
children: [
if (invites.isNotEmpty) ...[
Row(
children: [
Text(
'CLUB INVITATIONS',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 12.5,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
color: colors.launchGreen,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: colors.launchGreen.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${invites.length}',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 11,
fontWeight: FontWeight.w700,
color: colors.launchGreen,
),
),
),
],
),
const SizedBox(height: 10),
for (final invite in invites)
ClubInvitationCard(
key: ValueKey('invite_${invite.clubId}'),
member: invite,
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 12),
],
if (memberships.isNotEmpty) ...[
if (invites.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 10.0),
child: Text(
'ACTIVE MEMBERSHIPS',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 12.5,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
color: colors.cyberCyan,
),
),
),
for (final member in memberships)
ClubMemberCard(
key: ValueKey('my_club_${member.clubId}'),
member: member,
),
],
],
),
);
}

View File

@ -0,0 +1,203 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/app_typography.dart';
import '../../../../core/theme/theme_extensions.dart';
import '../../../../services/clubs/club_models.dart';
import '../../../../services/clubs/club_providers.dart';
import '../club_membership_controller.dart';
import 'club_monogram.dart';
/// Automotive card presenting a pending Club invitation in the My Clubs hub.
/// Provides immediate inline accept and decline actions and full preview on tap.
class ClubInvitationCard extends ConsumerWidget {
final ClubMember member;
const ClubInvitationCard({
super.key,
required this.member,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = context.motorColors;
final theme = Theme.of(context);
final clubAsync = ref.watch(clubDetailProvider(member.clubId));
final actionState = ref.watch(clubMembershipControllerProvider(member.clubId));
final isMutating = actionState.isSubmitting;
return Container(
margin: const EdgeInsets.only(bottom: 12.0),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: colors.launchGreen.withValues(alpha: 0.5), width: 1.0),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => context.push('/social/clubs/${member.clubId}'),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: clubAsync.when(
data: (club) {
if (club == null) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
ClubMonogram(name: club.name, size: 42, fontSize: 16),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
club.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
'@${club.slug}',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 12.5,
color: colors.cyberCyan,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: colors.launchGreen.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: colors.launchGreen, width: 0.8),
),
child: Text(
'INVITED',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 10.5,
fontWeight: FontWeight.w700,
color: colors.launchGreen,
letterSpacing: 0.5,
),
),
),
],
),
const SizedBox(height: 12),
Text(
'You were invited to join this motorcycle club.',
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 12.5,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 14),
if (isMutating)
Center(
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(colors.cyberCyan),
),
),
)
else
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
ref
.read(clubMembershipControllerProvider(member.clubId).notifier)
.declineInvitation(member.clubId);
},
style: OutlinedButton.styleFrom(
foregroundColor: colors.raceRed,
side: BorderSide(color: colors.raceRed.withValues(alpha: 0.6)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(vertical: 10),
),
child: const Text(
'Decline',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontWeight: FontWeight.w600,
fontSize: 12.5,
),
),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton.icon(
onPressed: () {
ref
.read(clubMembershipControllerProvider(member.clubId).notifier)
.acceptInvitation(member.clubId);
},
style: FilledButton.styleFrom(
backgroundColor: colors.launchGreen,
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(vertical: 10),
),
icon: const Icon(Icons.check, size: 16),
label: const Text(
'Accept',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontWeight: FontWeight.w700,
fontSize: 12.5,
),
),
),
),
],
),
],
);
},
loading: () => Center(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(colors.cyberCyan),
),
),
),
),
error: (_, _) => const SizedBox.shrink(),
),
),
),
),
);
}
}

View File

@ -0,0 +1,502 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/core/theme/theme_extensions.dart';
import 'package:motorcycle_dash/features/social/clubs/club_detail_screen.dart';
import 'package:motorcycle_dash/services/auth/auth_providers.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/clubs/club_service.dart';
class MockClubServiceForDetail extends ClubService {
String? lastAction;
String? lastClubId;
bool? lastAccept;
@override
Future<void> joinOpenClub(String clubId, {String? callerUid}) async {
lastAction = 'joinOpen';
lastClubId = clubId;
}
@override
Future<void> requestToJoinClub(String clubId, {String? callerUid}) async {
lastAction = 'requestToJoin';
lastClubId = clubId;
}
@override
Future<void> withdrawJoinRequest(String clubId, {String? callerUid}) async {
lastAction = 'withdrawRequest';
lastClubId = clubId;
}
@override
Future<void> respondToInvitation(String clubId, {required bool accept, String? callerUid}) async {
lastAction = 'respondInvite';
lastClubId = clubId;
lastAccept = accept;
}
@override
Future<void> leaveClub(String clubId, {String? callerUid}) async {
lastAction = 'leaveClub';
lastClubId = clubId;
}
}
void main() {
group('ClubDetailScreen Widget Lifecycle Tests', () {
late MockClubServiceForDetail mockService;
const testUser = AuthUser(uid: 'rider_1', isAnonymous: false);
final openClub = Club(
id: 'club_open',
name: 'Open Track Riders',
slug: 'open-track',
description: 'Track day motorcycle community',
ownerUid: 'owner_bob',
memberCount: 12,
visibility: ClubVisibility.public,
admissionPolicy: ClubAdmissionPolicy.open,
createdAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
final requestClub = Club(
id: 'club_request',
name: 'Sierra Twisties',
slug: 'sierra-twisties',
description: 'Mountain pass riding group',
ownerUid: 'owner_charlie',
memberCount: 5,
visibility: ClubVisibility.public,
admissionPolicy: ClubAdmissionPolicy.request,
createdAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
final inviteClub = Club(
id: 'club_invite',
name: 'Elite Racers',
slug: 'elite-racers',
description: 'Strictly invite only',
ownerUid: 'owner_dan',
memberCount: 3,
visibility: ClubVisibility.public,
admissionPolicy: ClubAdmissionPolicy.inviteOnly,
createdAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
setUp(() {
mockService = MockClubServiceForDetail();
});
testWidgets('renders Open Admission state and executes Join Club', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_open').overrideWith((ref) => Stream.value(openClub)),
currentMemberProvider('club_open').overrideWith((ref) => Stream.value(null)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_open'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('OPEN ADMISSION'), findsOneWidget);
expect(find.text('Open Membership'), findsOneWidget);
final joinButton = find.widgetWithText(FilledButton, 'Join Club');
expect(joinButton, findsOneWidget);
await tester.tap(joinButton);
await tester.pumpAndSettle();
expect(mockService.lastAction, 'joinOpen');
expect(mockService.lastClubId, 'club_open');
});
testWidgets('renders Request Admission state and executes Request to Join', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_request').overrideWith((ref) => Stream.value(requestClub)),
currentMemberProvider('club_request').overrideWith((ref) => Stream.value(null)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_request'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('REQUEST REQUIRED'), findsOneWidget);
expect(find.text('Request Admission'), findsOneWidget);
final requestButton = find.widgetWithText(FilledButton, 'Request to Join');
expect(requestButton, findsOneWidget);
await tester.tap(requestButton);
await tester.pumpAndSettle();
expect(mockService.lastAction, 'requestToJoin');
expect(mockService.lastClubId, 'club_request');
});
testWidgets('renders Invite-Only state with no join action', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_invite').overrideWith((ref) => Stream.value(inviteClub)),
currentMemberProvider('club_invite').overrideWith((ref) => Stream.value(null)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_invite'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Invite-Only Club'), findsOneWidget);
expect(find.text('Join Club'), findsNothing);
expect(find.text('Request to Join'), findsNothing);
});
testWidgets('renders Invited state with Accept and Decline actions', (tester) async {
final inviteMember = ClubMember(
uid: 'rider_1',
clubId: 'club_request',
status: ClubMemberStatus.invited,
role: ClubRole.member,
updatedAt: DateTime(2026, 1, 1),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_request').overrideWith((ref) => Stream.value(requestClub)),
currentMemberProvider('club_request').overrideWith((ref) => Stream.value(inviteMember)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_request'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('INVITED'), findsOneWidget);
expect(find.text('You Are Invited'), findsOneWidget);
expect(find.text('Accept'), findsOneWidget);
expect(find.text('Decline'), findsOneWidget);
await tester.tap(find.text('Accept'));
await tester.pumpAndSettle();
expect(mockService.lastAction, 'respondInvite');
expect(mockService.lastAccept, isTrue);
});
testWidgets('renders Requested state with Withdraw Request action', (tester) async {
final requestMember = ClubMember(
uid: 'rider_1',
clubId: 'club_request',
status: ClubMemberStatus.requested,
role: ClubRole.member,
requestedAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_request').overrideWith((ref) => Stream.value(requestClub)),
currentMemberProvider('club_request').overrideWith((ref) => Stream.value(requestMember)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_request'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('REQUEST PENDING'), findsOneWidget);
expect(find.text('Membership Requested'), findsOneWidget);
expect(find.text('Withdraw Request'), findsOneWidget);
await tester.tap(find.text('Withdraw Request'));
await tester.pumpAndSettle();
expect(mockService.lastAction, 'withdrawRequest');
});
testWidgets('renders Active Member state with Leave Club and confirmation dialog', (tester) async {
final activeMember = ClubMember(
uid: 'rider_1',
clubId: 'club_open',
status: ClubMemberStatus.active,
role: ClubRole.member,
joinedAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_open').overrideWith((ref) => Stream.value(openClub)),
currentMemberProvider('club_open').overrideWith((ref) => Stream.value(activeMember)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_open'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('MEMBER'), findsOneWidget);
expect(find.text('Active Member'), findsOneWidget);
expect(find.text('Leave Club'), findsOneWidget);
// Tap Leave Club to open confirmation dialog
await tester.tap(find.text('Leave Club'));
await tester.pumpAndSettle();
expect(find.text('Leave Open Track Riders?'), findsOneWidget);
expect(find.text('Cancel'), findsOneWidget);
// Confirm leaving inside the dialog
final confirmButtonFinder = find.widgetWithText(FilledButton, 'Leave Club');
expect(confirmButtonFinder, findsOneWidget);
await tester.tap(confirmButtonFinder);
await tester.pumpAndSettle();
expect(mockService.lastAction, 'leaveClub');
expect(mockService.lastClubId, 'club_open');
});
testWidgets('renders Owner state with ownership transfer constraint note and no leave button', (tester) async {
const ownerUser = AuthUser(uid: 'owner_bob', isAnonymous: false);
final ownerMember = ClubMember(
uid: 'owner_bob',
clubId: 'club_open',
status: ClubMemberStatus.active,
role: ClubRole.owner,
joinedAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => ownerUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_open').overrideWith((ref) => Stream.value(openClub)),
currentMemberProvider('club_open').overrideWith((ref) => Stream.value(ownerMember)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_open'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Club Owner'), findsOneWidget);
expect(find.textContaining('Ownership must be transferred before leaving'), findsOneWidget);
expect(find.text('Leave Club'), findsNothing);
});
testWidgets('renders loading state and prevents action button clicks while membership is loading', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_open').overrideWith((ref) => Stream.value(openClub)),
currentMemberProvider('club_open').overrideWith((ref) => const Stream.empty()),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_open'),
),
),
);
await tester.pump();
expect(find.text('Checking Membership Status...'), findsOneWidget);
expect(find.text('Join Club'), findsNothing);
expect(find.text('Request to Join'), findsNothing);
expect(find.text('Leave Club'), findsNothing);
});
testWidgets('renders retryable error state when membership stream errors', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_open').overrideWith((ref) => Stream.value(openClub)),
currentMemberProvider('club_open').overrideWith((ref) => Stream.error(Exception('Network error'))),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_open'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Membership Status Unavailable'), findsOneWidget);
expect(find.text('Retry'), findsOneWidget);
expect(find.text('Join Club'), findsNothing);
expect(find.text('Leave Club'), findsNothing);
});
testWidgets('renders clean unavailable screen with back navigation on club stream error', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('private_club').overrideWith((ref) => Stream.error(Exception('permission-denied'))),
currentMemberProvider('private_club').overrideWith((ref) => const Stream.empty()),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'private_club'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Club Unavailable'), findsOneWidget);
expect(find.text('Unable to display this club at this time.'), findsOneWidget);
expect(find.text('Back to Clubs'), findsOneWidget);
});
testWidgets('renders without overflow on portrait 412x915', (tester) async {
tester.view.physicalSize = const Size(412, 915);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_open').overrideWith((ref) => Stream.value(openClub)),
currentMemberProvider('club_open').overrideWith((ref) => Stream.value(null)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_open'),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
// Scroll to bottom
await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -400));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('renders without overflow on landscape 915x412 including dialog', (tester) async {
tester.view.physicalSize = const Size(915, 412);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
final activeMember = ClubMember(
uid: 'rider_1',
clubId: 'club_open',
status: ClubMemberStatus.active,
role: ClubRole.member,
joinedAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_open').overrideWith((ref) => Stream.value(openClub)),
currentMemberProvider('club_open').overrideWith((ref) => Stream.value(activeMember)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_open'),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
// Open leave confirmation dialog
await tester.ensureVisible(find.text('Leave Club'));
await tester.pumpAndSettle();
await tester.tap(find.text('Leave Club'));
await tester.pumpAndSettle();
expect(find.text('Leave Open Track Riders?'), findsOneWidget);
expect(tester.takeException(), isNull);
});
});
}

View File

@ -0,0 +1,282 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/features/social/clubs/club_membership_controller.dart';
import 'package:motorcycle_dash/services/auth/auth_providers.dart';
import 'package:motorcycle_dash/services/auth/auth_user.dart';
import 'package:motorcycle_dash/services/clubs/club_providers.dart';
import 'package:motorcycle_dash/services/clubs/club_service.dart';
class FakeClubServiceForController extends ClubService {
String? lastAction;
String? lastClubId;
String? lastCallerUid;
bool? lastAccept;
bool shouldThrow = false;
String? throwCode;
String? throwMessage;
Future<void> Function()? onJoinAsync;
@override
Future<void> joinOpenClub(String clubId, {String? callerUid}) async {
if (onJoinAsync != null) {
await onJoinAsync!();
}
if (shouldThrow) {
throw ClubOperationException(throwMessage ?? 'Error', code: throwCode);
}
lastAction = 'joinOpen';
lastClubId = clubId;
lastCallerUid = callerUid;
}
@override
Future<void> requestToJoinClub(String clubId, {String? callerUid}) async {
if (shouldThrow) {
throw ClubOperationException(throwMessage ?? 'Error', code: throwCode);
}
lastAction = 'requestToJoin';
lastClubId = clubId;
lastCallerUid = callerUid;
}
@override
Future<void> withdrawJoinRequest(String clubId, {String? callerUid}) async {
if (shouldThrow) {
throw ClubOperationException(throwMessage ?? 'Error', code: throwCode);
}
lastAction = 'withdrawRequest';
lastClubId = clubId;
lastCallerUid = callerUid;
}
@override
Future<void> respondToInvitation(String clubId, {required bool accept, String? callerUid}) async {
if (shouldThrow) {
throw ClubOperationException(throwMessage ?? 'Error', code: throwCode);
}
lastAction = 'respondInvite';
lastClubId = clubId;
lastAccept = accept;
lastCallerUid = callerUid;
}
@override
Future<void> leaveClub(String clubId, {String? callerUid}) async {
if (shouldThrow) {
throw ClubOperationException(throwMessage ?? 'Error', code: throwCode);
}
lastAction = 'leaveClub';
lastClubId = clubId;
lastCallerUid = callerUid;
}
}
void main() {
group('ClubMembershipController Tests', () {
late FakeClubServiceForController fakeService;
late ProviderContainer container;
setUp(() {
fakeService = FakeClubServiceForController();
container = ProviderContainer(
overrides: [
clubServiceProvider.overrideWithValue(fakeService),
currentUserProvider.overrideWith(
(ref) => const AuthUser(uid: 'user_rider_1', isAnonymous: false),
),
],
);
});
tearDown(() {
container.dispose();
});
test('joinOpenClub executes and transitions state to success', () async {
final controller = container.read(clubMembershipControllerProvider('club_1').notifier);
final success = await controller.joinOpenClub('club_1');
expect(success, isTrue);
expect(fakeService.lastAction, 'joinOpen');
expect(fakeService.lastClubId, 'club_1');
expect(fakeService.lastCallerUid, 'user_rider_1');
final state = container.read(clubMembershipControllerProvider('club_1'));
expect(state.isSubmitting, isFalse);
expect(state.success, 'Successfully joined club!');
expect(state.error, isNull);
});
test('requestToJoinClub executes and transitions state to success', () async {
final controller = container.read(clubMembershipControllerProvider('club_1').notifier);
final success = await controller.requestToJoinClub('club_1');
expect(success, isTrue);
expect(fakeService.lastAction, 'requestToJoin');
expect(fakeService.lastClubId, 'club_1');
final state = container.read(clubMembershipControllerProvider('club_1'));
expect(state.isSubmitting, isFalse);
expect(state.success, 'Join request submitted to club leadership.');
expect(state.error, isNull);
});
test('withdrawJoinRequest executes and transitions state to success', () async {
final controller = container.read(clubMembershipControllerProvider('club_1').notifier);
final success = await controller.withdrawJoinRequest('club_1');
expect(success, isTrue);
expect(fakeService.lastAction, 'withdrawRequest');
final state = container.read(clubMembershipControllerProvider('club_1'));
expect(state.isSubmitting, isFalse);
expect(state.success, 'Join request withdrawn.');
});
test('acceptInvitation executes and transitions state to success', () async {
final controller = container.read(clubMembershipControllerProvider('club_1').notifier);
final success = await controller.acceptInvitation('club_1');
expect(success, isTrue);
expect(fakeService.lastAction, 'respondInvite');
expect(fakeService.lastAccept, isTrue);
final state = container.read(clubMembershipControllerProvider('club_1'));
expect(state.success, contains('Welcome to the club'));
});
test('declineInvitation executes and transitions state to success', () async {
final controller = container.read(clubMembershipControllerProvider('club_1').notifier);
final success = await controller.declineInvitation('club_1');
expect(success, isTrue);
expect(fakeService.lastAction, 'respondInvite');
expect(fakeService.lastAccept, isFalse);
final state = container.read(clubMembershipControllerProvider('club_1'));
expect(state.success, 'Invitation declined.');
});
test('leaveClub executes and transitions state to success', () async {
final controller = container.read(clubMembershipControllerProvider('club_1').notifier);
final success = await controller.leaveClub('club_1');
expect(success, isTrue);
expect(fakeService.lastAction, 'leaveClub');
final state = container.read(clubMembershipControllerProvider('club_1'));
expect(state.success, 'You have left the club.');
});
test('fails gracefully when user is unauthenticated', () async {
final unauthContainer = ProviderContainer(
overrides: [
clubServiceProvider.overrideWithValue(fakeService),
currentUserProvider.overrideWith((ref) => null),
],
);
final controller = unauthContainer.read(clubMembershipControllerProvider('club_1').notifier);
final success = await controller.joinOpenClub('club_1');
expect(success, isFalse);
final state = unauthContainer.read(clubMembershipControllerProvider('club_1'));
expect(state.error, contains('must be signed in'));
unauthContainer.dispose();
});
test('maps banned error code to friendly generic message without leaking mod info', () async {
fakeService.shouldThrow = true;
fakeService.throwCode = 'banned';
fakeService.throwMessage = 'User is in bans subcollection.';
final controller = container.read(clubMembershipControllerProvider('club_1').notifier);
final success = await controller.joinOpenClub('club_1');
expect(success, isFalse);
final state = container.read(clubMembershipControllerProvider('club_1'));
expect(state.error, 'Membership is unavailable for this club.');
});
test('maps owner-cannot-leave error code properly', () async {
fakeService.shouldThrow = true;
fakeService.throwCode = 'owner-cannot-leave';
fakeService.throwMessage = 'Owner cannot leave.';
final controller = container.read(clubMembershipControllerProvider('club_1').notifier);
final success = await controller.leaveClub('club_1');
expect(success, isFalse);
final state = container.read(clubMembershipControllerProvider('club_1'));
expect(state.error, contains('transferring ownership first'));
});
test('maps policy-mismatch, not-found, and unavailable error codes', () async {
fakeService.shouldThrow = true;
fakeService.throwCode = 'policy-mismatch';
fakeService.throwMessage = 'Open join not allowed.';
final controller = container.read(clubMembershipControllerProvider('club_1').notifier);
final success1 = await controller.joinOpenClub('club_1');
expect(success1, isFalse);
expect(container.read(clubMembershipControllerProvider('club_1')).error,
'This club does not allow open joining. Please submit a request.');
fakeService.throwCode = 'not-found';
fakeService.throwMessage = 'Not found';
final success2 = await controller.joinOpenClub('club_1');
expect(success2, isFalse);
expect(container.read(clubMembershipControllerProvider('club_1')).error,
'Club or membership record was not found.');
fakeService.throwCode = 'unavailable';
fakeService.throwMessage = 'Firestore down';
final success3 = await controller.joinOpenClub('club_1');
expect(success3, isFalse);
expect(container.read(clubMembershipControllerProvider('club_1')).error,
'Club services are temporarily unavailable. Please try again later.');
});
test('rejects concurrent duplicate mutation while in-flight', () async {
final completer = Completer<void>();
fakeService.onJoinAsync = () => completer.future;
final controller = container.read(clubMembershipControllerProvider('club_1').notifier);
final firstFuture = controller.joinOpenClub('club_1');
expect(container.read(clubMembershipControllerProvider('club_1')).isSubmitting, isTrue);
// Attempt second rapid mutation while first is still pending
final secondSuccess = await controller.joinOpenClub('club_1');
expect(secondSuccess, isFalse);
completer.complete();
final firstSuccess = await firstFuture;
expect(firstSuccess, isTrue);
expect(container.read(clubMembershipControllerProvider('club_1')).isSubmitting, isFalse);
});
test('isolates controller state per club family instance', () async {
final controllerA = container.read(clubMembershipControllerProvider('club_A').notifier);
final controllerB = container.read(clubMembershipControllerProvider('club_B').notifier);
await controllerA.joinOpenClub('club_A');
await controllerB.requestToJoinClub('club_B');
expect(container.read(clubMembershipControllerProvider('club_A')).success, 'Successfully joined club!');
expect(container.read(clubMembershipControllerProvider('club_B')).success, 'Join request submitted to club leadership.');
});
test('clearMessages resets state', () async {
final controller = container.read(clubMembershipControllerProvider('club_1').notifier);
await controller.joinOpenClub('club_1');
expect(container.read(clubMembershipControllerProvider('club_1')).success, isNotNull);
controller.clearMessages();
final cleared = container.read(clubMembershipControllerProvider('club_1'));
expect(cleared.success, isNull);
expect(cleared.error, isNull);
});
});
}

View File

@ -0,0 +1,265 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/features/social/clubs/club_membership_presentation.dart';
import 'package:motorcycle_dash/services/clubs/club_models.dart';
void main() {
group('ClubMembershipPresentation Unit Tests', () {
final baseClub = Club(
id: 'test_club_1',
name: 'NorCal Adventurers',
slug: 'norcal-adv',
description: 'Adventure riding club',
ownerUid: 'owner_uid',
memberCount: 10,
visibility: ClubVisibility.public,
admissionPolicy: ClubAdmissionPolicy.open,
createdAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
test('resolves unavailable when club is null', () {
final p = ClubMembershipPresentation.resolve(
club: null,
member: null,
currentUserId: 'rider_1',
isAuthenticated: true,
);
expect(p.state, ClubMembershipPresentationState.unavailable);
expect(p.statusBadge, 'UNAVAILABLE');
expect(p.isActionable, isFalse);
expect(p.primaryAction, isNull);
});
test('resolves archived state when club.isArchived is true', () {
final archivedClub = baseClub.copyWith(archivedAt: DateTime(2026, 2, 1));
final p = ClubMembershipPresentation.resolve(
club: archivedClub,
member: null,
currentUserId: 'rider_1',
isAuthenticated: true,
);
expect(p.state, ClubMembershipPresentationState.archived);
expect(p.statusBadge, 'ARCHIVED');
expect(p.badgeType, PresentationBadgeType.amber);
expect(p.canLeave, isFalse);
expect(p.isActionable, isFalse);
expect(p.primaryAction, isNull);
});
test('resolves unauthenticated state when signed out or anonymous', () {
final p1 = ClubMembershipPresentation.resolve(
club: baseClub,
member: null,
currentUserId: null,
isAuthenticated: false,
);
expect(p1.state, ClubMembershipPresentationState.unauthenticated);
expect(p1.statusBadge, 'GUEST');
expect(p1.primaryAction, ClubMembershipAction.signIn);
expect(p1.isActionable, isTrue);
final p2 = ClubMembershipPresentation.resolve(
club: baseClub,
member: null,
currentUserId: '',
isAuthenticated: true,
);
expect(p2.state, ClubMembershipPresentationState.unauthenticated);
});
test('resolves outsiderOpen for open admission when unjoined', () {
final p = ClubMembershipPresentation.resolve(
club: baseClub.copyWith(admissionPolicy: ClubAdmissionPolicy.open),
member: null,
currentUserId: 'rider_1',
isAuthenticated: true,
);
expect(p.state, ClubMembershipPresentationState.outsiderOpen);
expect(p.statusBadge, 'OPEN ADMISSION');
expect(p.badgeType, PresentationBadgeType.cyan);
expect(p.primaryAction, ClubMembershipAction.joinOpen);
expect(p.primaryActionLabel, 'Join Club');
expect(p.canLeave, isFalse);
});
test('resolves outsiderRequest for request admission when unjoined', () {
final p = ClubMembershipPresentation.resolve(
club: baseClub.copyWith(admissionPolicy: ClubAdmissionPolicy.request),
member: null,
currentUserId: 'rider_1',
isAuthenticated: true,
);
expect(p.state, ClubMembershipPresentationState.outsiderRequest);
expect(p.statusBadge, 'REQUEST REQUIRED');
expect(p.primaryAction, ClubMembershipAction.requestToJoin);
expect(p.primaryActionLabel, 'Request to Join');
});
test('resolves outsiderInviteOnly for invite-only admission when unjoined', () {
final p = ClubMembershipPresentation.resolve(
club: baseClub.copyWith(admissionPolicy: ClubAdmissionPolicy.inviteOnly),
member: null,
currentUserId: 'rider_1',
isAuthenticated: true,
);
expect(p.state, ClubMembershipPresentationState.outsiderInviteOnly);
expect(p.statusBadge, 'INVITE ONLY');
expect(p.primaryAction, isNull);
expect(p.isActionable, isFalse);
});
test('resolves historical states (left, declined, removed) as re-joinable outsiders', () {
for (final status in [ClubMemberStatus.left, ClubMemberStatus.declined, ClubMemberStatus.removed]) {
final member = ClubMember(
uid: 'rider_1',
clubId: baseClub.id,
status: status,
role: ClubRole.member,
updatedAt: DateTime(2026, 1, 1),
);
final p = ClubMembershipPresentation.resolve(
club: baseClub.copyWith(admissionPolicy: ClubAdmissionPolicy.open),
member: member,
currentUserId: 'rider_1',
isAuthenticated: true,
);
expect(p.state, ClubMembershipPresentationState.outsiderOpen);
expect(p.primaryAction, ClubMembershipAction.joinOpen);
}
});
test('resolves invited state with accept and decline actions', () {
final invitedMember = ClubMember(
uid: 'rider_1',
clubId: baseClub.id,
status: ClubMemberStatus.invited,
role: ClubRole.member,
invitedByUid: 'owner_uid',
updatedAt: DateTime(2026, 1, 1),
);
final p = ClubMembershipPresentation.resolve(
club: baseClub,
member: invitedMember,
currentUserId: 'rider_1',
isAuthenticated: true,
);
expect(p.state, ClubMembershipPresentationState.invited);
expect(p.statusBadge, 'INVITED');
expect(p.primaryAction, ClubMembershipAction.acceptInvite);
expect(p.primaryActionLabel, 'Accept Invitation');
expect(p.secondaryAction, ClubMembershipAction.declineInvite);
expect(p.secondaryActionLabel, 'Decline');
expect(p.isSecondaryDestructive, isTrue);
expect(p.canLeave, isFalse);
});
test('resolves requested state with withdraw request action', () {
final requestedMember = ClubMember(
uid: 'rider_1',
clubId: baseClub.id,
status: ClubMemberStatus.requested,
role: ClubRole.member,
requestedAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
final p = ClubMembershipPresentation.resolve(
club: baseClub,
member: requestedMember,
currentUserId: 'rider_1',
isAuthenticated: true,
);
expect(p.state, ClubMembershipPresentationState.requested);
expect(p.statusBadge, 'REQUEST PENDING');
expect(p.badgeType, PresentationBadgeType.amber);
expect(p.primaryAction, ClubMembershipAction.withdrawRequest);
expect(p.primaryActionLabel, 'Withdraw Request');
expect(p.isPrimaryDestructive, isTrue);
expect(p.canLeave, isFalse);
});
test('resolves activeMember state with leave club action', () {
final activeMember = ClubMember(
uid: 'rider_1',
clubId: baseClub.id,
status: ClubMemberStatus.active,
role: ClubRole.member,
joinedAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
final p = ClubMembershipPresentation.resolve(
club: baseClub,
member: activeMember,
currentUserId: 'rider_1',
isAuthenticated: true,
);
expect(p.state, ClubMembershipPresentationState.activeMember);
expect(p.statusBadge, 'MEMBER');
expect(p.primaryAction, ClubMembershipAction.leaveClub);
expect(p.primaryActionLabel, 'Leave Club');
expect(p.isPrimaryDestructive, isTrue);
expect(p.canLeave, isTrue);
});
test('resolves activeAdmin state with leave club action', () {
final activeAdmin = ClubMember(
uid: 'rider_admin',
clubId: baseClub.id,
status: ClubMemberStatus.active,
role: ClubRole.admin,
joinedAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
final p = ClubMembershipPresentation.resolve(
club: baseClub,
member: activeAdmin,
currentUserId: 'rider_admin',
isAuthenticated: true,
);
expect(p.state, ClubMembershipPresentationState.activeAdmin);
expect(p.statusBadge, 'ADMIN');
expect(p.primaryAction, ClubMembershipAction.leaveClub);
expect(p.canLeave, isTrue);
});
test('resolves activeOwner state where owner cannot leave', () {
final activeOwner = ClubMember(
uid: 'owner_uid',
clubId: baseClub.id,
status: ClubMemberStatus.active,
role: ClubRole.owner,
joinedAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
final p = ClubMembershipPresentation.resolve(
club: baseClub,
member: activeOwner,
currentUserId: 'owner_uid',
isAuthenticated: true,
);
expect(p.state, ClubMembershipPresentationState.activeOwner);
expect(p.statusBadge, 'OWNER');
expect(p.badgeType, PresentationBadgeType.green);
expect(p.primaryAction, isNull);
expect(p.canLeave, isFalse);
expect(p.isActionable, isFalse);
expect(p.statusDescription, contains('Ownership must be transferred'));
});
});
}

View File

@ -170,5 +170,60 @@ void main() {
expect(find.text('No club found with handle @unknown-club'), findsOneWidget);
});
testWidgets('renders incoming invitations section in My Clubs tab and handles accept', (tester) async {
const user = AuthUser(uid: 'alice', isAnonymous: false);
final inviteClub = Club(
id: 'club_invite_1',
name: 'Bay Area Motos',
slug: 'bay-area-motos',
description: 'San Francisco & Bay Area motorcycle rides.',
ownerUid: 'bob',
memberCount: 15,
createdAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
final inviteMember = ClubMember(
uid: 'alice',
clubId: 'club_invite_1',
status: ClubMemberStatus.invited,
role: ClubRole.member,
invitedByUid: 'bob',
updatedAt: DateTime(2026, 1, 1),
);
final fakeService = FakeClubServiceForHub(
clubs: {'club_invite_1': inviteClub},
handles: {'bay-area-motos': 'club_invite_1'},
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => user),
clubServiceProvider.overrideWithValue(fakeService),
clubDetailProvider('club_invite_1').overrideWith((ref) => Stream.value(inviteClub)),
myClubsProvider.overrideWith((ref) => Stream.value(const [])),
myClubInvitesProvider.overrideWith((ref) => Stream.value([inviteMember])),
publicClubsProvider.overrideWith((ref) => Future.value([inviteClub])),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubsHubScreen(),
),
),
);
await tester.pumpAndSettle();
expect(find.text('CLUB INVITATIONS'), findsOneWidget);
expect(find.text('Bay Area Motos'), findsOneWidget);
expect(find.text('INVITED'), findsOneWidget);
expect(find.text('Accept'), findsOneWidget);
expect(find.text('Decline'), findsOneWidget);
});
});
}