feat: add clubs discovery and creation experience
This commit is contained in:
parent
0d8e26750f
commit
a805497f6a
|
|
@ -19,6 +19,9 @@ import '../features/activity/activity_screen.dart';
|
|||
import '../features/social/social_hub_screen.dart';
|
||||
import '../features/social/rider_profile_screen.dart';
|
||||
import '../features/social/friends_screen.dart';
|
||||
import '../features/social/clubs/clubs_hub_screen.dart';
|
||||
import '../features/social/clubs/create_club_screen.dart';
|
||||
import '../features/social/clubs/club_preview_screen.dart';
|
||||
import '../features/group_rides/group_rides_hub_screen.dart';
|
||||
import '../features/group_rides/create_group_ride_screen.dart';
|
||||
import '../features/group_rides/group_ride_detail_screen.dart';
|
||||
|
|
@ -160,6 +163,23 @@ final appRouter = GoRouter(
|
|||
path: 'friends',
|
||||
builder: (context, state) => const FriendsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'clubs',
|
||||
builder: (context, state) => const ClubsHubScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'create',
|
||||
builder: (context, state) => const CreateClubScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':id',
|
||||
builder: (context, state) {
|
||||
final id = state.pathParameters['id']!;
|
||||
return ClubPreviewScreen(clubId: id);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: 'diagnostics',
|
||||
builder: (context, state) => const DiagnosticsScreen(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,433 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/responsive/responsive_layout.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../core/theme/theme_extensions.dart';
|
||||
import '../../../services/clubs/club_models.dart';
|
||||
import '../../../services/clubs/club_providers.dart';
|
||||
import 'widgets/club_monogram.dart';
|
||||
|
||||
/// Read-only preview destination for a MotorSignal Club.
|
||||
/// Displays authoritative identity and metadata without CLUB-2C membership mutations.
|
||||
class ClubPreviewScreen extends ConsumerWidget {
|
||||
final String clubId;
|
||||
|
||||
const ClubPreviewScreen({
|
||||
super.key,
|
||||
required this.clubId,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colors = context.motorColors;
|
||||
final theme = Theme.of(context);
|
||||
final clubAsync = ref.watch(clubDetailProvider(clubId));
|
||||
final memberAsync = ref.watch(currentMemberProvider(clubId));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'Club',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: clubAsync.when(
|
||||
data: (club) {
|
||||
if (club == null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.shield_outlined, size: 48, color: colors.warningAmber),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Club Not Found',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'This club may have been archived or does not exist.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 13,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (club.isArchived) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.archive_outlined, size: 48, color: colors.warningAmber),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Club Archived',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'This club has been archived by leadership and is no longer active.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 13,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final myMembership = memberAsync.value;
|
||||
|
||||
return ResponsiveLayout(
|
||||
builder: (context, responsive) {
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 750),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(18.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Club Hero Identity Card
|
||||
_buildHeroCard(context, club, myMembership, colors, theme),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// Description Card
|
||||
_buildDescriptionCard(context, club, colors, theme),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// Metadata Card (Region, Focus, Dates)
|
||||
_buildMetadataCard(context, club, colors, theme),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(colors.cyberCyan),
|
||||
),
|
||||
),
|
||||
error: (err, _) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.shield_outlined, size: 36, color: colors.warningAmber),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Club Unavailable',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Unable to display this club at this time.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 13,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeroCard(
|
||||
BuildContext context,
|
||||
Club club,
|
||||
ClubMember? member,
|
||||
MotorSignalThemeColors colors,
|
||||
ThemeData theme,
|
||||
) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: colors.outlineVariant, width: 0.8),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
ClubMonogram(name: club.name, size: 68, fontSize: 26),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
club.name,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'@${club.slug}',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
_buildPill(
|
||||
icon: Icons.people_outline,
|
||||
label: '${club.memberCount} ${club.memberCount == 1 ? 'member' : 'members'}',
|
||||
colors: colors,
|
||||
theme: theme,
|
||||
),
|
||||
_buildPill(
|
||||
icon: club.admissionPolicy == ClubAdmissionPolicy.open
|
||||
? Icons.lock_open_outlined
|
||||
: Icons.how_to_reg_outlined,
|
||||
label: club.admissionPolicy.label,
|
||||
colors: colors,
|
||||
theme: theme,
|
||||
tintColor: colors.cyberCyan,
|
||||
),
|
||||
_buildPill(
|
||||
icon: club.visibility == ClubVisibility.public
|
||||
? Icons.public
|
||||
: Icons.visibility_off_outlined,
|
||||
label: club.visibility.label,
|
||||
colors: colors,
|
||||
theme: theme,
|
||||
),
|
||||
if (member != null &&
|
||||
member.isActive &&
|
||||
(member.role == ClubRole.owner || member.role == ClubRole.admin))
|
||||
_buildPill(
|
||||
icon: Icons.verified_user_outlined,
|
||||
label: 'ROLE: ${member.role.label.toUpperCase()}',
|
||||
colors: colors,
|
||||
theme: theme,
|
||||
tintColor: colors.launchGreen,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDescriptionCard(
|
||||
BuildContext context,
|
||||
Club club,
|
||||
MotorSignalThemeColors colors,
|
||||
ThemeData theme,
|
||||
) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(18.0),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: colors.outlineVariant, width: 0.8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'About the Club',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
club.description.isNotEmpty
|
||||
? club.description
|
||||
: 'No club description provided by leadership.',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 13.5,
|
||||
color: club.description.isNotEmpty
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetadataCard(
|
||||
BuildContext context,
|
||||
Club club,
|
||||
MotorSignalThemeColors colors,
|
||||
ThemeData theme,
|
||||
) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(18.0),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: colors.outlineVariant, width: 0.8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Club Details',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildDetailRow(
|
||||
icon: Icons.location_on_outlined,
|
||||
title: 'Home Region',
|
||||
value: club.homeRegion?.trim().isNotEmpty == true ? club.homeRegion! : 'Global / Not Specified',
|
||||
colors: colors,
|
||||
theme: theme,
|
||||
),
|
||||
const Divider(height: 18),
|
||||
_buildDetailRow(
|
||||
icon: Icons.two_wheeler_outlined,
|
||||
title: 'Motorcycle Focus',
|
||||
value: club.motorcycleFocus?.trim().isNotEmpty == true
|
||||
? club.motorcycleFocus!
|
||||
: 'All Motorcycles & Disciplines',
|
||||
colors: colors,
|
||||
theme: theme,
|
||||
),
|
||||
const Divider(height: 18),
|
||||
_buildDetailRow(
|
||||
icon: Icons.calendar_today_outlined,
|
||||
title: 'Established',
|
||||
value: '${club.createdAt.year}-${club.createdAt.month.toString().padLeft(2, '0')}-${club.createdAt.day.toString().padLeft(2, '0')}',
|
||||
colors: colors,
|
||||
theme: theme,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String value,
|
||||
required MotorSignalThemeColors colors,
|
||||
required ThemeData theme,
|
||||
}) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: colors.cyberCyan),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 11.5,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPill({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required MotorSignalThemeColors colors,
|
||||
required ThemeData theme,
|
||||
Color? tintColor,
|
||||
}) {
|
||||
final effectiveColor = tintColor ?? theme.colorScheme.onSurfaceVariant;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.6), width: 0.6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 13, color: effectiveColor),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: effectiveColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,613 @@
|
|||
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 '../../../services/clubs/club_slug_validator.dart';
|
||||
import 'widgets/club_card.dart';
|
||||
import 'widgets/club_member_card.dart';
|
||||
|
||||
/// Authoritative Hub for MotorSignal Clubs.
|
||||
/// Houses My Clubs (active memberships) and Discover (public clubs & exact slug search).
|
||||
class ClubsHubScreen extends ConsumerStatefulWidget {
|
||||
const ClubsHubScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ClubsHubScreen> createState() => _ClubsHubScreenState();
|
||||
}
|
||||
|
||||
class _ClubsHubScreenState extends ConsumerState<ClubsHubScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
bool _isSearching = false;
|
||||
bool _searchExecuted = false;
|
||||
String _lastSearchedSlug = '';
|
||||
Club? _searchedClub;
|
||||
String? _searchError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _performSlugSearch(String rawQuery) async {
|
||||
final clean = rawQuery.trim().replaceFirst(RegExp(r'^@'), '');
|
||||
if (clean.isEmpty) {
|
||||
_clearSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
final normalized = ClubSlugValidator.normalize(clean);
|
||||
setState(() {
|
||||
_isSearching = true;
|
||||
_searchExecuted = true;
|
||||
_lastSearchedSlug = normalized;
|
||||
_searchError = null;
|
||||
_searchedClub = null;
|
||||
});
|
||||
|
||||
final validation = ClubSlugValidator.validate(normalized);
|
||||
if (!validation.isValid) {
|
||||
setState(() {
|
||||
_isSearching = false;
|
||||
_searchError = validation.error;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final service = ref.read(clubServiceProvider);
|
||||
final club = await service.getClubBySlug(normalized);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isSearching = false;
|
||||
_searchedClub = club;
|
||||
if (club == null) {
|
||||
_searchError = 'No club found with handle @$normalized';
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isSearching = false;
|
||||
_searchError = 'Search failed. Please try again.';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _clearSearch() {
|
||||
setState(() {
|
||||
_searchController.clear();
|
||||
_isSearching = false;
|
||||
_searchExecuted = false;
|
||||
_lastSearchedSlug = '';
|
||||
_searchedClub = null;
|
||||
_searchError = null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.motorColors;
|
||||
final user = ref.watch(currentUserProvider);
|
||||
final isAuthenticated = user != null && !user.isAnonymous;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'Clubs',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
if (isAuthenticated)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
tooltip: 'Create Club',
|
||||
onPressed: () => context.push('/social/clubs/create'),
|
||||
),
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
indicatorColor: colors.cyberCyan,
|
||||
labelColor: colors.cyberCyan,
|
||||
unselectedLabelColor: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
labelStyle: const TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
tabs: const [
|
||||
Tab(text: 'MY CLUBS'),
|
||||
Tab(text: 'DISCOVER'),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: isAuthenticated
|
||||
? FloatingActionButton.extended(
|
||||
onPressed: () => context.push('/social/clubs/create'),
|
||||
backgroundColor: colors.cyberCyan,
|
||||
foregroundColor: Colors.black,
|
||||
icon: const Icon(Icons.add, size: 20),
|
||||
label: const Text(
|
||||
'Create Club',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
body: SafeArea(
|
||||
child: ResponsiveLayout(
|
||||
builder: (context, responsive) {
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 800),
|
||||
child: !isAuthenticated
|
||||
? _buildUnauthenticatedView(context, colors)
|
||||
: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildMyClubsTab(context, colors),
|
||||
_buildDiscoverTab(context, colors),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUnauthenticatedView(BuildContext context, MotorSignalThemeColors colors) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: colors.outlineVariant, width: 0.8),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.cyberCyan.withValues(alpha: 0.12),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.shield_outlined, size: 28, color: colors.cyberCyan),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
const Text(
|
||||
'Account Required for Clubs',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Sign in or create a MotorSignal account to join and manage riding clubs.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 13,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: () => context.push('/settings/account'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colors.cyberCyan,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
icon: const Icon(Icons.login, size: 18),
|
||||
label: const Text(
|
||||
'Sign In / Register',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMyClubsTab(BuildContext context, MotorSignalThemeColors colors) {
|
||||
final myClubsAsync = ref.watch(myClubsProvider);
|
||||
|
||||
return myClubsAsync.when(
|
||||
data: (memberships) {
|
||||
if (memberships.isEmpty) {
|
||||
return _buildEmptyMyClubs(context, colors);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
ref.invalidate(myClubsProvider);
|
||||
},
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
itemCount: memberships.length,
|
||||
itemBuilder: (context, index) {
|
||||
final member = memberships[index];
|
||||
return ClubMemberCard(
|
||||
key: ValueKey('my_club_${member.clubId}'),
|
||||
member: member,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => Center(
|
||||
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.error_outline, size: 36, color: colors.warningAmber),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Failed to load club memberships',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
onPressed: () => ref.invalidate(myClubsProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyMyClubs(BuildContext context, MotorSignalThemeColors colors) {
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.cyberCyan.withValues(alpha: 0.10),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.groups_outlined, size: 28, color: colors.cyberCyan),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'No Club Memberships Yet',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'You are not currently an active member of any clubs.\nDiscover riding communities or start your own motorcycle club.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 13,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _tabController.animateTo(1),
|
||||
icon: const Icon(Icons.explore_outlined, size: 18),
|
||||
label: const Text('Discover Clubs'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: () => context.push('/social/clubs/create'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colors.cyberCyan,
|
||||
foregroundColor: Colors.black,
|
||||
),
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('Create Club'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDiscoverTab(BuildContext context, MotorSignalThemeColors colors) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
ref.invalidate(publicClubsProvider);
|
||||
},
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
children: [
|
||||
// Exact Handle Search Bar
|
||||
_buildSearchBar(colors),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Search Results View
|
||||
if (_searchExecuted) ...[
|
||||
_buildSearchResults(colors),
|
||||
const SizedBox(height: 20),
|
||||
const Divider(),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
// Public Discovery Section Header
|
||||
Text(
|
||||
'Public Clubs Directory',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Public Clubs List
|
||||
_buildPublicClubsList(colors),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchBar(MotorSignalThemeColors colors) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
textInputAction: TextInputAction.search,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search club by exact handle...',
|
||||
prefixIcon: Icon(Icons.search, color: colors.cyberCyan, size: 20),
|
||||
prefixText: '@',
|
||||
prefixStyle: TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear, size: 18),
|
||||
onPressed: _clearSearch,
|
||||
)
|
||||
: null,
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainer,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colors.outlineVariant, width: 0.8),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colors.outlineVariant, width: 0.8),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colors.cyberCyan, width: 1.5),
|
||||
),
|
||||
),
|
||||
onSubmitted: _performSlugSearch,
|
||||
onChanged: (val) {
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filledTonal(
|
||||
icon: const Icon(Icons.arrow_forward, size: 20),
|
||||
tooltip: 'Find Club',
|
||||
onPressed: () => _performSlugSearch(_searchController.text),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchResults(MotorSignalThemeColors colors) {
|
||||
if (_isSearching) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(colors.cyberCyan),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_searchedClub != null) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'EXACT MATCH: @${_searchedClub!.slug}',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colors.launchGreen,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _clearSearch,
|
||||
child: const Text('Clear', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
ClubCard(
|
||||
club: _searchedClub!,
|
||||
onTap: () => context.push('/social/clubs/${_searchedClub!.id}'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14.0),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: colors.outlineVariant, width: 0.8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 20, color: colors.warningAmber),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_searchError ?? 'No club found with handle @$_lastSearchedSlug',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 12.5,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _clearSearch,
|
||||
child: const Text('Clear', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPublicClubsList(MotorSignalThemeColors colors) {
|
||||
final publicClubsAsync = ref.watch(publicClubsProvider);
|
||||
|
||||
return publicClubsAsync.when(
|
||||
data: (clubs) {
|
||||
if (clubs.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32.0),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No public clubs discovered yet.\nBe the first to create one!',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 13,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: clubs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final club = clubs[index];
|
||||
return ClubCard(
|
||||
key: ValueKey('discover_club_${club.id}'),
|
||||
club: club,
|
||||
onTap: () => context.push('/social/clubs/${club.id}'),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(colors.cyberCyan),
|
||||
),
|
||||
),
|
||||
),
|
||||
error: (err, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 28, color: colors.warningAmber),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Could not load public clubs directory'),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton(
|
||||
onPressed: () => ref.invalidate(publicClubsProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,597 @@
|
|||
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 '../../../services/clubs/club_service.dart';
|
||||
import 'widgets/slug_input_field.dart';
|
||||
|
||||
/// Screen allowing authenticated riders to create a new Club.
|
||||
/// Features live slug availability verification and atomic creation.
|
||||
class CreateClubScreen extends ConsumerStatefulWidget {
|
||||
const CreateClubScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CreateClubScreen> createState() => _CreateClubScreenState();
|
||||
}
|
||||
|
||||
class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
final _slugController = TextEditingController();
|
||||
final _descController = TextEditingController();
|
||||
final _regionController = TextEditingController();
|
||||
final _focusController = TextEditingController();
|
||||
|
||||
ClubVisibility _visibility = ClubVisibility.public;
|
||||
ClubAdmissionPolicy _admissionPolicy = ClubAdmissionPolicy.request;
|
||||
|
||||
bool _isSlugValidAndAvailable = false;
|
||||
bool _isSubmitting = false;
|
||||
String? _submissionError;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_slugController.dispose();
|
||||
_descController.dispose();
|
||||
_regionController.dispose();
|
||||
_focusController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submitForm() async {
|
||||
if (_isSubmitting) return;
|
||||
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
if (!_isSlugValidAndAvailable) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Please choose a valid and available club handle.'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
_submissionError = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final user = ref.read(currentUserProvider);
|
||||
if (user == null || user.isAnonymous) {
|
||||
throw const ClubOperationException(
|
||||
'You must be signed in with a registered account to create a club.',
|
||||
code: 'unauthenticated',
|
||||
);
|
||||
}
|
||||
|
||||
final service = ref.read(clubServiceProvider);
|
||||
final createdClub = await service.createClub(
|
||||
name: _nameController.text.trim(),
|
||||
slug: _slugController.text.trim(),
|
||||
description: _descController.text.trim(),
|
||||
visibility: _visibility,
|
||||
admissionPolicy: _admissionPolicy,
|
||||
homeRegion: _regionController.text.trim().isEmpty ? null : _regionController.text.trim(),
|
||||
motorcycleFocus: _focusController.text.trim().isEmpty ? null : _focusController.text.trim(),
|
||||
callerUid: user.uid,
|
||||
);
|
||||
|
||||
// Invalidate relevant providers to reflect new club
|
||||
ref.invalidate(myClubsProvider);
|
||||
ref.invalidate(publicClubsProvider);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Club "${createdClub.name}" created successfully!'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
|
||||
context.pushReplacement('/social/clubs/${createdClub.id}');
|
||||
} on ClubOperationException catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
if (e.code == 'slug-taken') {
|
||||
_submissionError = 'This handle was just taken by another club. Please pick a different handle.';
|
||||
} else {
|
||||
_submissionError = e.message;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
_submissionError = 'An unexpected error occurred while creating the club: $e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.motorColors;
|
||||
final theme = Theme.of(context);
|
||||
final user = ref.watch(currentUserProvider);
|
||||
final isAuthenticated = user != null && !user.isAnonymous;
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Create Club')),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.shield_outlined, size: 48, color: colors.cyberCyan),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Account Required',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Sign in or create a MotorSignal account to join and manage riding clubs.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 13,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: () => context.push('/settings/account'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colors.cyberCyan,
|
||||
foregroundColor: Colors.black,
|
||||
),
|
||||
icon: const Icon(Icons.login, size: 18),
|
||||
label: const Text('Sign In / Register'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'Create Club',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: ResponsiveLayout(
|
||||
builder: (context, responsive) {
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 700),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(18.0),
|
||||
children: [
|
||||
// Header card explaining Club identity
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14.0),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: colors.outlineVariant, width: 0.8),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.shield_outlined, color: colors.cyberCyan, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Found a Motorcycle Club',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'As the founder, you will be designated the Club Owner with administrative authority over admissions, group rides, and club settings.',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Submission error alert if present
|
||||
if (_submissionError != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.raceRed.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: colors.raceRed.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colors.raceRed, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_submissionError!,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 12.5,
|
||||
color: colors.raceRed,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Club Name
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
enabled: !_isSubmitting,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Club Name *',
|
||||
hintText: 'e.g. Ridge Runners MC',
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainer,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colors.outlineVariant, width: 0.8),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colors.outlineVariant, width: 0.8),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colors.cyberCyan, width: 1.5),
|
||||
),
|
||||
),
|
||||
validator: (val) {
|
||||
final clean = val?.trim() ?? '';
|
||||
if (clean.length < 3) return 'Name must be at least 3 characters';
|
||||
if (clean.length > 50) return 'Name cannot exceed 50 characters';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// Slug / Handle with live debounce & availability checking
|
||||
SlugInputField(
|
||||
controller: _slugController,
|
||||
enabled: !_isSubmitting,
|
||||
onValidationChanged: (isValid) {
|
||||
setState(() {
|
||||
_isSlugValidAndAvailable = isValid;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// Description
|
||||
TextFormField(
|
||||
controller: _descController,
|
||||
enabled: !_isSubmitting,
|
||||
maxLines: 3,
|
||||
maxLength: 500,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Description (Optional)',
|
||||
hintText: 'Share your club mission, riding style, or club guidelines...',
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainer,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colors.outlineVariant, width: 0.8),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colors.outlineVariant, width: 0.8),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colors.cyberCyan, width: 1.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Home Region & Focus Row
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _regionController,
|
||||
enabled: !_isSubmitting,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Home Region',
|
||||
hintText: 'e.g. NorCal',
|
||||
prefixIcon: const Icon(Icons.location_on_outlined, size: 18),
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainer,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colors.outlineVariant, width: 0.8),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colors.outlineVariant, width: 0.8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _focusController,
|
||||
enabled: !_isSubmitting,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Motorcycle Focus',
|
||||
hintText: 'e.g. Touring, Adventure, Commuting',
|
||||
prefixIcon: const Icon(Icons.two_wheeler_outlined, size: 18),
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainer,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colors.outlineVariant, width: 0.8),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colors.outlineVariant, width: 0.8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Visibility Selection
|
||||
Text(
|
||||
'Club Visibility',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildVisibilitySelector(colors, theme),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Admission Policy Selection
|
||||
Text(
|
||||
'Admission Policy',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildAdmissionSelector(colors, theme),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Submit Button
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: FilledButton(
|
||||
key: const ValueKey('create_club_submit_button'),
|
||||
onPressed: (_isSubmitting || !_isSlugValidAndAvailable)
|
||||
? null
|
||||
: _submitForm,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colors.cyberCyan,
|
||||
foregroundColor: Colors.black,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: _isSubmitting
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.black),
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'Create Club',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVisibilitySelector(MotorSignalThemeColors colors, ThemeData theme) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildChoiceTile(
|
||||
title: 'Public',
|
||||
subtitle: 'Discoverable in search and public directory. Any rider can find the club.',
|
||||
selected: _visibility == ClubVisibility.public,
|
||||
colors: colors,
|
||||
theme: theme,
|
||||
onTap: () => setState(() => _visibility = ClubVisibility.public),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildChoiceTile(
|
||||
title: 'Unlisted',
|
||||
subtitle: 'Hidden from public discovery. Accessible via direct handle or invitation.',
|
||||
selected: _visibility == ClubVisibility.unlisted,
|
||||
colors: colors,
|
||||
theme: theme,
|
||||
onTap: () => setState(() => _visibility = ClubVisibility.unlisted),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildChoiceTile(
|
||||
title: 'Private',
|
||||
subtitle: 'Completely hidden. Visible only to active members and leadership.',
|
||||
selected: _visibility == ClubVisibility.private,
|
||||
colors: colors,
|
||||
theme: theme,
|
||||
onTap: () => setState(() => _visibility = ClubVisibility.private),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAdmissionSelector(MotorSignalThemeColors colors, ThemeData theme) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildChoiceTile(
|
||||
title: 'Request to Join (Recommended)',
|
||||
subtitle: 'Riders submit a join request for leadership review before becoming active members.',
|
||||
selected: _admissionPolicy == ClubAdmissionPolicy.request,
|
||||
colors: colors,
|
||||
theme: theme,
|
||||
onTap: () => setState(() => _admissionPolicy = ClubAdmissionPolicy.request),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildChoiceTile(
|
||||
title: 'Open to Join',
|
||||
subtitle: 'Any rider can instantly join and become an active member without prior approval.',
|
||||
selected: _admissionPolicy == ClubAdmissionPolicy.open,
|
||||
colors: colors,
|
||||
theme: theme,
|
||||
onTap: () => setState(() => _admissionPolicy = ClubAdmissionPolicy.open),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildChoiceTile(
|
||||
title: 'Invite Only',
|
||||
subtitle: 'Riders cannot submit join requests. Only club leadership can invite members.',
|
||||
selected: _admissionPolicy == ClubAdmissionPolicy.inviteOnly,
|
||||
colors: colors,
|
||||
theme: theme,
|
||||
onTap: () => setState(() => _admissionPolicy = ClubAdmissionPolicy.inviteOnly),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChoiceTile({
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required bool selected,
|
||||
required MotorSignalThemeColors colors,
|
||||
required ThemeData theme,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? colors.cyberCyan.withValues(alpha: 0.08)
|
||||
: colors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: selected ? colors.cyberCyan : colors.outlineVariant,
|
||||
width: selected ? 1.5 : 0.8,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: _isSubmitting ? null : onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
selected ? Icons.radio_button_checked : Icons.radio_button_off,
|
||||
color: selected ? colors.cyberCyan : colors.outline,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: selected ? colors.cyberCyan : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 11.5,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.25,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/theme/app_typography.dart';
|
||||
import '../../../../core/theme/theme_extensions.dart';
|
||||
import '../../../../services/clubs/club_models.dart';
|
||||
import 'club_monogram.dart';
|
||||
|
||||
/// Automotive styled card representing a MotorSignal Club in list views,
|
||||
/// discovery grids, and membership directories.
|
||||
class ClubCard extends StatelessWidget {
|
||||
final Club club;
|
||||
final ClubRole? role;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const ClubCard({
|
||||
super.key,
|
||||
required this.club,
|
||||
this.role,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.motorColors;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6.0),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: colors.outlineVariant, width: 0.8),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header row: Monogram + Name/Slug + Optional Role Badge
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
ClubMonogram(name: club.name, size: 44),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
club.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (role != null && (role == ClubRole.owner || role == ClubRole.admin)) ...[
|
||||
const SizedBox(width: 8),
|
||||
_buildRolePill(context, role!, colors),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'@${club.slug}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Optional description preview if present
|
||||
if (club.description.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
club.description,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 12.5,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Badges row: Member count, admission policy, region, focus
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
// Member count badge
|
||||
_buildPill(
|
||||
icon: Icons.people_outline,
|
||||
label: '${club.memberCount} ${club.memberCount == 1 ? 'member' : 'members'}',
|
||||
colors: colors,
|
||||
theme: theme,
|
||||
),
|
||||
|
||||
// Admission policy badge
|
||||
_buildAdmissionBadge(context, club.admissionPolicy, colors),
|
||||
|
||||
// Region badge if available
|
||||
if (club.homeRegion != null && club.homeRegion!.trim().isNotEmpty)
|
||||
_buildPill(
|
||||
icon: Icons.location_on_outlined,
|
||||
label: club.homeRegion!.trim(),
|
||||
colors: colors,
|
||||
theme: theme,
|
||||
),
|
||||
|
||||
// Motorcycle focus badge if available
|
||||
if (club.motorcycleFocus != null && club.motorcycleFocus!.trim().isNotEmpty)
|
||||
_buildPill(
|
||||
icon: Icons.two_wheeler_outlined,
|
||||
label: club.motorcycleFocus!.trim(),
|
||||
colors: colors,
|
||||
theme: theme,
|
||||
),
|
||||
|
||||
// Non-public visibility badge
|
||||
if (club.visibility != ClubVisibility.public)
|
||||
_buildPill(
|
||||
icon: club.visibility == ClubVisibility.private
|
||||
? Icons.lock_outline
|
||||
: Icons.visibility_off_outlined,
|
||||
label: club.visibility.label,
|
||||
colors: colors,
|
||||
theme: theme,
|
||||
tintColor: colors.warningAmber,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRolePill(BuildContext context, ClubRole role, MotorSignalThemeColors colors) {
|
||||
Color badgeColor;
|
||||
Color textColor = Colors.black;
|
||||
|
||||
switch (role) {
|
||||
case ClubRole.owner:
|
||||
badgeColor = colors.warningAmber;
|
||||
break;
|
||||
case ClubRole.admin:
|
||||
badgeColor = colors.cyberCyan;
|
||||
break;
|
||||
case ClubRole.member:
|
||||
badgeColor = colors.outlineVariant;
|
||||
textColor = Theme.of(context).colorScheme.onSurface;
|
||||
break;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeColor,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
role.label.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 9.5,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.4,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAdmissionBadge(
|
||||
BuildContext context,
|
||||
ClubAdmissionPolicy policy,
|
||||
MotorSignalThemeColors colors,
|
||||
) {
|
||||
Color color;
|
||||
IconData icon;
|
||||
|
||||
switch (policy) {
|
||||
case ClubAdmissionPolicy.open:
|
||||
color = colors.launchGreen;
|
||||
icon = Icons.lock_open_outlined;
|
||||
break;
|
||||
case ClubAdmissionPolicy.request:
|
||||
color = colors.cyberCyan;
|
||||
icon = Icons.how_to_reg_outlined;
|
||||
break;
|
||||
case ClubAdmissionPolicy.inviteOnly:
|
||||
color = colors.outline;
|
||||
icon = Icons.mail_outline;
|
||||
break;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: color.withValues(alpha: 0.35), width: 0.8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 12, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
policy.label,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.5), width: 0.6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 12, color: effectiveColor),
|
||||
const SizedBox(width: 4),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 160),
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: effectiveColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/theme/theme_extensions.dart';
|
||||
import '../../../../services/clubs/club_models.dart';
|
||||
import '../../../../services/clubs/club_providers.dart';
|
||||
import 'club_card.dart';
|
||||
|
||||
/// Renders a ClubCard for a rider's active membership by streaming the underlying
|
||||
/// Club document in real time.
|
||||
class ClubMemberCard extends ConsumerWidget {
|
||||
final ClubMember member;
|
||||
|
||||
const ClubMemberCard({
|
||||
super.key,
|
||||
required this.member,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final clubAsync = ref.watch(clubDetailProvider(member.clubId));
|
||||
|
||||
return clubAsync.when(
|
||||
data: (club) {
|
||||
if (club == null || club.isArchived) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return ClubCard(
|
||||
club: club,
|
||||
role: member.role,
|
||||
onTap: () => context.push('/social/clubs/${club.id}'),
|
||||
);
|
||||
},
|
||||
loading: () => _buildPlaceholder(context),
|
||||
error: (err, stack) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholder(BuildContext context) {
|
||||
final colors = context.motorColors;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6.0),
|
||||
height: 96,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.4), width: 0.8),
|
||||
),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(colors.cyberCyan),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/theme/app_typography.dart';
|
||||
import '../../../../core/theme/theme_extensions.dart';
|
||||
|
||||
/// Reusable widget rendering a deterministic 1–2 character monogram badge
|
||||
/// derived from a Club's name. Follows automotive cockpit design guidelines.
|
||||
class ClubMonogram extends StatelessWidget {
|
||||
final String name;
|
||||
final double size;
|
||||
final double? fontSize;
|
||||
final Color? backgroundColor;
|
||||
final Color? textColor;
|
||||
|
||||
const ClubMonogram({
|
||||
super.key,
|
||||
required this.name,
|
||||
this.size = 44.0,
|
||||
this.fontSize,
|
||||
this.backgroundColor,
|
||||
this.textColor,
|
||||
});
|
||||
|
||||
/// Computes a deterministic 1–2 character monogram from a raw club name.
|
||||
/// Unicode-safe using Characters to handle grapheme clusters, emojis,
|
||||
/// leading punctuation, non-Latin scripts, and single characters.
|
||||
static String getMonogram(String rawName) {
|
||||
final trimmed = rawName.trim();
|
||||
if (trimmed.isEmpty) return 'C';
|
||||
|
||||
final words = trimmed
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((w) => w.isNotEmpty)
|
||||
.toList();
|
||||
if (words.isEmpty) return 'C';
|
||||
|
||||
String cleanWord(String word) {
|
||||
final chars = word.characters.toList();
|
||||
int start = 0;
|
||||
while (start < chars.length) {
|
||||
final ch = chars[start];
|
||||
if (_isLeadingPunctuation(ch)) {
|
||||
start++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (start >= chars.length) {
|
||||
return word;
|
||||
}
|
||||
return chars.sublist(start).join();
|
||||
}
|
||||
|
||||
if (words.length == 1) {
|
||||
final cleaned = cleanWord(words.first);
|
||||
final chars = cleaned.characters.take(2).toList();
|
||||
if (chars.isEmpty) return 'C';
|
||||
return chars.join().toUpperCase();
|
||||
} else {
|
||||
final cleanedWords = words.map(cleanWord).toList();
|
||||
final firstGrapheme = cleanedWords[0].characters.isNotEmpty
|
||||
? cleanedWords[0].characters.first
|
||||
: '';
|
||||
final secondGrapheme = cleanedWords[1].characters.isNotEmpty
|
||||
? cleanedWords[1].characters.first
|
||||
: '';
|
||||
final combined = '$firstGrapheme$secondGrapheme'.trim();
|
||||
if (combined.isEmpty) return 'C';
|
||||
return combined.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
static bool _isLeadingPunctuation(String char) {
|
||||
const punct = {
|
||||
'-', '_', '.', ',', '!', '?', '@', '#', r'$', '%', '^', '&', '*',
|
||||
'(', ')', '[', ']', '{', '}', '<', '>', '/', r'\', '|', '~', '`',
|
||||
'+', '=', ':', ';', '"', '\'', '«', '»', '“', '”', '‘', '’', '•',
|
||||
};
|
||||
return punct.contains(char);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.motorColors;
|
||||
final monogram = getMonogram(name);
|
||||
final effectiveFontSize = fontSize ?? (size * 0.40);
|
||||
|
||||
return Semantics(
|
||||
label: 'Club monogram for $name',
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor ?? colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(size * 0.28),
|
||||
border: Border.all(
|
||||
color: colors.outlineVariant.withValues(alpha: 0.6),
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
monogram,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: effectiveFontSize,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.5,
|
||||
color: textColor ?? colors.cyberCyan,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,333 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../../core/theme/app_typography.dart';
|
||||
import '../../../../core/theme/theme_extensions.dart';
|
||||
import '../../../../services/clubs/club_providers.dart';
|
||||
import '../../../../services/clubs/club_slug_validator.dart';
|
||||
|
||||
/// Validation and availability state for a candidate Club slug.
|
||||
enum SlugStatus {
|
||||
initial,
|
||||
checking,
|
||||
available,
|
||||
unavailable,
|
||||
invalid,
|
||||
}
|
||||
|
||||
/// A specialized text input field for creating permanent Club handles/slugs.
|
||||
/// Performs debounced real-time validation and Firestore availability checking.
|
||||
class SlugInputField extends ConsumerStatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final ValueChanged<bool>? onValidationChanged;
|
||||
final bool enabled;
|
||||
|
||||
const SlugInputField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.onChanged,
|
||||
this.onValidationChanged,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<SlugInputField> createState() => _SlugInputFieldState();
|
||||
}
|
||||
|
||||
class _SlugInputFieldState extends ConsumerState<SlugInputField> {
|
||||
Timer? _debounceTimer;
|
||||
SlugStatus _status = SlugStatus.initial;
|
||||
String? _errorMessage;
|
||||
int _lastCheckId = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_onTextChanged);
|
||||
if (widget.controller.text.isNotEmpty) {
|
||||
_checkAvailability(widget.controller.text);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
widget.controller.removeListener(_onTextChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onTextChanged() {
|
||||
final text = widget.controller.text;
|
||||
widget.onChanged?.call(text);
|
||||
|
||||
_debounceTimer?.cancel();
|
||||
final normalized = ClubSlugValidator.normalize(text);
|
||||
|
||||
if (normalized.isEmpty) {
|
||||
setState(() {
|
||||
_status = SlugStatus.initial;
|
||||
_errorMessage = null;
|
||||
});
|
||||
widget.onValidationChanged?.call(false);
|
||||
return;
|
||||
}
|
||||
|
||||
final validation = ClubSlugValidator.validate(normalized);
|
||||
if (!validation.isValid) {
|
||||
setState(() {
|
||||
_status = SlugStatus.invalid;
|
||||
_errorMessage = validation.error;
|
||||
});
|
||||
widget.onValidationChanged?.call(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_status = SlugStatus.checking;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
final currentCheckId = ++_lastCheckId;
|
||||
_debounceTimer = Timer(const Duration(milliseconds: 400), () async {
|
||||
await _checkAvailability(normalized, checkId: currentCheckId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _checkAvailability(String slug, {int? checkId}) async {
|
||||
final validation = ClubSlugValidator.validate(slug);
|
||||
if (!validation.isValid) {
|
||||
if (mounted && (checkId == null || checkId == _lastCheckId)) {
|
||||
setState(() {
|
||||
_status = SlugStatus.invalid;
|
||||
_errorMessage = validation.error;
|
||||
});
|
||||
widget.onValidationChanged?.call(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final service = ref.read(clubServiceProvider);
|
||||
final isAvailable = await service.isSlugAvailable(validation.normalizedSlug!);
|
||||
|
||||
if (!mounted) return;
|
||||
if (checkId != null && checkId != _lastCheckId) return;
|
||||
|
||||
setState(() {
|
||||
if (isAvailable) {
|
||||
_status = SlugStatus.available;
|
||||
_errorMessage = null;
|
||||
} else {
|
||||
_status = SlugStatus.unavailable;
|
||||
_errorMessage = 'Handle is already reserved by another club';
|
||||
}
|
||||
});
|
||||
widget.onValidationChanged?.call(isAvailable);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
if (checkId != null && checkId != _lastCheckId) return;
|
||||
setState(() {
|
||||
_status = SlugStatus.invalid;
|
||||
_errorMessage = 'Could not verify handle availability. Please try again.';
|
||||
});
|
||||
widget.onValidationChanged?.call(false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.motorColors;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: widget.controller,
|
||||
enabled: widget.enabled,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
keyboardType: TextInputType.text,
|
||||
textCapitalization: TextCapitalization.none,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z0-9-]')),
|
||||
_LowerCaseTextFormatter(),
|
||||
],
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Club Handle / Slug *',
|
||||
hintText: 'e.g. bay-area-riders',
|
||||
prefixText: '@',
|
||||
prefixStyle: TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainer,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colors.outlineVariant, width: 0.8),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colors.outlineVariant, width: 0.8),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colors.cyberCyan, width: 1.5),
|
||||
),
|
||||
suffixIcon: _buildSuffixIcon(colors),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
|
||||
// Status & feedback row
|
||||
_buildFeedbackWidget(colors, theme),
|
||||
|
||||
const SizedBox(height: 6),
|
||||
// Permanence disclaimer notice
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 14,
|
||||
color: colors.warningAmber.withValues(alpha: 0.9),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Club handles are permanent and cannot be changed after creation. Choose carefully.',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 11.5,
|
||||
color: colors.warningAmber.withValues(alpha: 0.9),
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _buildSuffixIcon(MotorSignalThemeColors colors) {
|
||||
switch (_status) {
|
||||
case SlugStatus.initial:
|
||||
return null;
|
||||
case SlugStatus.checking:
|
||||
return Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(colors.cyberCyan),
|
||||
),
|
||||
);
|
||||
case SlugStatus.available:
|
||||
return Icon(
|
||||
Icons.check_circle,
|
||||
color: colors.launchGreen,
|
||||
size: 20,
|
||||
);
|
||||
case SlugStatus.unavailable:
|
||||
return Icon(
|
||||
Icons.cancel,
|
||||
color: colors.raceRed,
|
||||
size: 20,
|
||||
);
|
||||
case SlugStatus.invalid:
|
||||
return Icon(
|
||||
Icons.error_outline,
|
||||
color: colors.warningAmber,
|
||||
size: 20,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildFeedbackWidget(MotorSignalThemeColors colors, ThemeData theme) {
|
||||
if (_status == SlugStatus.initial) {
|
||||
return Text(
|
||||
'3–30 characters: lowercase letters, numbers, and single hyphens.',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 11.5,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_status == SlugStatus.checking) {
|
||||
return Text(
|
||||
'Checking handle availability...',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 11.5,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_status == SlugStatus.available) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(Icons.check, size: 14, color: colors.launchGreen),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Handle is available',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.launchGreen,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Invalid or unavailable
|
||||
final color = _status == SlugStatus.unavailable ? colors.raceRed : colors.warningAmber;
|
||||
return Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 14, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage ?? 'Invalid slug',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom TextInputFormatter ensuring lowercase text.
|
||||
class _LowerCaseTextFormatter extends TextInputFormatter {
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
return TextEditingValue(
|
||||
text: newValue.text.toLowerCase(),
|
||||
selection: newValue.selection,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -45,8 +45,9 @@ class SocialHubScreen extends ConsumerWidget {
|
|||
children: [
|
||||
// Rider Identity Header Card
|
||||
_buildRiderHeroCard(context, profileAsync.value, user, colors),
|
||||
const SizedBox(height: 18),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Section 1: Rider Network
|
||||
Text(
|
||||
'Rider Network',
|
||||
style: TextStyle(
|
||||
|
|
@ -66,50 +67,30 @@ class SocialHubScreen extends ConsumerWidget {
|
|||
Expanded(
|
||||
child: _buildNavigationCard(
|
||||
context,
|
||||
key: const ValueKey('social_card_profile'),
|
||||
icon: Icons.badge_outlined,
|
||||
title: 'Rider Profile',
|
||||
subtitle: 'Manage public handle, bio, region & visibility preferences',
|
||||
key: const ValueKey('social_card_friends'),
|
||||
icon: Icons.people_outline,
|
||||
title: 'Friends',
|
||||
subtitle: 'Find fellow riders, manage requests & connections',
|
||||
badgeCount: pendingCount,
|
||||
colors: colors,
|
||||
onTap: () => context.push('/social/profile'),
|
||||
onTap: () => context.push('/social/friends'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: _buildNavigationCard(
|
||||
context,
|
||||
key: const ValueKey('social_card_friends'),
|
||||
icon: Icons.people_outline,
|
||||
title: 'Friends',
|
||||
subtitle: 'Find fellow riders, manage requests & connections',
|
||||
badgeCount: pendingCount,
|
||||
key: const ValueKey('social_card_clubs'),
|
||||
icon: Icons.shield_outlined,
|
||||
title: 'Clubs',
|
||||
subtitle: 'Discover motorcycle clubs, riding communities & member groups',
|
||||
colors: colors,
|
||||
onTap: () => context.push('/social/friends'),
|
||||
onTap: () => context.push('/social/clubs'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_buildNavigationCard(
|
||||
context,
|
||||
key: const ValueKey('social_card_group_rides'),
|
||||
icon: Icons.groups_outlined,
|
||||
title: 'Group Rides',
|
||||
subtitle: 'Join active sessions, explore local pack rides & track group telemetry',
|
||||
colors: colors,
|
||||
onTap: () => context.push('/group-rides'),
|
||||
),
|
||||
] else ...[
|
||||
_buildNavigationCard(
|
||||
context,
|
||||
key: const ValueKey('social_card_profile'),
|
||||
icon: Icons.badge_outlined,
|
||||
title: 'Rider Profile',
|
||||
subtitle: 'Manage public handle, bio, region & visibility preferences',
|
||||
colors: colors,
|
||||
onTap: () => context.push('/social/profile'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildNavigationCard(
|
||||
context,
|
||||
key: const ValueKey('social_card_friends'),
|
||||
|
|
@ -121,6 +102,31 @@ class SocialHubScreen extends ConsumerWidget {
|
|||
onTap: () => context.push('/social/friends'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildNavigationCard(
|
||||
context,
|
||||
key: const ValueKey('social_card_clubs'),
|
||||
icon: Icons.shield_outlined,
|
||||
title: 'Clubs',
|
||||
subtitle: 'Discover motorcycle clubs, riding communities & member groups',
|
||||
colors: colors,
|
||||
onTap: () => context.push('/social/clubs'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Section 2: Motorcycle Activities
|
||||
Text(
|
||||
'Motorcycle Activities',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
_buildNavigationCard(
|
||||
context,
|
||||
key: const ValueKey('social_card_group_rides'),
|
||||
|
|
@ -130,7 +136,6 @@ class SocialHubScreen extends ConsumerWidget {
|
|||
colors: colors,
|
||||
onTap: () => context.push('/group-rides'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -57,3 +57,20 @@ final pendingClubRequestsProvider = StreamProvider.family<List<ClubMember>, Stri
|
|||
final service = ref.watch(clubServiceProvider);
|
||||
return service.pendingRequestsStream(clubId);
|
||||
});
|
||||
|
||||
/// FutureProvider resolving a Club by its unique slug handle.
|
||||
/// Gated to registered, non-anonymous riders.
|
||||
final clubBySlugProvider = FutureProvider.family<Club?, String>((ref, slug) async {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
if (user == null || user.isAnonymous) {
|
||||
return null;
|
||||
}
|
||||
final service = ref.watch(clubServiceProvider);
|
||||
return service.getClubBySlug(slug);
|
||||
});
|
||||
|
||||
/// FutureProvider for public club discovery (initial limit 25).
|
||||
final publicClubsProvider = FutureProvider<List<Club>>((ref) {
|
||||
final service = ref.watch(clubServiceProvider);
|
||||
return service.discoverPublicClubs(limit: 25);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -886,9 +886,14 @@ class ClubService {
|
|||
Future<Club?> getClub(String clubId) async {
|
||||
final firestore = _firestore;
|
||||
if (firestore == null) return null;
|
||||
try {
|
||||
final doc = await firestore.collection('clubs').doc(clubId).get();
|
||||
if (!doc.exists || doc.data() == null) return null;
|
||||
return Club.fromFirestore(doc);
|
||||
} catch (_) {
|
||||
// Permission denied or inaccessible private club
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Realtime stream of a Club document by ID.
|
||||
|
|
@ -898,7 +903,59 @@ class ClubService {
|
|||
return firestore.collection('clubs').doc(clubId).snapshots().map((doc) {
|
||||
if (!doc.exists || doc.data() == null) return null;
|
||||
return Club.fromFirestore(doc);
|
||||
});
|
||||
}).handleError((_) => null);
|
||||
}
|
||||
|
||||
/// Checks whether a slug is valid and available (not already reserved in club_handles/{slug}).
|
||||
/// Gated to registered, non-anonymous users.
|
||||
Future<bool> isSlugAvailable(String slug) async {
|
||||
final validation = ClubSlugValidator.validate(slug);
|
||||
if (!validation.isValid) return false;
|
||||
final user = _auth?.currentUser;
|
||||
if (user == null || user.isAnonymous) return false;
|
||||
final firestore = _firestore;
|
||||
if (firestore == null) return false;
|
||||
try {
|
||||
final doc = await firestore
|
||||
.collection('club_handles')
|
||||
.doc(validation.normalizedSlug!)
|
||||
.get();
|
||||
return !doc.exists;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact lookup of a Club by its reserved handle slug.
|
||||
/// Resolves club_handles/{slug} -> clubs/{clubId}.
|
||||
/// Gated to registered, non-anonymous users. Translates permission denials into safe null results.
|
||||
Future<Club?> getClubBySlug(String slug) async {
|
||||
final validation = ClubSlugValidator.validate(slug);
|
||||
if (!validation.isValid) return null;
|
||||
final user = _auth?.currentUser;
|
||||
if (user == null || user.isAnonymous) {
|
||||
throw const ClubOperationException(
|
||||
'A registered, non-anonymous account is required to perform club lookups.',
|
||||
code: 'unauthenticated',
|
||||
);
|
||||
}
|
||||
final firestore = _firestore;
|
||||
if (firestore == null) return null;
|
||||
try {
|
||||
final handleDoc = await firestore
|
||||
.collection('club_handles')
|
||||
.doc(validation.normalizedSlug!)
|
||||
.get();
|
||||
if (!handleDoc.exists || handleDoc.data() == null) return null;
|
||||
final clubId = handleDoc.data()?['clubId'] as String?;
|
||||
if (clubId == null || clubId.isEmpty) return null;
|
||||
return await getClub(clubId);
|
||||
} on ClubOperationException {
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
// Permission denied or inaccessible private club -> safe null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Point read of a ClubMember document.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:motorcycle_dash/core/theme/theme_extensions.dart';
|
||||
import 'package:motorcycle_dash/features/social/clubs/widgets/club_card.dart';
|
||||
import 'package:motorcycle_dash/services/clubs/club_models.dart';
|
||||
|
||||
void main() {
|
||||
group('ClubCard Widget Tests', () {
|
||||
final sampleClub = Club(
|
||||
id: 'club_test_1',
|
||||
name: 'Pacific Coast Riders',
|
||||
slug: 'pacific-coast-riders',
|
||||
description: 'Cruising the Pacific Coast Highway together.',
|
||||
ownerUid: 'user_1',
|
||||
memberCount: 42,
|
||||
homeRegion: 'NorCal',
|
||||
motorcycleFocus: 'Sport Touring',
|
||||
visibility: ClubVisibility.public,
|
||||
admissionPolicy: ClubAdmissionPolicy.request,
|
||||
createdAt: DateTime(2026, 1, 1),
|
||||
updatedAt: DateTime(2026, 1, 1),
|
||||
);
|
||||
|
||||
testWidgets('renders club details, chips, and triggers onTap', (tester) async {
|
||||
bool tapped = false;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: ThemeData.dark().copyWith(
|
||||
extensions: [MotorSignalThemeColors.dark],
|
||||
),
|
||||
home: Scaffold(
|
||||
body: ClubCard(
|
||||
club: sampleClub,
|
||||
role: ClubRole.owner,
|
||||
onTap: () => tapped = true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Pacific Coast Riders'), findsOneWidget);
|
||||
expect(find.text('@pacific-coast-riders'), findsOneWidget);
|
||||
expect(find.text('Cruising the Pacific Coast Highway together.'), findsOneWidget);
|
||||
expect(find.text('42 members'), findsOneWidget);
|
||||
expect(find.text('Request to Join'), findsOneWidget);
|
||||
expect(find.text('NorCal'), findsOneWidget);
|
||||
expect(find.text('Sport Touring'), findsOneWidget);
|
||||
expect(find.text('OWNER'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byType(ClubCard));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tapped, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('renders open admission badge correctly', (tester) async {
|
||||
final openClub = sampleClub.copyWith(admissionPolicy: ClubAdmissionPolicy.open);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: ThemeData.dark().copyWith(
|
||||
extensions: [MotorSignalThemeColors.dark],
|
||||
),
|
||||
home: Scaffold(
|
||||
body: ClubCard(club: openClub),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Open to Join'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('suppresses role pill for standard members and shows for admin/owner', (tester) async {
|
||||
// Test member role (should NOT be rendered)
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: ThemeData.dark().copyWith(
|
||||
extensions: [MotorSignalThemeColors.dark],
|
||||
),
|
||||
home: Scaffold(
|
||||
body: ClubCard(
|
||||
club: sampleClub,
|
||||
role: ClubRole.member,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('MEMBER'), findsNothing);
|
||||
|
||||
// Test admin role (should be rendered)
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: ThemeData.dark().copyWith(
|
||||
extensions: [MotorSignalThemeColors.dark],
|
||||
),
|
||||
home: Scaffold(
|
||||
body: ClubCard(
|
||||
club: sampleClub,
|
||||
role: ClubRole.admin,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('ADMIN'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:motorcycle_dash/core/theme/theme_extensions.dart';
|
||||
import 'package:motorcycle_dash/features/social/clubs/widgets/club_monogram.dart';
|
||||
|
||||
void main() {
|
||||
group('ClubMonogram Algorithm Tests', () {
|
||||
test('handles empty or whitespace strings', () {
|
||||
expect(ClubMonogram.getMonogram(''), equals('C'));
|
||||
expect(ClubMonogram.getMonogram(' '), equals('C'));
|
||||
});
|
||||
|
||||
test('handles single word club names', () {
|
||||
expect(ClubMonogram.getMonogram('Ducati'), equals('DU'));
|
||||
expect(ClubMonogram.getMonogram('moto'), equals('MO'));
|
||||
expect(ClubMonogram.getMonogram('X'), equals('X'));
|
||||
expect(ClubMonogram.getMonogram('Yamaha'), equals('YA'));
|
||||
});
|
||||
|
||||
test('handles multi-word club names', () {
|
||||
expect(ClubMonogram.getMonogram('Blue Ridge'), equals('BR'));
|
||||
expect(ClubMonogram.getMonogram('Bay Area Riders'), equals('BA'));
|
||||
expect(ClubMonogram.getMonogram('norcal adventure group'), equals('NA'));
|
||||
expect(ClubMonogram.getMonogram(' Pacific Coast '), equals('PC'));
|
||||
});
|
||||
|
||||
test('handles numeric and special character names', () {
|
||||
expect(ClubMonogram.getMonogram('99 Problems'), equals('9P'));
|
||||
expect(ClubMonogram.getMonogram('1000cc Club'), equals('1C'));
|
||||
});
|
||||
|
||||
test('handles emojis and pictographs safely without splitting grapheme clusters', () {
|
||||
expect(ClubMonogram.getMonogram('🏍️'), equals('🏍️'));
|
||||
expect(ClubMonogram.getMonogram('🏍️ NorCal Riders'), equals('🏍️N'));
|
||||
expect(ClubMonogram.getMonogram('🔥 Motos'), equals('🔥M'));
|
||||
});
|
||||
|
||||
test('handles non-Latin scripts and CJK characters', () {
|
||||
expect(ClubMonogram.getMonogram('東京 Motos'), equals('東M'));
|
||||
expect(ClubMonogram.getMonogram('東京'), equals('東京'));
|
||||
expect(ClubMonogram.getMonogram('Москва Riders'), equals('МR'));
|
||||
});
|
||||
|
||||
test('strips leading punctuation to find meaningful monogram letters', () {
|
||||
expect(ClubMonogram.getMonogram('---NorCal---'), equals('NO'));
|
||||
expect(ClubMonogram.getMonogram('---NorCal--- Riders'), equals('NR'));
|
||||
expect(ClubMonogram.getMonogram('@NorCal #Riders'), equals('NR'));
|
||||
expect(ClubMonogram.getMonogram('---'), equals('--'));
|
||||
});
|
||||
});
|
||||
|
||||
group('ClubMonogram Widget Tests', () {
|
||||
testWidgets('renders monogram and semantics properly', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: ThemeData.dark().copyWith(
|
||||
extensions: [MotorSignalThemeColors.dark],
|
||||
),
|
||||
home: const Scaffold(
|
||||
body: Center(
|
||||
child: ClubMonogram(
|
||||
name: 'Ridge Runners',
|
||||
size: 50,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('RR'), findsOneWidget);
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Club monogram for Ridge Runners',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
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_preview_screen.dart';
|
||||
import 'package:motorcycle_dash/services/clubs/club_models.dart';
|
||||
import 'package:motorcycle_dash/services/clubs/club_providers.dart';
|
||||
|
||||
void main() {
|
||||
group('ClubPreviewScreen Widget Tests', () {
|
||||
final activeClub = Club(
|
||||
id: 'club_active_1',
|
||||
name: 'NorCal Adventurers',
|
||||
slug: 'norcal-adv',
|
||||
description: 'Northern California backroads and trails.',
|
||||
ownerUid: 'user_owner',
|
||||
memberCount: 24,
|
||||
homeRegion: 'NorCal',
|
||||
motorcycleFocus: 'Adventure Touring',
|
||||
visibility: ClubVisibility.public,
|
||||
admissionPolicy: ClubAdmissionPolicy.request,
|
||||
createdAt: DateTime(2026, 1, 15),
|
||||
updatedAt: DateTime(2026, 1, 15),
|
||||
);
|
||||
|
||||
testWidgets('renders active club preview with clean title and no phase notice', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
clubDetailProvider('club_active_1').overrideWith(
|
||||
(ref) => Stream.value(activeClub),
|
||||
),
|
||||
currentMemberProvider('club_active_1').overrideWith(
|
||||
(ref) => Stream.value(null),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: ThemeData.dark().copyWith(
|
||||
extensions: [MotorSignalThemeColors.dark],
|
||||
),
|
||||
home: const ClubPreviewScreen(clubId: 'club_active_1'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Title must be 'Club' (not 'Club Preview')
|
||||
expect(find.text('Club'), findsOneWidget);
|
||||
expect(find.text('Club Preview'), findsNothing);
|
||||
|
||||
// Hero info
|
||||
expect(find.text('NorCal Adventurers'), findsOneWidget);
|
||||
expect(find.text('@norcal-adv'), findsOneWidget);
|
||||
expect(find.text('24 members'), findsOneWidget);
|
||||
|
||||
// Club Details (not Chapter Details)
|
||||
expect(find.text('Club Details'), findsOneWidget);
|
||||
expect(find.text('Chapter Details'), findsNothing);
|
||||
expect(find.text('About the Club'), findsOneWidget);
|
||||
|
||||
// Phase notice must NOT exist
|
||||
expect(find.textContaining('CLUB-2C'), findsNothing);
|
||||
expect(find.textContaining('Read-only preview'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('suppresses role pill for regular member', (tester) async {
|
||||
final regularMember = ClubMember(
|
||||
clubId: 'club_active_1',
|
||||
uid: 'user_member',
|
||||
role: ClubRole.member,
|
||||
status: ClubMemberStatus.active,
|
||||
joinedAt: DateTime(2026, 2, 1),
|
||||
updatedAt: DateTime(2026, 2, 1),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
clubDetailProvider('club_active_1').overrideWith(
|
||||
(ref) => Stream.value(activeClub),
|
||||
),
|
||||
currentMemberProvider('club_active_1').overrideWith(
|
||||
(ref) => Stream.value(regularMember),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: ThemeData.dark().copyWith(
|
||||
extensions: [MotorSignalThemeColors.dark],
|
||||
),
|
||||
home: const ClubPreviewScreen(clubId: 'club_active_1'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('ROLE: MEMBER'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('renders role pill for owner', (tester) async {
|
||||
final ownerMember = ClubMember(
|
||||
clubId: 'club_active_1',
|
||||
uid: 'user_owner',
|
||||
role: ClubRole.owner,
|
||||
status: ClubMemberStatus.active,
|
||||
joinedAt: DateTime(2026, 2, 1),
|
||||
updatedAt: DateTime(2026, 2, 1),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
clubDetailProvider('club_active_1').overrideWith(
|
||||
(ref) => Stream.value(activeClub),
|
||||
),
|
||||
currentMemberProvider('club_active_1').overrideWith(
|
||||
(ref) => Stream.value(ownerMember),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: ThemeData.dark().copyWith(
|
||||
extensions: [MotorSignalThemeColors.dark],
|
||||
),
|
||||
home: const ClubPreviewScreen(clubId: 'club_active_1'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('ROLE: OWNER'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders Club Not Found when club is null', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
clubDetailProvider('missing_id').overrideWith(
|
||||
(ref) => Stream.value(null),
|
||||
),
|
||||
currentMemberProvider('missing_id').overrideWith(
|
||||
(ref) => Stream.value(null),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: ThemeData.dark().copyWith(
|
||||
extensions: [MotorSignalThemeColors.dark],
|
||||
),
|
||||
home: const ClubPreviewScreen(clubId: 'missing_id'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Club Not Found'), findsOneWidget);
|
||||
expect(find.text('This club may have been archived or does not exist.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders Club Archived state when club is archived', (tester) async {
|
||||
final archivedClub = activeClub.copyWith(archivedAt: DateTime(2026, 3, 1));
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
clubDetailProvider('club_active_1').overrideWith(
|
||||
(ref) => Stream.value(archivedClub),
|
||||
),
|
||||
currentMemberProvider('club_active_1').overrideWith(
|
||||
(ref) => Stream.value(null),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: ThemeData.dark().copyWith(
|
||||
extensions: [MotorSignalThemeColors.dark],
|
||||
),
|
||||
home: const ClubPreviewScreen(clubId: 'club_active_1'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Club Archived'), findsOneWidget);
|
||||
expect(
|
||||
find.text('This club has been archived by leadership and is no longer active.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('renders Club Unavailable on stream error', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
clubDetailProvider('error_id').overrideWith(
|
||||
(ref) => Stream.error(Exception('Permission denied')),
|
||||
),
|
||||
currentMemberProvider('error_id').overrideWith(
|
||||
(ref) => Stream.value(null),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: ThemeData.dark().copyWith(
|
||||
extensions: [MotorSignalThemeColors.dark],
|
||||
),
|
||||
home: const ClubPreviewScreen(clubId: 'error_id'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Club Unavailable'), findsOneWidget);
|
||||
expect(find.text('Unable to display this club at this time.'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
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/clubs_hub_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 FakeClubServiceForHub extends ClubService {
|
||||
final Map<String, Club> clubs;
|
||||
final Map<String, String> handles;
|
||||
|
||||
FakeClubServiceForHub({
|
||||
this.clubs = const {},
|
||||
this.handles = const {},
|
||||
});
|
||||
|
||||
@override
|
||||
Future<Club?> getClubBySlug(String slug) async {
|
||||
final clubId = handles[slug];
|
||||
if (clubId == null) return null;
|
||||
return clubs[clubId];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Club>> discoverPublicClubs({int limit = 25, dynamic startAfter}) async {
|
||||
return clubs.values.where((c) => c.visibility == ClubVisibility.public).toList();
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('ClubsHubScreen Widget Tests', () {
|
||||
testWidgets('renders account-required notice when signed out', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
currentUserProvider.overrideWith((ref) => null),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: ThemeData.dark().copyWith(
|
||||
extensions: [MotorSignalThemeColors.dark],
|
||||
),
|
||||
home: const ClubsHubScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Account Required for Clubs'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Sign in or create a MotorSignal account to join and manage riding clubs.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Sign In / Register'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders tabs, empty my clubs, and public clubs when authenticated', (tester) async {
|
||||
const user = AuthUser(uid: 'alice', isAnonymous: false);
|
||||
final publicClub = Club(
|
||||
id: 'club_pub_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 fakeService = FakeClubServiceForHub(
|
||||
clubs: {'club_pub_1': publicClub},
|
||||
handles: {'bay-area-motos': 'club_pub_1'},
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
currentUserProvider.overrideWith((ref) => user),
|
||||
clubServiceProvider.overrideWithValue(fakeService),
|
||||
myClubsProvider.overrideWith((ref) => Stream.value(const [])),
|
||||
publicClubsProvider.overrideWith((ref) => Future.value([publicClub])),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: ThemeData.dark().copyWith(
|
||||
extensions: [MotorSignalThemeColors.dark],
|
||||
),
|
||||
home: const ClubsHubScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify tabs
|
||||
expect(find.text('MY CLUBS'), findsOneWidget);
|
||||
expect(find.text('DISCOVER'), findsOneWidget);
|
||||
|
||||
// Verify Empty My Clubs state
|
||||
expect(find.text('No Club Memberships Yet'), findsOneWidget);
|
||||
expect(find.text('Discover Clubs'), findsOneWidget);
|
||||
|
||||
// Tap on DISCOVER tab
|
||||
await tester.tap(find.text('DISCOVER'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify Public Discovery content
|
||||
expect(find.text('Public Clubs Directory'), findsOneWidget);
|
||||
expect(find.text('Bay Area Motos'), findsOneWidget);
|
||||
expect(find.text('@bay-area-motos'), findsOneWidget);
|
||||
|
||||
// Verify search input
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('executes exact handle search in Discover tab', (tester) async {
|
||||
const user = AuthUser(uid: 'alice', isAnonymous: false);
|
||||
final targetClub = Club(
|
||||
id: 'club_norcal',
|
||||
name: 'NorCal Riders',
|
||||
slug: 'norcal-riders',
|
||||
description: 'Northern California twisties.',
|
||||
ownerUid: 'charlie',
|
||||
memberCount: 8,
|
||||
createdAt: DateTime(2026, 1, 1),
|
||||
updatedAt: DateTime(2026, 1, 1),
|
||||
);
|
||||
|
||||
final fakeService = FakeClubServiceForHub(
|
||||
clubs: {'club_norcal': targetClub},
|
||||
handles: {'norcal-riders': 'club_norcal'},
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
currentUserProvider.overrideWith((ref) => user),
|
||||
clubServiceProvider.overrideWithValue(fakeService),
|
||||
myClubsProvider.overrideWith((ref) => Stream.value(const [])),
|
||||
publicClubsProvider.overrideWith((ref) => Future.value(const [])),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: ThemeData.dark().copyWith(
|
||||
extensions: [MotorSignalThemeColors.dark],
|
||||
),
|
||||
home: const ClubsHubScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Switch to Discover tab
|
||||
await tester.tap(find.text('DISCOVER'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Search for club by slug
|
||||
await tester.enterText(find.byType(TextField), 'norcal-riders');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('EXACT MATCH: @norcal-riders'), findsOneWidget);
|
||||
expect(find.text('NorCal Riders'), findsOneWidget);
|
||||
|
||||
// Search for non-existent slug
|
||||
await tester.enterText(find.byType(TextField), 'unknown-club');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('No club found with handle @unknown-club'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
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/create_club_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';
|
||||
import 'package:motorcycle_dash/services/clubs/club_slug_validator.dart';
|
||||
|
||||
class FakeCreateClubService extends ClubService {
|
||||
final Set<String> takenSlugs;
|
||||
Club? lastCreatedClub;
|
||||
bool shouldFailSlugTaken;
|
||||
|
||||
FakeCreateClubService({
|
||||
this.takenSlugs = const {},
|
||||
this.shouldFailSlugTaken = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<bool> isSlugAvailable(String slug) async {
|
||||
final validation = ClubSlugValidator.validate(slug);
|
||||
if (!validation.isValid) return false;
|
||||
return !takenSlugs.contains(validation.normalizedSlug!);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Club> createClub({
|
||||
required String name,
|
||||
required String slug,
|
||||
String description = '',
|
||||
ClubVisibility visibility = ClubVisibility.public,
|
||||
ClubAdmissionPolicy admissionPolicy = ClubAdmissionPolicy.request,
|
||||
String? logoUrl,
|
||||
String? bannerUrl,
|
||||
String? homeRegion,
|
||||
String? motorcycleFocus,
|
||||
String? callerUid,
|
||||
}) async {
|
||||
if (shouldFailSlugTaken || takenSlugs.contains(slug)) {
|
||||
throw const ClubOperationException(
|
||||
'This club slug is already taken.',
|
||||
code: 'slug-taken',
|
||||
);
|
||||
}
|
||||
final club = Club(
|
||||
id: 'created_club_123',
|
||||
name: name,
|
||||
slug: slug,
|
||||
description: description,
|
||||
visibility: visibility,
|
||||
admissionPolicy: admissionPolicy,
|
||||
ownerUid: callerUid ?? 'user_1',
|
||||
memberCount: 1,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
homeRegion: homeRegion,
|
||||
motorcycleFocus: motorcycleFocus,
|
||||
);
|
||||
lastCreatedClub = club;
|
||||
return club;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('CreateClubScreen Widget Tests', () {
|
||||
testWidgets('renders creation form and handles creation flow', (tester) async {
|
||||
tester.view.physicalSize = const Size(1000, 2000);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
const user = AuthUser(uid: 'alice', isAnonymous: false);
|
||||
final fakeService = FakeCreateClubService();
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
currentUserProvider.overrideWith((ref) => user),
|
||||
clubServiceProvider.overrideWithValue(fakeService),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: ThemeData.dark().copyWith(
|
||||
extensions: [MotorSignalThemeColors.dark],
|
||||
),
|
||||
home: const CreateClubScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Create Club'), findsWidgets);
|
||||
expect(find.text('Found a Motorcycle Club'), findsOneWidget);
|
||||
expect(find.text('Club Name *'), findsOneWidget);
|
||||
expect(find.text('Club Handle / Slug *'), findsOneWidget);
|
||||
|
||||
// Fill in Name
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Club Name *'),
|
||||
'Smoky Mountain MC',
|
||||
);
|
||||
|
||||
// Fill in Slug
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Club Handle / Slug *'),
|
||||
'smoky-mountain-mc',
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500)); // debounce
|
||||
|
||||
expect(find.text('Handle is available'), findsOneWidget);
|
||||
|
||||
final submitButton = find.byKey(const ValueKey('create_club_submit_button'));
|
||||
expect(submitButton, findsOneWidget);
|
||||
await tester.tap(submitButton);
|
||||
await tester.pump();
|
||||
|
||||
expect(fakeService.lastCreatedClub, isNotNull);
|
||||
expect(fakeService.lastCreatedClub!.name, equals('Smoky Mountain MC'));
|
||||
expect(fakeService.lastCreatedClub!.slug, equals('smoky-mountain-mc'));
|
||||
});
|
||||
|
||||
testWidgets('handles slug-taken conflict gracefully', (tester) async {
|
||||
tester.view.physicalSize = const Size(1000, 2000);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
const user = AuthUser(uid: 'alice', isAnonymous: false);
|
||||
final fakeService = FakeCreateClubService(shouldFailSlugTaken: true);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
currentUserProvider.overrideWith((ref) => user),
|
||||
clubServiceProvider.overrideWithValue(fakeService),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: ThemeData.dark().copyWith(
|
||||
extensions: [MotorSignalThemeColors.dark],
|
||||
),
|
||||
home: const CreateClubScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Club Name *'),
|
||||
'Contested Name',
|
||||
);
|
||||
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Club Handle / Slug *'),
|
||||
'contested-slug',
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
final submitButton = find.byKey(const ValueKey('create_club_submit_button'));
|
||||
expect(submitButton, findsOneWidget);
|
||||
await tester.tap(submitButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('This handle was just taken by another club. Please pick a different handle.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('renders across responsive viewports and keyboard constraints without overflow', (tester) async {
|
||||
const user = AuthUser(uid: 'alice', isAnonymous: false);
|
||||
final fakeService = FakeCreateClubService();
|
||||
|
||||
const viewports = [
|
||||
Size(412, 915), // Standard portrait
|
||||
Size(915, 412), // Landscape
|
||||
Size(412, 400), // Keyboard-constrained height
|
||||
];
|
||||
|
||||
for (final vp in viewports) {
|
||||
tester.view.physicalSize = vp;
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
currentUserProvider.overrideWith((ref) => user),
|
||||
clubServiceProvider.overrideWithValue(fakeService),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: ThemeData.dark().copyWith(
|
||||
extensions: [MotorSignalThemeColors.dark],
|
||||
),
|
||||
home: const CreateClubScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull, reason: 'No layout overflow on $vp');
|
||||
expect(find.text('Found a Motorcycle Club'), findsOneWidget);
|
||||
expect(find.byType(ListView), findsOneWidget);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('renders account required screen when unauthenticated', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
currentUserProvider.overrideWith((ref) => null),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: ThemeData.dark().copyWith(
|
||||
extensions: [MotorSignalThemeColors.dark],
|
||||
),
|
||||
home: const CreateClubScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Account Required'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Sign in or create a MotorSignal account to join and manage riding clubs.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Sign In / Register'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
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/widgets/slug_input_field.dart';
|
||||
import 'package:motorcycle_dash/services/clubs/club_providers.dart';
|
||||
import 'package:motorcycle_dash/services/clubs/club_service.dart';
|
||||
import 'package:motorcycle_dash/services/clubs/club_slug_validator.dart';
|
||||
|
||||
class FakeClubService extends ClubService {
|
||||
final Set<String> takenSlugs;
|
||||
FakeClubService({this.takenSlugs = const {}});
|
||||
|
||||
@override
|
||||
Future<bool> isSlugAvailable(String slug) async {
|
||||
final validation = ClubSlugValidator.validate(slug);
|
||||
if (!validation.isValid) return false;
|
||||
return !takenSlugs.contains(validation.normalizedSlug!);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('SlugInputField Widget Tests', () {
|
||||
late TextEditingController controller;
|
||||
|
||||
setUp(() {
|
||||
controller = TextEditingController();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
testWidgets('shows initial guidance and permanence warning', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
clubServiceProvider.overrideWithValue(FakeClubService()),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: ThemeData.dark().copyWith(
|
||||
extensions: [MotorSignalThemeColors.dark],
|
||||
),
|
||||
home: Scaffold(
|
||||
body: SlugInputField(controller: controller),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Club Handle / Slug *'), findsOneWidget);
|
||||
expect(
|
||||
find.text('3–30 characters: lowercase letters, numbers, and single hyphens.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.text('Club handles are permanent and cannot be changed after creation. Choose carefully.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('shows validation error on invalid or reserved slugs', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
clubServiceProvider.overrideWithValue(FakeClubService()),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: ThemeData.dark().copyWith(
|
||||
extensions: [MotorSignalThemeColors.dark],
|
||||
),
|
||||
home: Scaffold(
|
||||
body: SlugInputField(controller: controller),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Enter short slug
|
||||
controller.text = 'ab';
|
||||
await tester.pump();
|
||||
expect(find.text('Club slug must be at least 3 characters'), findsOneWidget);
|
||||
|
||||
// Enter reserved slug
|
||||
controller.text = 'admin';
|
||||
await tester.pump();
|
||||
expect(find.text('This club slug is reserved'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('debounces and reports availability', (tester) async {
|
||||
bool? lastAvailability;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
clubServiceProvider.overrideWithValue(
|
||||
FakeClubService(takenSlugs: {'taken-slug'}),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: ThemeData.dark().copyWith(
|
||||
extensions: [MotorSignalThemeColors.dark],
|
||||
),
|
||||
home: Scaffold(
|
||||
body: SlugInputField(
|
||||
controller: controller,
|
||||
onValidationChanged: (val) => lastAvailability = val,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Enter valid available slug
|
||||
controller.text = 'cool-riders';
|
||||
await tester.pump(); // immediate update: checking
|
||||
expect(find.text('Checking handle availability...'), findsOneWidget);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 500)); // after debounce
|
||||
expect(find.text('Handle is available'), findsOneWidget);
|
||||
expect(lastAvailability, isTrue);
|
||||
|
||||
// Enter taken slug
|
||||
controller.text = 'taken-slug';
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
expect(find.text('Handle is already reserved by another club'), findsOneWidget);
|
||||
expect(lastAvailability, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -30,6 +30,10 @@ void main() {
|
|||
path: 'friends',
|
||||
builder: (context, state) => const Scaffold(body: Center(child: Text('Friends Target'))),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'clubs',
|
||||
builder: (context, state) => const Scaffold(body: Center(child: Text('Clubs Target'))),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
|
|
@ -59,22 +63,25 @@ void main() {
|
|||
expect(find.text('Social'), findsOneWidget);
|
||||
expect(find.text('Rider'), findsOneWidget);
|
||||
expect(find.byType(AvatarBadge), findsOneWidget);
|
||||
expect(find.text('Edit'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Renders all social destination cards', (tester) async {
|
||||
testWidgets('Renders sections and social destination cards', (tester) async {
|
||||
await tester.pumpWidget(buildTestSocialHub());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Rider Profile'), findsOneWidget);
|
||||
expect(find.text('Rider Network'), findsOneWidget);
|
||||
expect(find.text('Motorcycle Activities'), findsOneWidget);
|
||||
expect(find.text('Friends'), findsOneWidget);
|
||||
expect(find.text('Clubs'), findsOneWidget);
|
||||
expect(find.text('Group Rides'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Navigates to Rider Profile when profile card is tapped', (tester) async {
|
||||
testWidgets('Navigates to Rider Profile when Edit button is tapped', (tester) async {
|
||||
await tester.pumpWidget(buildTestSocialHub());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Rider Profile'));
|
||||
await tester.tap(find.text('Edit'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Profile Target'), findsOneWidget);
|
||||
|
|
@ -90,6 +97,16 @@ void main() {
|
|||
expect(find.text('Friends Target'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Navigates to Clubs when clubs card is tapped', (tester) async {
|
||||
await tester.pumpWidget(buildTestSocialHub());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Clubs'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Clubs Target'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Navigates to Group Rides when group rides card is tapped', (tester) async {
|
||||
await tester.pumpWidget(buildTestSocialHub());
|
||||
await tester.pumpAndSettle();
|
||||
|
|
|
|||
Loading…
Reference in New Issue