684 lines
22 KiB
Dart
684 lines
22 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,
|
|
),
|
|
),
|
|
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);
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
for (final member in memberships)
|
|
ClubMemberCard(
|
|
key: ValueKey('my_club_${member.clubId}'),
|
|
member: member,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|