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
|
// Transitions
|
||||||
allow update: if isRegisteredUser() && (
|
allow update: if isRegisteredUser() && (
|
||||||
// Invitee accepts invite (invited -> active)
|
// Invitee accepts invite (invited -> active)
|
||||||
|
|
@ -876,6 +910,10 @@ service cloud.firestore {
|
||||||
) ||
|
) ||
|
||||||
// Requestor cancels request (requested -> left)
|
// Requestor cancels request (requested -> left)
|
||||||
(request.auth.uid == memberUid && resource.data.status == 'requested' && request.resource.data.status == '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)
|
// Admin/Owner approves request (requested -> active)
|
||||||
(
|
(
|
||||||
isClubAdmin(request.auth.uid)
|
isClubAdmin(request.auth.uid)
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import '../features/social/rider_profile_screen.dart';
|
||||||
import '../features/social/friends_screen.dart';
|
import '../features/social/friends_screen.dart';
|
||||||
import '../features/social/clubs/clubs_hub_screen.dart';
|
import '../features/social/clubs/clubs_hub_screen.dart';
|
||||||
import '../features/social/clubs/create_club_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/group_rides_hub_screen.dart';
|
||||||
import '../features/group_rides/create_group_ride_screen.dart';
|
import '../features/group_rides/create_group_ride_screen.dart';
|
||||||
import '../features/group_rides/group_ride_detail_screen.dart';
|
import '../features/group_rides/group_ride_detail_screen.dart';
|
||||||
|
|
@ -175,7 +175,7 @@ final appRouter = GoRouter(
|
||||||
path: ':id',
|
path: ':id',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final id = state.pathParameters['id']!;
|
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 'club_detail_screen.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';
|
|
||||||
|
|
||||||
/// Read-only preview destination for a MotorSignal Club.
|
export 'club_detail_screen.dart';
|
||||||
/// Displays authoritative identity and metadata without CLUB-2C membership mutations.
|
|
||||||
class ClubPreviewScreen extends ConsumerWidget {
|
|
||||||
final String clubId;
|
|
||||||
|
|
||||||
const ClubPreviewScreen({
|
/// Backwards compatibility alias for ClubDetailScreen.
|
||||||
super.key,
|
typedef ClubPreviewScreen = ClubDetailScreen;
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import '../../../services/clubs/club_models.dart';
|
||||||
import '../../../services/clubs/club_providers.dart';
|
import '../../../services/clubs/club_providers.dart';
|
||||||
import '../../../services/clubs/club_slug_validator.dart';
|
import '../../../services/clubs/club_slug_validator.dart';
|
||||||
import 'widgets/club_card.dart';
|
import 'widgets/club_card.dart';
|
||||||
|
import 'widgets/club_invitation_card.dart';
|
||||||
import 'widgets/club_member_card.dart';
|
import 'widgets/club_member_card.dart';
|
||||||
|
|
||||||
/// Authoritative Hub for MotorSignal Clubs.
|
/// Authoritative Hub for MotorSignal Clubs.
|
||||||
|
|
@ -248,36 +249,20 @@ class _ClubsHubScreenState extends ConsumerState<ClubsHubScreen>
|
||||||
|
|
||||||
Widget _buildMyClubsTab(BuildContext context, MotorSignalThemeColors colors) {
|
Widget _buildMyClubsTab(BuildContext context, MotorSignalThemeColors colors) {
|
||||||
final myClubsAsync = ref.watch(myClubsProvider);
|
final myClubsAsync = ref.watch(myClubsProvider);
|
||||||
|
final myInvitesAsync = ref.watch(myClubInvitesProvider);
|
||||||
|
|
||||||
return myClubsAsync.when(
|
// If both are loading, show loading spinner
|
||||||
data: (memberships) {
|
if (myClubsAsync.isLoading && myInvitesAsync.isLoading) {
|
||||||
if (memberships.isEmpty) {
|
return Center(
|
||||||
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(
|
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(colors.cyberCyan),
|
valueColor: AlwaysStoppedAnimation<Color>(colors.cyberCyan),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
error: (err, _) => Center(
|
}
|
||||||
|
|
||||||
|
// If both failed, show error
|
||||||
|
if (myClubsAsync.hasError && myInvitesAsync.hasError) {
|
||||||
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24.0),
|
padding: const EdgeInsets.all(24.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -294,12 +279,97 @@ class _ClubsHubScreenState extends ConsumerState<ClubsHubScreen>
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onPressed: () => ref.invalidate(myClubsProvider),
|
onPressed: () {
|
||||||
|
ref.invalidate(myClubsProvider);
|
||||||
|
ref.invalidate(myClubInvitesProvider);
|
||||||
|
},
|
||||||
child: const Text('Retry'),
|
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);
|
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