feat: add secure club membership lifecycle
This commit is contained in:
parent
a805497f6a
commit
2e76561f67
File diff suppressed because it is too large
Load Diff
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue