feat: add clubs discovery and creation experience

This commit is contained in:
Developer 2026-09-06 19:42:01 -04:00
parent 0d8e26750f
commit a805497f6a
18 changed files with 3548 additions and 46 deletions

View File

@ -19,6 +19,9 @@ import '../features/activity/activity_screen.dart';
import '../features/social/social_hub_screen.dart'; import '../features/social/social_hub_screen.dart';
import '../features/social/rider_profile_screen.dart'; import '../features/social/rider_profile_screen.dart';
import '../features/social/friends_screen.dart'; import '../features/social/friends_screen.dart';
import '../features/social/clubs/clubs_hub_screen.dart';
import '../features/social/clubs/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/group_rides_hub_screen.dart';
import '../features/group_rides/create_group_ride_screen.dart'; import '../features/group_rides/create_group_ride_screen.dart';
import '../features/group_rides/group_ride_detail_screen.dart'; import '../features/group_rides/group_ride_detail_screen.dart';
@ -160,6 +163,23 @@ final appRouter = GoRouter(
path: 'friends', path: 'friends',
builder: (context, state) => const FriendsScreen(), 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( GoRoute(
path: 'diagnostics', path: 'diagnostics',
builder: (context, state) => const DiagnosticsScreen(), builder: (context, state) => const DiagnosticsScreen(),

View File

@ -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,
),
),
],
),
);
}
}

View File

@ -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'),
),
],
),
),
),
);
}
}

View File

@ -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,
),
),
],
),
),
],
),
),
),
),
);
}
}

View File

@ -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,
),
),
),
],
),
);
}
}

View File

@ -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),
),
),
),
);
}
}

View File

@ -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,
),
),
),
);
}
}

View File

@ -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,
);
}
}

View File

@ -45,8 +45,9 @@ class SocialHubScreen extends ConsumerWidget {
children: [ children: [
// Rider Identity Header Card // Rider Identity Header Card
_buildRiderHeroCard(context, profileAsync.value, user, colors), _buildRiderHeroCard(context, profileAsync.value, user, colors),
const SizedBox(height: 18), const SizedBox(height: 20),
// Section 1: Rider Network
Text( Text(
'Rider Network', 'Rider Network',
style: TextStyle( style: TextStyle(
@ -63,18 +64,6 @@ class SocialHubScreen extends ConsumerWidget {
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
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',
colors: colors,
onTap: () => context.push('/social/profile'),
),
),
const SizedBox(width: 14),
Expanded( Expanded(
child: _buildNavigationCard( child: _buildNavigationCard(
context, context,
@ -87,29 +76,21 @@ class SocialHubScreen extends ConsumerWidget {
onTap: () => context.push('/social/friends'), onTap: () => context.push('/social/friends'),
), ),
), ),
const SizedBox(width: 14),
Expanded(
child: _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: 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 ...[ ] 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( _buildNavigationCard(
context, context,
key: const ValueKey('social_card_friends'), key: const ValueKey('social_card_friends'),
@ -123,14 +104,38 @@ class SocialHubScreen extends ConsumerWidget {
const SizedBox(height: 12), const SizedBox(height: 12),
_buildNavigationCard( _buildNavigationCard(
context, context,
key: const ValueKey('social_card_group_rides'), key: const ValueKey('social_card_clubs'),
icon: Icons.groups_outlined, icon: Icons.shield_outlined,
title: 'Group Rides', title: 'Clubs',
subtitle: 'Join active sessions, explore local pack rides & track group telemetry', subtitle: 'Discover motorcycle clubs, riding communities & member groups',
colors: colors, colors: colors,
onTap: () => context.push('/group-rides'), 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'),
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'),
),
const SizedBox(height: 24), const SizedBox(height: 24),
], ],
), ),

View File

@ -57,3 +57,20 @@ final pendingClubRequestsProvider = StreamProvider.family<List<ClubMember>, Stri
final service = ref.watch(clubServiceProvider); final service = ref.watch(clubServiceProvider);
return service.pendingRequestsStream(clubId); 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);
});

View File

@ -886,9 +886,14 @@ class ClubService {
Future<Club?> getClub(String clubId) async { Future<Club?> getClub(String clubId) async {
final firestore = _firestore; final firestore = _firestore;
if (firestore == null) return null; if (firestore == null) return null;
final doc = await firestore.collection('clubs').doc(clubId).get(); try {
if (!doc.exists || doc.data() == null) return null; final doc = await firestore.collection('clubs').doc(clubId).get();
return Club.fromFirestore(doc); 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. /// Realtime stream of a Club document by ID.
@ -898,7 +903,59 @@ class ClubService {
return firestore.collection('clubs').doc(clubId).snapshots().map((doc) { return firestore.collection('clubs').doc(clubId).snapshots().map((doc) {
if (!doc.exists || doc.data() == null) return null; if (!doc.exists || doc.data() == null) return null;
return Club.fromFirestore(doc); 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. /// Point read of a ClubMember document.

109
test/club_card_test.dart Normal file
View File

@ -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);
});
});
}

View File

@ -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,
);
});
});
}

View File

@ -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);
});
});
}

View File

@ -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);
});
});
}

View File

@ -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);
});
});
}

View File

@ -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);
});
});
}

View File

@ -30,6 +30,10 @@ void main() {
path: 'friends', path: 'friends',
builder: (context, state) => const Scaffold(body: Center(child: Text('Friends Target'))), 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( GoRoute(
@ -59,22 +63,25 @@ void main() {
expect(find.text('Social'), findsOneWidget); expect(find.text('Social'), findsOneWidget);
expect(find.text('Rider'), findsOneWidget); expect(find.text('Rider'), findsOneWidget);
expect(find.byType(AvatarBadge), 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.pumpWidget(buildTestSocialHub());
await tester.pumpAndSettle(); 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('Friends'), findsOneWidget);
expect(find.text('Clubs'), findsOneWidget);
expect(find.text('Group Rides'), 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.pumpWidget(buildTestSocialHub());
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Rider Profile')); await tester.tap(find.text('Edit'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Profile Target'), findsOneWidget); expect(find.text('Profile Target'), findsOneWidget);
@ -90,6 +97,16 @@ void main() {
expect(find.text('Friends Target'), findsOneWidget); 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 { testWidgets('Navigates to Group Rides when group rides card is tapped', (tester) async {
await tester.pumpWidget(buildTestSocialHub()); await tester.pumpWidget(buildTestSocialHub());
await tester.pumpAndSettle(); await tester.pumpAndSettle();