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