diff --git a/lib/core/router.dart b/lib/core/router.dart index 0c1e376..9c1400e 100644 --- a/lib/core/router.dart +++ b/lib/core/router.dart @@ -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(), + ), ], ); diff --git a/lib/features/dashboard/widgets/responsive_dashboard_layout.dart b/lib/features/dashboard/widgets/responsive_dashboard_layout.dart index 1189e07..88d0764 100644 --- a/lib/features/dashboard/widgets/responsive_dashboard_layout.dart +++ b/lib/features/dashboard/widgets/responsive_dashboard_layout.dart @@ -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(), diff --git a/lib/features/social/feed/social_feed_screen.dart b/lib/features/social/feed/social_feed_screen.dart index 79dd694..afa754d 100644 --- a/lib/features/social/feed/social_feed_screen.dart +++ b/lib/features/social/feed/social_feed_screen.dart @@ -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, + ), ), ), ], diff --git a/lib/features/social/feed/widgets/feed_comments_bottom_sheet.dart b/lib/features/social/feed/widgets/feed_comments_bottom_sheet.dart index 88b0bd6..3860cd8 100644 --- a/lib/features/social/feed/widgets/feed_comments_bottom_sheet.dart +++ b/lib/features/social/feed/widgets/feed_comments_bottom_sheet.dart @@ -35,6 +35,13 @@ class _FeedCommentsBottomSheetState extends ConsumerState { final TextEditingController _commentController = TextEditingController(); bool _isSubmitting = false; + late List _localComments; + + @override + void initState() { + super.initState(); + _localComments = List.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().firstWhere( + (p) => p?.id == widget.feedItem.id, + orElse: () => null, + ); + final comments = livePost != null && livePost.comments.isNotEmpty + ? livePost.comments + : _localComments; return Container( decoration: BoxDecoration( diff --git a/lib/features/social/feed/widgets/quick_squad_feed_button.dart b/lib/features/social/feed/widgets/quick_squad_feed_button.dart new file mode 100644 index 0000000..d1bf900 --- /dev/null +++ b/lib/features/social/feed/widgets/quick_squad_feed_button.dart @@ -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), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/social/feed/widgets/share_session_modal.dart b/lib/features/social/feed/widgets/share_session_modal.dart index 4bebe0f..3c99cbb 100644 --- a/lib/features/social/feed/widgets/share_session_modal.dart +++ b/lib/features/social/feed/widgets/share_session_modal.dart @@ -87,9 +87,16 @@ class _ShareSessionModalState extends ConsumerState { 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 { ], ); + // 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( diff --git a/lib/features/social/social_hub_screen.dart b/lib/features/social/social_hub_screen.dart index 5d24375..fa7c081 100644 --- a/lib/features/social/social_hub_screen.dart +++ b/lib/features/social/social_hub_screen.dart @@ -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 createState() => _SocialHubScreenState(); +} + +class _SocialHubScreenState extends ConsumerState { + 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), + ], + ), + ), + ), + ); + }, ), ); } diff --git a/lib/services/social/social_feed_providers.dart b/lib/services/social/social_feed_providers.dart index 8391302..f7b6762 100644 --- a/lib/services/social/social_feed_providers.dart +++ b/lib/services/social/social_feed_providers.dart @@ -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((ref) { FirebaseFirestore? firestore; try { - firestore = FirebaseFirestore.instance; + if (Firebase.apps.isNotEmpty) { + firestore = FirebaseFirestore.instance; + } } catch (_) { firestore = null; } @@ -23,10 +27,40 @@ final feedFilterStateProvider = StateProvider((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> { + final SocialFeedService _service; + final FeedFilter _filter; + StreamSubscription>? _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>( + (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>((ref) { final service = ref.watch(socialFeedServiceProvider); final filter = ref.watch(feedFilterStateProvider); return service.getFeedStream(filter: filter); }); + diff --git a/lib/services/social/social_feed_service.dart b/lib/services/social/social_feed_service.dart index 4ae4025..e42781f 100644 --- a/lib/services/social/social_feed_service.dart +++ b/lib/services/social/social_feed_service.dart @@ -124,6 +124,15 @@ class SocialFeedService { _localFeedController.add(List.unmodifiable(_memoryFeed)); } + List get currentFeed => List.unmodifiable(_memoryFeed); + + List getFilteredItems({ + FeedFilter filter = FeedFilter.allActivity, + String? userClubName, + }) { + return _filterItems(_memoryFeed, filter, userClubName); + } + Stream> getFeedStream({ FeedFilter filter = FeedFilter.allActivity, String? userClubName, diff --git a/test/router_test.dart b/test/router_test.dart index a55bd9b..1d224e6 100644 --- a/test/router_test.dart +++ b/test/router_test.dart @@ -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); }); }); } diff --git a/test/social_hub_navigation_test.dart b/test/social_hub_navigation_test.dart new file mode 100644 index 0000000..b86076f --- /dev/null +++ b/test/social_hub_navigation_test.dart @@ -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); + }); +}