From a805497f6ad7376c266f985e98cd58c9d331729f Mon Sep 17 00:00:00 2001 From: Developer Date: Sun, 6 Sep 2026 19:42:01 -0400 Subject: [PATCH] feat: add clubs discovery and creation experience --- lib/core/router.dart | 20 + .../social/clubs/club_preview_screen.dart | 433 +++++++++++++ .../social/clubs/clubs_hub_screen.dart | 613 ++++++++++++++++++ .../social/clubs/create_club_screen.dart | 597 +++++++++++++++++ .../social/clubs/widgets/club_card.dart | 295 +++++++++ .../clubs/widgets/club_member_card.dart | 62 ++ .../social/clubs/widgets/club_monogram.dart | 114 ++++ .../clubs/widgets/slug_input_field.dart | 333 ++++++++++ lib/features/social/social_hub_screen.dart | 81 +-- lib/services/clubs/club_providers.dart | 17 + lib/services/clubs/club_service.dart | 65 +- test/club_card_test.dart | 109 ++++ test/club_monogram_test.dart | 79 +++ test/club_preview_screen_test.dart | 213 ++++++ test/clubs_hub_screen_test.dart | 174 +++++ test/create_club_screen_test.dart | 233 +++++++ test/slug_input_field_test.dart | 131 ++++ test/social_hub_screen_test.dart | 25 +- 18 files changed, 3548 insertions(+), 46 deletions(-) create mode 100644 lib/features/social/clubs/club_preview_screen.dart create mode 100644 lib/features/social/clubs/clubs_hub_screen.dart create mode 100644 lib/features/social/clubs/create_club_screen.dart create mode 100644 lib/features/social/clubs/widgets/club_card.dart create mode 100644 lib/features/social/clubs/widgets/club_member_card.dart create mode 100644 lib/features/social/clubs/widgets/club_monogram.dart create mode 100644 lib/features/social/clubs/widgets/slug_input_field.dart create mode 100644 test/club_card_test.dart create mode 100644 test/club_monogram_test.dart create mode 100644 test/club_preview_screen_test.dart create mode 100644 test/clubs_hub_screen_test.dart create mode 100644 test/create_club_screen_test.dart create mode 100644 test/slug_input_field_test.dart diff --git a/lib/core/router.dart b/lib/core/router.dart index 30efd13..8054d1e 100644 --- a/lib/core/router.dart +++ b/lib/core/router.dart @@ -19,6 +19,9 @@ import '../features/activity/activity_screen.dart'; import '../features/social/social_hub_screen.dart'; import '../features/social/rider_profile_screen.dart'; import '../features/social/friends_screen.dart'; +import '../features/social/clubs/clubs_hub_screen.dart'; +import '../features/social/clubs/create_club_screen.dart'; +import '../features/social/clubs/club_preview_screen.dart'; import '../features/group_rides/group_rides_hub_screen.dart'; import '../features/group_rides/create_group_ride_screen.dart'; import '../features/group_rides/group_ride_detail_screen.dart'; @@ -160,6 +163,23 @@ final appRouter = GoRouter( path: 'friends', builder: (context, state) => const FriendsScreen(), ), + GoRoute( + path: 'clubs', + builder: (context, state) => const ClubsHubScreen(), + routes: [ + GoRoute( + path: 'create', + builder: (context, state) => const CreateClubScreen(), + ), + GoRoute( + path: ':id', + builder: (context, state) { + final id = state.pathParameters['id']!; + return ClubPreviewScreen(clubId: id); + }, + ), + ], + ), GoRoute( path: 'diagnostics', builder: (context, state) => const DiagnosticsScreen(), diff --git a/lib/features/social/clubs/club_preview_screen.dart b/lib/features/social/clubs/club_preview_screen.dart new file mode 100644 index 0000000..236c88b --- /dev/null +++ b/lib/features/social/clubs/club_preview_screen.dart @@ -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(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, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/social/clubs/clubs_hub_screen.dart b/lib/features/social/clubs/clubs_hub_screen.dart new file mode 100644 index 0000000..2371496 --- /dev/null +++ b/lib/features/social/clubs/clubs_hub_screen.dart @@ -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 createState() => _ClubsHubScreenState(); +} + +class _ClubsHubScreenState extends ConsumerState + 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 _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(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(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(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'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/social/clubs/create_club_screen.dart b/lib/features/social/clubs/create_club_screen.dart new file mode 100644 index 0000000..3c0beaa --- /dev/null +++ b/lib/features/social/clubs/create_club_screen.dart @@ -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 createState() => _CreateClubScreenState(); +} + +class _CreateClubScreenState extends ConsumerState { + final _formKey = GlobalKey(); + 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 _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(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, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/social/clubs/widgets/club_card.dart b/lib/features/social/clubs/widgets/club_card.dart new file mode 100644 index 0000000..f8936a6 --- /dev/null +++ b/lib/features/social/clubs/widgets/club_card.dart @@ -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, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/social/clubs/widgets/club_member_card.dart b/lib/features/social/clubs/widgets/club_member_card.dart new file mode 100644 index 0000000..dabb253 --- /dev/null +++ b/lib/features/social/clubs/widgets/club_member_card.dart @@ -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(colors.cyberCyan), + ), + ), + ), + ); + } +} diff --git a/lib/features/social/clubs/widgets/club_monogram.dart b/lib/features/social/clubs/widgets/club_monogram.dart new file mode 100644 index 0000000..4d0a425 --- /dev/null +++ b/lib/features/social/clubs/widgets/club_monogram.dart @@ -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, + ), + ), + ), + ); + } +} diff --git a/lib/features/social/clubs/widgets/slug_input_field.dart b/lib/features/social/clubs/widgets/slug_input_field.dart new file mode 100644 index 0000000..a67f85c --- /dev/null +++ b/lib/features/social/clubs/widgets/slug_input_field.dart @@ -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? onChanged; + final ValueChanged? onValidationChanged; + final bool enabled; + + const SlugInputField({ + super.key, + required this.controller, + this.onChanged, + this.onValidationChanged, + this.enabled = true, + }); + + @override + ConsumerState createState() => _SlugInputFieldState(); +} + +class _SlugInputFieldState extends ConsumerState { + 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 _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(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, + ); + } +} diff --git a/lib/features/social/social_hub_screen.dart b/lib/features/social/social_hub_screen.dart index 5383719..e096c56 100644 --- a/lib/features/social/social_hub_screen.dart +++ b/lib/features/social/social_hub_screen.dart @@ -45,8 +45,9 @@ class SocialHubScreen extends ConsumerWidget { children: [ // Rider Identity Header Card _buildRiderHeroCard(context, profileAsync.value, user, colors), - const SizedBox(height: 18), + const SizedBox(height: 20), + // Section 1: Rider Network Text( 'Rider Network', style: TextStyle( @@ -63,18 +64,6 @@ class SocialHubScreen extends ConsumerWidget { Row( crossAxisAlignment: CrossAxisAlignment.start, 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( child: _buildNavigationCard( context, @@ -87,29 +76,21 @@ class SocialHubScreen extends ConsumerWidget { 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 ...[ - _buildNavigationCard( - context, - key: const ValueKey('social_card_profile'), - icon: Icons.badge_outlined, - title: 'Rider Profile', - subtitle: 'Manage public handle, bio, region & visibility preferences', - colors: colors, - onTap: () => context.push('/social/profile'), - ), - const SizedBox(height: 12), _buildNavigationCard( context, key: const ValueKey('social_card_friends'), @@ -123,14 +104,38 @@ class SocialHubScreen extends ConsumerWidget { const SizedBox(height: 12), _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', + 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('/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), ], ), diff --git a/lib/services/clubs/club_providers.dart b/lib/services/clubs/club_providers.dart index 9174a12..9fb9a88 100644 --- a/lib/services/clubs/club_providers.dart +++ b/lib/services/clubs/club_providers.dart @@ -57,3 +57,20 @@ final pendingClubRequestsProvider = StreamProvider.family, Stri final service = ref.watch(clubServiceProvider); return service.pendingRequestsStream(clubId); }); + +/// FutureProvider resolving a Club by its unique slug handle. +/// Gated to registered, non-anonymous riders. +final clubBySlugProvider = FutureProvider.family((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>((ref) { + final service = ref.watch(clubServiceProvider); + return service.discoverPublicClubs(limit: 25); +}); diff --git a/lib/services/clubs/club_service.dart b/lib/services/clubs/club_service.dart index 1fc4f95..10b7556 100644 --- a/lib/services/clubs/club_service.dart +++ b/lib/services/clubs/club_service.dart @@ -886,9 +886,14 @@ class ClubService { Future getClub(String clubId) async { final firestore = _firestore; if (firestore == null) return null; - final doc = await firestore.collection('clubs').doc(clubId).get(); - if (!doc.exists || doc.data() == null) return null; - return Club.fromFirestore(doc); + try { + final doc = await firestore.collection('clubs').doc(clubId).get(); + if (!doc.exists || doc.data() == null) return null; + return Club.fromFirestore(doc); + } catch (_) { + // Permission denied or inaccessible private club + return null; + } } /// Realtime stream of a Club document by ID. @@ -898,7 +903,59 @@ class ClubService { return firestore.collection('clubs').doc(clubId).snapshots().map((doc) { if (!doc.exists || doc.data() == null) return null; return Club.fromFirestore(doc); - }); + }).handleError((_) => null); + } + + /// Checks whether a slug is valid and available (not already reserved in club_handles/{slug}). + /// Gated to registered, non-anonymous users. + Future 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 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. diff --git a/test/club_card_test.dart b/test/club_card_test.dart new file mode 100644 index 0000000..4895aa3 --- /dev/null +++ b/test/club_card_test.dart @@ -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); + }); + }); +} diff --git a/test/club_monogram_test.dart b/test/club_monogram_test.dart new file mode 100644 index 0000000..06d6349 --- /dev/null +++ b/test/club_monogram_test.dart @@ -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, + ); + }); + }); +} diff --git a/test/club_preview_screen_test.dart b/test/club_preview_screen_test.dart new file mode 100644 index 0000000..af2153b --- /dev/null +++ b/test/club_preview_screen_test.dart @@ -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); + }); + }); +} diff --git a/test/clubs_hub_screen_test.dart b/test/clubs_hub_screen_test.dart new file mode 100644 index 0000000..f0b6b43 --- /dev/null +++ b/test/clubs_hub_screen_test.dart @@ -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 clubs; + final Map handles; + + FakeClubServiceForHub({ + this.clubs = const {}, + this.handles = const {}, + }); + + @override + Future getClubBySlug(String slug) async { + final clubId = handles[slug]; + if (clubId == null) return null; + return clubs[clubId]; + } + + @override + Future> 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); + }); + }); +} diff --git a/test/create_club_screen_test.dart b/test/create_club_screen_test.dart new file mode 100644 index 0000000..7d8c577 --- /dev/null +++ b/test/create_club_screen_test.dart @@ -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 takenSlugs; + Club? lastCreatedClub; + bool shouldFailSlugTaken; + + FakeCreateClubService({ + this.takenSlugs = const {}, + this.shouldFailSlugTaken = false, + }); + + @override + Future isSlugAvailable(String slug) async { + final validation = ClubSlugValidator.validate(slug); + if (!validation.isValid) return false; + return !takenSlugs.contains(validation.normalizedSlug!); + } + + @override + Future 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); + }); + }); +} diff --git a/test/slug_input_field_test.dart b/test/slug_input_field_test.dart new file mode 100644 index 0000000..7bfe110 --- /dev/null +++ b/test/slug_input_field_test.dart @@ -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 takenSlugs; + FakeClubService({this.takenSlugs = const {}}); + + @override + Future 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); + }); + }); +} diff --git a/test/social_hub_screen_test.dart b/test/social_hub_screen_test.dart index f90218c..ac371c7 100644 --- a/test/social_hub_screen_test.dart +++ b/test/social_hub_screen_test.dart @@ -30,6 +30,10 @@ void main() { path: 'friends', builder: (context, state) => const Scaffold(body: Center(child: Text('Friends Target'))), ), + GoRoute( + path: 'clubs', + builder: (context, state) => const Scaffold(body: Center(child: Text('Clubs Target'))), + ), ], ), GoRoute( @@ -59,22 +63,25 @@ void main() { expect(find.text('Social'), findsOneWidget); expect(find.text('Rider'), findsOneWidget); expect(find.byType(AvatarBadge), findsOneWidget); + expect(find.text('Edit'), findsOneWidget); }); - testWidgets('Renders all social destination cards', (tester) async { + testWidgets('Renders sections and social destination cards', (tester) async { await tester.pumpWidget(buildTestSocialHub()); await tester.pumpAndSettle(); - expect(find.text('Rider Profile'), findsOneWidget); + expect(find.text('Rider Network'), findsOneWidget); + expect(find.text('Motorcycle Activities'), findsOneWidget); expect(find.text('Friends'), findsOneWidget); + expect(find.text('Clubs'), findsOneWidget); expect(find.text('Group Rides'), findsOneWidget); }); - testWidgets('Navigates to Rider Profile when profile card is tapped', (tester) async { + testWidgets('Navigates to Rider Profile when Edit button is tapped', (tester) async { await tester.pumpWidget(buildTestSocialHub()); await tester.pumpAndSettle(); - await tester.tap(find.text('Rider Profile')); + await tester.tap(find.text('Edit')); await tester.pumpAndSettle(); expect(find.text('Profile Target'), findsOneWidget); @@ -90,6 +97,16 @@ void main() { expect(find.text('Friends Target'), findsOneWidget); }); + testWidgets('Navigates to Clubs when clubs card is tapped', (tester) async { + await tester.pumpWidget(buildTestSocialHub()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Clubs')); + await tester.pumpAndSettle(); + + expect(find.text('Clubs Target'), findsOneWidget); + }); + testWidgets('Navigates to Group Rides when group rides card is tapped', (tester) async { await tester.pumpWidget(buildTestSocialHub()); await tester.pumpAndSettle();