MotorSignal-App/lib/features/social/clubs/club_detail_screen.dart

1262 lines
45 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.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 '../../../services/clubs/club_service.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),
// Club Rides Card
_buildClubRidesCard(
context,
ref,
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,
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,
),
),
],
),
);
}
Widget _buildClubRidesCard(
BuildContext context,
WidgetRef ref,
Club club,
ClubMember? member,
MotorSignalThemeColors colors,
ThemeData theme,
) {
final isActiveMember = member != null && member.status.isActive;
final canPlanRides =
isActiveMember && member.role.isAtLeast(ClubRole.admin);
return Container(
padding: const EdgeInsets.all(18.0),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colors.outlineVariant, width: 0.8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'CLUB RIDES',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 14,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
color: colors.cyberCyan,
),
),
if (canPlanRides)
ElevatedButton.icon(
onPressed: () => context.push('/group-rides/create?clubId=${club.id}'),
icon: const Icon(Icons.add_road, size: 14),
label: const Text('Plan Club Ride'),
style: ElevatedButton.styleFrom(
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
textStyle: const TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 11.5,
fontWeight: FontWeight.w700,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
),
const SizedBox(height: 12),
if (!isActiveMember)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: Text(
'Club rides are visible to active members only.',
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 12.5,
color: theme.colorScheme.onSurfaceVariant,
),
),
)
else
_buildClubDiscoveryList(context, ref, club.id, colors, theme),
],
),
);
}
Widget _buildClubDiscoveryList(
BuildContext context,
WidgetRef ref,
String clubId,
MotorSignalThemeColors colors,
ThemeData theme,
) {
final ridesAsync = ref.watch(clubDiscoveryRidesProvider(clubId));
return ridesAsync.when(
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 24.0),
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
error: (err, _) {
String message = 'Unable to load club rides at this time.';
if (err is ClubOperationException) {
switch (err.code) {
case 'unauthenticated':
message = 'Please sign in to view club rides.';
break;
case 'permission_denied':
message = 'Active club membership is required to view club rides.';
break;
case 'timeout':
case 'network_error':
message = 'Network connection issue. Please check your connection and try again.';
break;
default:
message = 'Unable to load club rides at this time.';
}
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
message,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 12.5,
color: theme.colorScheme.error,
),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () => ref.invalidate(clubDiscoveryRidesProvider(clubId)),
icon: const Icon(Icons.refresh, size: 14),
label: const Text('Retry'),
style: OutlinedButton.styleFrom(
foregroundColor: colors.cyberCyan,
side: BorderSide(color: colors.cyberCyan.withValues(alpha: 0.5)),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
textStyle: const TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
},
data: (rides) {
if (rides.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: Row(
children: [
Icon(Icons.two_wheeler, size: 18, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(
child: Text(
'No upcoming club rides scheduled.',
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 12.5,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
);
}
final dateFormat = DateFormat('EEE, MMM d • h:mm a');
return Column(
children: rides.map((ride) {
final startLabel = dateFormat.format(ride.scheduledStartAt.toLocal());
final locationLabel = ride.meetingLocationLabel ?? 'Meeting area shared with participants';
return Padding(
padding: const EdgeInsets.only(top: 10.0),
child: Material(
color: colors.surfaceContainerHighest.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: () => context.push('/group-rides/${ride.rideId}'),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: colors.outlineVariant.withValues(alpha: 0.4),
width: 0.8,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
ride.title,
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 13.5,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: colors.cyberCyan.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: colors.cyberCyan.withValues(alpha: 0.3),
width: 0.5,
),
),
child: Text(
ride.joinPolicy == 'open'
? 'OPEN'
: (ride.joinPolicy == 'requestToJoin' ? 'REQUEST TO JOIN' : 'INVITE ONLY'),
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 9,
fontWeight: FontWeight.w700,
color: colors.cyberCyan,
),
),
),
],
),
const SizedBox(height: 6),
Row(
children: [
Icon(Icons.calendar_today, size: 12, color: colors.cyberCyan),
const SizedBox(width: 5),
Text(
startLabel,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 11.5,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 4),
Row(
children: [
Icon(Icons.location_on_outlined, size: 12, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 5),
Expanded(
child: Text(
locationLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 11.5,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
_buildPill(
icon: Icons.group,
label: '${ride.participantCount}${ride.participantLimit != null ? ' / ${ride.participantLimit}' : ''} riders',
colors: colors,
theme: theme,
),
if (ride.paceClassification != null && ride.paceClassification!.isNotEmpty)
_buildPill(
icon: Icons.speed,
label: ride.paceClassification!,
colors: colors,
theme: theme,
),
if (ride.organizerDisplayName != null || ride.organizerHandle != null)
_buildPill(
icon: Icons.person_pin,
label: ride.organizerDisplayName ?? ride.organizerHandle!,
colors: colors,
theme: theme,
),
],
),
],
),
),
),
),
);
}).toList(),
);
},
);
}
}