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

751 lines
25 KiB
Dart

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_invitation_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,
),
),
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('/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);
final myInvitesAsync = ref.watch(myClubInvitesProvider);
// If both are loading, show loading spinner
if (myClubsAsync.isLoading && myInvitesAsync.isLoading) {
return Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(colors.cyberCyan),
),
);
}
// If both failed, show error
if (myClubsAsync.hasError && myInvitesAsync.hasError) {
return 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);
ref.invalidate(myClubInvitesProvider);
},
child: const Text('Retry'),
),
],
),
),
);
}
final memberships = myClubsAsync.value ?? const [];
final invites = myInvitesAsync.value ?? const [];
if (memberships.isEmpty && invites.isEmpty) {
return _buildEmptyMyClubs(context, colors);
}
return RefreshIndicator(
onRefresh: () async {
ref.invalidate(myClubsProvider);
ref.invalidate(myClubInvitesProvider);
},
child: ListView(
padding: const EdgeInsets.all(16.0),
children: [
if (invites.isNotEmpty) ...[
Row(
children: [
Text(
'CLUB INVITATIONS',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 12.5,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
color: colors.launchGreen,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: colors.launchGreen.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${invites.length}',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 11,
fontWeight: FontWeight.w700,
color: colors.launchGreen,
),
),
),
],
),
const SizedBox(height: 10),
for (final invite in invites)
ClubInvitationCard(
key: ValueKey('invite_${invite.clubId}'),
member: invite,
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 12),
],
if (memberships.isNotEmpty) ...[
if (invites.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 10.0),
child: Text(
'ACTIVE MEMBERSHIPS',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 12.5,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
color: colors.cyberCyan,
),
),
),
LayoutBuilder(
builder: (context, constraints) {
final isLandscape = MediaQuery.sizeOf(context).width > MediaQuery.sizeOf(context).height;
final useGrid = isLandscape || constraints.maxWidth >= 600;
if (useGrid) {
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
mainAxisExtent: 215,
),
itemCount: memberships.length,
itemBuilder: (context, index) {
return ClubMemberCard(
key: ValueKey('my_club_${memberships[index].clubId}'),
member: memberships[index],
inGrid: true,
);
},
);
}
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: memberships.length,
itemBuilder: (context, index) {
return ClubMemberCard(
key: ValueKey('my_club_${memberships[index].clubId}'),
member: memberships[index],
);
},
);
},
),
],
],
),
);
}
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)),
),
],
),
LayoutBuilder(
builder: (context, constraints) {
final isLandscape = MediaQuery.sizeOf(context).width > MediaQuery.sizeOf(context).height;
return ConstrainedBox(
constraints: BoxConstraints(
maxWidth: isLandscape ? 400 : double.infinity,
),
child: 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 LayoutBuilder(
builder: (context, constraints) {
final isLandscape = MediaQuery.sizeOf(context).width > MediaQuery.sizeOf(context).height;
final useGrid = isLandscape || constraints.maxWidth >= 600;
if (useGrid) {
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
mainAxisExtent: 215,
),
itemCount: clubs.length,
itemBuilder: (context, index) {
final club = clubs[index];
return ClubCard(
key: ValueKey('discover_club_${club.id}'),
club: club,
inGrid: true,
onTap: () => context.push('/social/clubs/${club.id}'),
);
},
);
}
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'),
),
],
),
),
),
);
}
}