fix(social): resolve community feed discoverability, tab routing, live comments, and responsive overflows
This commit is contained in:
parent
71cd994b2f
commit
d378db4c86
|
|
@ -139,7 +139,13 @@ final appRouter = GoRouter(
|
|||
routes: [
|
||||
GoRoute(
|
||||
path: '/social',
|
||||
builder: (context, state) => const SocialHubScreen(),
|
||||
builder: (context, state) {
|
||||
final tabParam = state.uri.queryParameters['tab'];
|
||||
final initialTab = tabParam == 'hub' || tabParam == 'network'
|
||||
? SocialHubTab.hub
|
||||
: SocialHubTab.feed;
|
||||
return SocialHubScreen(initialTab: initialTab);
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'profile',
|
||||
|
|
@ -293,5 +299,13 @@ final appRouter = GoRouter(
|
|||
path: '/launch',
|
||||
builder: (context, state) => const PerformanceLaunchScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/social/feed',
|
||||
builder: (context, state) => const SocialFeedScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/feed',
|
||||
builder: (context, state) => const SocialFeedScreen(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import 'ride_action_button.dart';
|
|||
import 'ride_status_chip.dart';
|
||||
import 'speed_readout.dart';
|
||||
import '../../../services/alerts/widgets/quick_swarm_report_modal.dart';
|
||||
import '../../social/feed/widgets/quick_squad_feed_button.dart';
|
||||
|
||||
/// The single authoritative responsive layout for the MotorSignal dashboard.
|
||||
///
|
||||
|
|
@ -82,6 +83,8 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
|
|||
SizedBox(width: 4),
|
||||
QuickSwarmReportButton(compact: true),
|
||||
SizedBox(width: 4),
|
||||
QuickSquadFeedButton(compact: true),
|
||||
SizedBox(width: 4),
|
||||
GPSStatusIndicator(),
|
||||
SizedBox(width: 8),
|
||||
HeadingIndicator(),
|
||||
|
|
|
|||
|
|
@ -18,13 +18,76 @@ import 'widgets/share_session_modal.dart';
|
|||
/// snapshot cards, custom route vector track visualization, apex angle markers,
|
||||
/// glove-friendly fist bump kudos reactions, comment drawers, and session publishing.
|
||||
class SocialFeedScreen extends ConsumerWidget {
|
||||
const SocialFeedScreen({super.key});
|
||||
final bool showAppBar;
|
||||
|
||||
const SocialFeedScreen({super.key, this.showAppBar = true});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colors = context.motorColors;
|
||||
final activeFilter = ref.watch(feedFilterStateProvider);
|
||||
final feedAsync = ref.watch(socialFeedStreamProvider);
|
||||
final items = ref.watch(socialFeedNotifierProvider);
|
||||
|
||||
final content = SafeArea(
|
||||
child: ResponsiveLayout(
|
||||
builder: (context, responsive) {
|
||||
return Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 720),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Top Context Header & Filter Bar
|
||||
_buildTopFilterSection(context, ref, activeFilter, colors),
|
||||
|
||||
// Feed List
|
||||
Expanded(
|
||||
child: items.isEmpty
|
||||
? _buildEmptyFeedState(context, activeFilter, colors)
|
||||
: ListView.separated(
|
||||
key: const ValueKey('social_feed_list_view'),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 16),
|
||||
itemBuilder: (context, index) {
|
||||
return _SocialFeedCard(
|
||||
key: ValueKey('feed_card_${items[index].id}'),
|
||||
item: items[index],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Sticky Glove-Friendly Share Session Action Bar
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 12,
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 720),
|
||||
child: _buildStickyShareButton(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (!showAppBar) {
|
||||
return Container(
|
||||
color: const Color(0xFF101419),
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF101419),
|
||||
|
|
@ -93,81 +156,14 @@ class SocialFeedScreen extends ConsumerWidget {
|
|||
icon: const Icon(Icons.refresh_rounded, color: Color(0xFF849396)),
|
||||
tooltip: 'Refresh Feed',
|
||||
onPressed: () {
|
||||
ref.invalidate(socialFeedNotifierProvider);
|
||||
ref.invalidate(socialFeedStreamProvider);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: ResponsiveLayout(
|
||||
builder: (context, responsive) {
|
||||
return Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 720),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Top Context Header & Filter Bar
|
||||
_buildTopFilterSection(context, ref, activeFilter, colors),
|
||||
|
||||
// Feed List
|
||||
Expanded(
|
||||
child: feedAsync.when(
|
||||
data: (items) {
|
||||
if (items.isEmpty) {
|
||||
return _buildEmptyFeedState(context, activeFilter, colors);
|
||||
}
|
||||
return ListView.separated(
|
||||
key: const ValueKey('social_feed_list_view'),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 80),
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 16),
|
||||
itemBuilder: (context, index) {
|
||||
return _SocialFeedCard(
|
||||
key: ValueKey('feed_card_${items[index].id}'),
|
||||
item: items[index],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.electricCyan,
|
||||
),
|
||||
),
|
||||
error: (err, _) => Center(
|
||||
child: Text(
|
||||
'Error loading feed: $err',
|
||||
style: const TextStyle(color: Colors.redAccent),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Sticky Glove-Friendly Share Session Action Bar
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 12,
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 720),
|
||||
child: _buildStickyShareButton(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
body: content,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -869,15 +865,18 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
|
|||
children: [
|
||||
const Text('👊', style: TextStyle(fontSize: 18)),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$bumpCount Bumps',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isBumped
|
||||
? const Color(0xFF00363D)
|
||||
: Colors.white,
|
||||
Flexible(
|
||||
child: Text(
|
||||
'$bumpCount Bumps',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isBumped
|
||||
? const Color(0xFF00363D)
|
||||
: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -991,14 +990,17 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
|
|||
color: AppColors.electricCyan,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'INSPECT TELEMETRY GRAPH',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
color: AppColors.electricCyan,
|
||||
Flexible(
|
||||
child: Text(
|
||||
'INSPECT TELEMETRY GRAPH',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
color: AppColors.electricCyan,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -35,6 +35,13 @@ class _FeedCommentsBottomSheetState
|
|||
extends ConsumerState<FeedCommentsBottomSheet> {
|
||||
final TextEditingController _commentController = TextEditingController();
|
||||
bool _isSubmitting = false;
|
||||
late List<FeedPostComment> _localComments;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_localComments = List<FeedPostComment>.from(widget.feedItem.comments);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
|
|
@ -78,6 +85,9 @@ class _FeedCommentsBottomSheetState
|
|||
|
||||
_commentController.clear();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_localComments.add(comment);
|
||||
});
|
||||
FocusScope.of(context).unfocus();
|
||||
}
|
||||
} finally {
|
||||
|
|
@ -91,7 +101,14 @@ class _FeedCommentsBottomSheetState
|
|||
Widget build(BuildContext context) {
|
||||
final colors = context.motorColors;
|
||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||
final comments = widget.feedItem.comments;
|
||||
final feedItems = ref.watch(socialFeedNotifierProvider);
|
||||
final livePost = feedItems.cast<SocialFeedItem?>().firstWhere(
|
||||
(p) => p?.id == widget.feedItem.id,
|
||||
orElse: () => null,
|
||||
);
|
||||
final comments = livePost != null && livePost.comments.isNotEmpty
|
||||
? livePost.comments
|
||||
: _localComments;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/theme/app_typography.dart';
|
||||
|
||||
/// Glove-friendly quick launcher button for the Squad & Canyon Community Feed.
|
||||
///
|
||||
/// Designed to sit directly in the Dashboard status bar for instantaneous
|
||||
/// 1-tap access to live rider activity, route snapshots, and kudos.
|
||||
class QuickSquadFeedButton extends StatelessWidget {
|
||||
final bool compact;
|
||||
|
||||
const QuickSquadFeedButton({super.key, this.compact = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (compact) {
|
||||
return IconButton(
|
||||
key: const ValueKey('dash_quick_feed_button'),
|
||||
icon: const Icon(
|
||||
Icons.dynamic_feed_rounded,
|
||||
color: AppColors.electricCyan,
|
||||
size: 19,
|
||||
),
|
||||
tooltip: 'Squad & Canyon Feed',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
onPressed: () => context.push('/social/feed'),
|
||||
);
|
||||
}
|
||||
|
||||
return InkWell(
|
||||
key: const ValueKey('dash_quick_feed_button'),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () => context.push('/social/feed'),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF181C21),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppColors.electricCyan.withValues(alpha: 0.3),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 7,
|
||||
height: 7,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.electricCyan,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.electricCyan,
|
||||
blurRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
const Text(
|
||||
'FEED',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.6,
|
||||
color: Color(0xFFC3F5FF),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -87,9 +87,16 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
authorDisplayName: authorDisplayName,
|
||||
authorHandle: authorHandle,
|
||||
authorBadge: 'PRO',
|
||||
locationName: _locationController.text.trim(),
|
||||
rideTitle: _titleController.text.trim(),
|
||||
bikeModel: _bikeController.text.trim(),
|
||||
clubName: 'Bay Area Twisties Club',
|
||||
locationName: _locationController.text.trim().isNotEmpty
|
||||
? _locationController.text.trim()
|
||||
: 'Skyline Blvd & Highway 9',
|
||||
rideTitle: _titleController.text.trim().isNotEmpty
|
||||
? _titleController.text.trim()
|
||||
: 'Morning Canyon Carve',
|
||||
bikeModel: _bikeController.text.trim().isNotEmpty
|
||||
? _bikeController.text.trim()
|
||||
: '2024 Ducati Streetfighter V4',
|
||||
distanceMiles: dist,
|
||||
durationSeconds: durMins * 60,
|
||||
maxLeanAngleDeg: lean,
|
||||
|
|
@ -106,6 +113,9 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
],
|
||||
);
|
||||
|
||||
// Reset filter to All Activity so published session is immediately displayed
|
||||
ref.read(feedFilterStateProvider.notifier).state = FeedFilter.allActivity;
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
|
|
|||
|
|
@ -8,15 +8,42 @@ import '../../services/auth/auth_providers.dart';
|
|||
import '../../services/social/social_providers.dart';
|
||||
import 'widgets/avatar_badge.dart';
|
||||
|
||||
import 'feed/social_feed_screen.dart';
|
||||
|
||||
enum SocialHubTab { hub, feed }
|
||||
|
||||
/// The authoritative landing screen for MotorSignal Social & Community.
|
||||
///
|
||||
/// Unifies Rider Profile, Friends, and Group Rides under a dedicated,
|
||||
/// restrained automotive social home completely decoupled from Settings.
|
||||
class SocialHubScreen extends ConsumerWidget {
|
||||
const SocialHubScreen({super.key});
|
||||
/// Unifies the Community Social Feed & Kudos with Rider Profile, Friends,
|
||||
/// and Group Rides under a dedicated, restrained automotive social home.
|
||||
class SocialHubScreen extends ConsumerStatefulWidget {
|
||||
final SocialHubTab initialTab;
|
||||
|
||||
const SocialHubScreen({super.key, this.initialTab = SocialHubTab.hub});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<SocialHubScreen> createState() => _SocialHubScreenState();
|
||||
}
|
||||
|
||||
class _SocialHubScreenState extends ConsumerState<SocialHubScreen> {
|
||||
late SocialHubTab _currentTab;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentTab = widget.initialTab;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant SocialHubScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.initialTab != widget.initialTab) {
|
||||
_currentTab = widget.initialTab;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.motorColors;
|
||||
final user = ref.watch(currentUserProvider);
|
||||
final profileAsync = ref.watch(myRiderProfileProvider);
|
||||
|
|
@ -29,162 +56,246 @@ class SocialHubScreen extends ConsumerWidget {
|
|||
'Social',
|
||||
style: TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: ResponsiveLayout(
|
||||
builder: (context, responsive) {
|
||||
final isWide = responsive.isExpanded || (responsive.isMedium && responsive.isLandscape);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 800),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Rider Identity Header Card
|
||||
_buildRiderHeroCard(context, profileAsync.value, user, colors),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Section 1: Rider Network
|
||||
Text(
|
||||
'Rider Network',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
if (isWide) ...[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildNavigationCard(
|
||||
context,
|
||||
key: const ValueKey('social_card_friends'),
|
||||
icon: Icons.people_outline,
|
||||
title: 'Friends',
|
||||
subtitle: 'Find fellow riders, manage requests & connections',
|
||||
badgeCount: pendingCount,
|
||||
colors: colors,
|
||||
onTap: () => context.push('/social/friends'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: _buildNavigationCard(
|
||||
context,
|
||||
key: const ValueKey('social_card_clubs'),
|
||||
icon: Icons.shield_outlined,
|
||||
title: 'Clubs',
|
||||
subtitle: 'Discover motorcycle clubs, riding communities & member groups',
|
||||
colors: colors,
|
||||
onTap: () => context.push('/social/clubs'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
_buildNavigationCard(
|
||||
context,
|
||||
key: const ValueKey('social_card_friends'),
|
||||
icon: Icons.people_outline,
|
||||
title: 'Friends',
|
||||
subtitle: 'Find fellow riders, manage requests & connections',
|
||||
badgeCount: pendingCount,
|
||||
colors: colors,
|
||||
onTap: () => context.push('/social/friends'),
|
||||
),
|
||||
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),
|
||||
|
||||
if (isWide) ...[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildNavigationCard(
|
||||
context,
|
||||
key: const ValueKey('social_card_feed'),
|
||||
icon: Icons.dynamic_feed_outlined,
|
||||
title: 'Community Feed',
|
||||
subtitle: 'Squad telemetry, route snapshots, canyon paces & fist-bump kudos',
|
||||
colors: colors,
|
||||
onTap: () => context.push('/social/feed'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: _buildNavigationCard(
|
||||
context,
|
||||
key: const ValueKey('social_card_group_rides'),
|
||||
icon: Icons.groups_outlined,
|
||||
title: 'Group Rides',
|
||||
subtitle: 'Join active sessions, explore local pack rides & track group telemetry',
|
||||
colors: colors,
|
||||
onTap: () => context.push('/group-rides'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
_buildNavigationCard(
|
||||
context,
|
||||
key: const ValueKey('social_card_feed'),
|
||||
icon: Icons.dynamic_feed_outlined,
|
||||
title: 'Community Feed',
|
||||
subtitle: 'Squad telemetry, route snapshots, canyon paces & fist-bump kudos',
|
||||
colors: colors,
|
||||
onTap: () => context.push('/social/feed'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildNavigationCard(
|
||||
context,
|
||||
key: const ValueKey('social_card_group_rides'),
|
||||
icon: Icons.groups_outlined,
|
||||
title: 'Group Rides',
|
||||
subtitle: 'Join active sessions, explore local pack rides & track group telemetry',
|
||||
colors: colors,
|
||||
onTap: () => context.push('/group-rides'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF181C21),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: colors.outlineVariant.withValues(alpha: 0.5),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildTabToggle(
|
||||
key: const ValueKey('social_tab_feed'),
|
||||
label: 'Feed',
|
||||
isSelected: _currentTab == SocialHubTab.feed,
|
||||
colors: colors,
|
||||
onTap: () => setState(() => _currentTab = SocialHubTab.feed),
|
||||
),
|
||||
_buildTabToggle(
|
||||
key: const ValueKey('social_tab_hub'),
|
||||
label: 'Hub',
|
||||
isSelected: _currentTab == SocialHubTab.hub,
|
||||
colors: colors,
|
||||
onTap: () => setState(() => _currentTab = SocialHubTab.hub),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: IndexedStack(
|
||||
index: _currentTab == SocialHubTab.hub ? 0 : 1,
|
||||
children: [
|
||||
_buildHubBody(context, colors, user, profileAsync.value, pendingCount),
|
||||
const SocialFeedScreen(showAppBar: false),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabToggle({
|
||||
required Key key,
|
||||
required String label,
|
||||
required bool isSelected,
|
||||
required MotorSignalThemeColors colors,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return InkWell(
|
||||
key: key,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colors.cyberCyan : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isSelected ? const Color(0xFF00363D) : const Color(0xFFBAC9CC),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHubBody(
|
||||
BuildContext context,
|
||||
MotorSignalThemeColors colors,
|
||||
dynamic user,
|
||||
dynamic profile,
|
||||
int pendingCount,
|
||||
) {
|
||||
return SafeArea(
|
||||
child: ResponsiveLayout(
|
||||
builder: (context, responsive) {
|
||||
final isWide = responsive.isExpanded || (responsive.isMedium && responsive.isLandscape);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 800),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Rider Identity Header Card
|
||||
_buildRiderHeroCard(context, profile, user, colors),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Section 1: Rider Network
|
||||
Text(
|
||||
'Rider Network',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
if (isWide) ...[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildNavigationCard(
|
||||
context,
|
||||
key: const ValueKey('social_card_friends'),
|
||||
icon: Icons.people_outline,
|
||||
title: 'Friends',
|
||||
subtitle: 'Find fellow riders, manage requests & connections',
|
||||
badgeCount: pendingCount,
|
||||
colors: colors,
|
||||
onTap: () => context.push('/social/friends'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: _buildNavigationCard(
|
||||
context,
|
||||
key: const ValueKey('social_card_clubs'),
|
||||
icon: Icons.shield_outlined,
|
||||
title: 'Clubs',
|
||||
subtitle: 'Discover motorcycle clubs, riding communities & member groups',
|
||||
colors: colors,
|
||||
onTap: () => context.push('/social/clubs'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
_buildNavigationCard(
|
||||
context,
|
||||
key: const ValueKey('social_card_friends'),
|
||||
icon: Icons.people_outline,
|
||||
title: 'Friends',
|
||||
subtitle: 'Find fellow riders, manage requests & connections',
|
||||
badgeCount: pendingCount,
|
||||
colors: colors,
|
||||
onTap: () => context.push('/social/friends'),
|
||||
),
|
||||
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),
|
||||
|
||||
if (isWide) ...[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildNavigationCard(
|
||||
context,
|
||||
key: const ValueKey('social_card_feed'),
|
||||
icon: Icons.dynamic_feed_outlined,
|
||||
title: 'Community Feed',
|
||||
subtitle: 'Squad telemetry, route snapshots, canyon paces & fist-bump kudos',
|
||||
colors: colors,
|
||||
onTap: () => setState(() => _currentTab = SocialHubTab.feed),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: _buildNavigationCard(
|
||||
context,
|
||||
key: const ValueKey('social_card_group_rides'),
|
||||
icon: Icons.groups_outlined,
|
||||
title: 'Group Rides',
|
||||
subtitle: 'Join active sessions, explore local pack rides & track group telemetry',
|
||||
colors: colors,
|
||||
onTap: () => context.push('/group-rides'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
_buildNavigationCard(
|
||||
context,
|
||||
key: const ValueKey('social_card_feed'),
|
||||
icon: Icons.dynamic_feed_outlined,
|
||||
title: 'Community Feed',
|
||||
subtitle: 'Squad telemetry, route snapshots, canyon paces & fist-bump kudos',
|
||||
colors: colors,
|
||||
onTap: () => setState(() => _currentTab = SocialHubTab.feed),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildNavigationCard(
|
||||
context,
|
||||
key: const ValueKey('social_card_group_rides'),
|
||||
icon: Icons.groups_outlined,
|
||||
title: 'Group Rides',
|
||||
subtitle: 'Join active sessions, explore local pack rides & track group telemetry',
|
||||
colors: colors,
|
||||
onTap: () => context.push('/group-rides'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import 'dart:async';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'social_feed_models.dart';
|
||||
import 'social_feed_service.dart';
|
||||
|
|
@ -7,7 +9,9 @@ import 'social_feed_service.dart';
|
|||
final socialFeedServiceProvider = Provider<SocialFeedService>((ref) {
|
||||
FirebaseFirestore? firestore;
|
||||
try {
|
||||
firestore = FirebaseFirestore.instance;
|
||||
if (Firebase.apps.isNotEmpty) {
|
||||
firestore = FirebaseFirestore.instance;
|
||||
}
|
||||
} catch (_) {
|
||||
firestore = null;
|
||||
}
|
||||
|
|
@ -23,10 +27,40 @@ final feedFilterStateProvider = StateProvider<FeedFilter>((ref) {
|
|||
return FeedFilter.allActivity;
|
||||
});
|
||||
|
||||
/// Stream provider for the filtered community social feed.
|
||||
/// StateNotifier providing instant synchronous initial feed items and reacting to changes.
|
||||
class SocialFeedNotifier extends StateNotifier<List<SocialFeedItem>> {
|
||||
final SocialFeedService _service;
|
||||
final FeedFilter _filter;
|
||||
StreamSubscription<List<SocialFeedItem>>? _sub;
|
||||
|
||||
SocialFeedNotifier(this._service, this._filter)
|
||||
: super(_service.getFilteredItems(filter: _filter)) {
|
||||
_sub = _service.getFeedStream(filter: _filter).listen((items) {
|
||||
state = items;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Primary feed provider with zero-latency synchronous initial data.
|
||||
final socialFeedNotifierProvider =
|
||||
StateNotifierProvider.autoDispose<SocialFeedNotifier, List<SocialFeedItem>>(
|
||||
(ref) {
|
||||
final service = ref.watch(socialFeedServiceProvider);
|
||||
final filter = ref.watch(feedFilterStateProvider);
|
||||
return SocialFeedNotifier(service, filter);
|
||||
});
|
||||
|
||||
/// Stream provider for the filtered community social feed (preserved for backwards compatibility).
|
||||
final socialFeedStreamProvider =
|
||||
StreamProvider.autoDispose<List<SocialFeedItem>>((ref) {
|
||||
final service = ref.watch(socialFeedServiceProvider);
|
||||
final filter = ref.watch(feedFilterStateProvider);
|
||||
return service.getFeedStream(filter: filter);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -124,6 +124,15 @@ class SocialFeedService {
|
|||
_localFeedController.add(List.unmodifiable(_memoryFeed));
|
||||
}
|
||||
|
||||
List<SocialFeedItem> get currentFeed => List.unmodifiable(_memoryFeed);
|
||||
|
||||
List<SocialFeedItem> getFilteredItems({
|
||||
FeedFilter filter = FeedFilter.allActivity,
|
||||
String? userClubName,
|
||||
}) {
|
||||
return _filterItems(_memoryFeed, filter, userClubName);
|
||||
}
|
||||
|
||||
Stream<List<SocialFeedItem>> getFeedStream({
|
||||
FeedFilter filter = FeedFilter.allActivity,
|
||||
String? userClubName,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:motorcycle_dash/core/router.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -30,6 +30,12 @@ void main() {
|
|||
|
||||
final matchFriends = config.findMatch(Uri.parse('/social/friends'));
|
||||
expect(matchFriends.matches.isNotEmpty, isTrue);
|
||||
|
||||
final matchSocialFeed = config.findMatch(Uri.parse('/social/feed'));
|
||||
expect(matchSocialFeed.matches.isNotEmpty, isTrue);
|
||||
|
||||
final matchFeed = config.findMatch(Uri.parse('/feed'));
|
||||
expect(matchFeed.matches.isNotEmpty, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:motorcycle_dash/core/router.dart';
|
||||
import 'package:motorcycle_dash/core/theme/theme_extensions.dart';
|
||||
import 'package:motorcycle_dash/features/records/records_service.dart';
|
||||
import 'package:motorcycle_dash/features/social/feed/social_feed_screen.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
testWidgets('Navigating to /social and tapping Community Feed opens SocialFeedScreen', (tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(400, 800));
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
sharedPreferencesProvider.overrideWithValue(prefs),
|
||||
],
|
||||
child: MaterialApp.router(
|
||||
theme: ThemeData.dark().copyWith(
|
||||
extensions: const [
|
||||
MotorSignalThemeColors.dark,
|
||||
],
|
||||
),
|
||||
routerConfig: appRouter,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 1. We start on Dashboard (index 0)
|
||||
expect(find.text('Dash'), findsWidgets);
|
||||
|
||||
// 2. Tap Social in bottom navigation bar
|
||||
final socialTab = find.byKey(const ValueKey('nav_social_icon'));
|
||||
expect(socialTab, findsOneWidget);
|
||||
await tester.tap(socialTab);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 3. We land directly on Community Feed!
|
||||
expect(find.byType(SocialFeedScreen), findsOneWidget);
|
||||
expect(find.text('SQUAD & CANYON FEED'), findsOneWidget);
|
||||
|
||||
// 4. Tap 'Hub' toggle in AppBar to view Rider Hub
|
||||
final hubToggle = find.byKey(const ValueKey('social_tab_hub'));
|
||||
expect(hubToggle, findsOneWidget);
|
||||
await tester.tap(hubToggle);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Rider Network'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('social_card_feed')), findsOneWidget);
|
||||
|
||||
// 5. Tapping 'Feed' toggle switches back to Community Feed
|
||||
final feedToggle = find.byKey(const ValueKey('social_tab_feed'));
|
||||
await tester.tap(feedToggle);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('SQUAD & CANYON FEED'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue