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_colors.dart'; import '../../../core/theme/app_typography.dart'; import '../../../core/theme/theme_extensions.dart'; import '../../../services/auth/auth_providers.dart'; import '../../../services/map/map_provider_interface.dart'; import '../../../services/social/social_feed_models.dart'; import '../../../services/social/social_feed_providers.dart'; import 'widgets/feed_comments_bottom_sheet.dart'; import 'widgets/feed_telemetry_inspector_modal.dart'; import 'widgets/share_session_modal.dart'; /// Community Social Feed & Kudos Screen (`SOC-01` / `MOB-19`). /// /// Implements the Stitch canonical Community Social Feed HUD with telemetry /// snapshot cards, custom route vector track visualization, apex angle markers, /// glove-friendly fist bump kudos reactions, comment drawers, and session publishing. /// glove-friendly fist bump kudos reactions, comment drawers, and session publishing. class SocialFeedScreen extends ConsumerStatefulWidget { final bool showAppBar; const SocialFeedScreen({super.key, this.showAppBar = true}); @override ConsumerState createState() => _SocialFeedScreenState(); } class _SocialFeedScreenState extends ConsumerState { bool _isSearchExpanded = false; late final TextEditingController _searchController; @override void initState() { super.initState(); _searchController = TextEditingController( text: ref.read(feedSearchQueryProvider), ); } @override void dispose() { _searchController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final colors = context.motorColors; final activeFilter = ref.watch(feedFilterStateProvider); final activeDiscipline = ref.watch(feedDisciplineFilterProvider); final searchQuery = ref.watch(feedSearchQueryProvider); 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, Multi-Stream Bar & Discipline Chips _buildTopFilterSection( context, activeFilter, activeDiscipline, searchQuery, colors, ), // Feed List Expanded( child: items.isEmpty ? _buildEmptyFeedState( context, activeFilter, activeDiscipline, searchQuery, colors, ) : ListView.separated( key: const ValueKey('social_feed_list_view'), padding: const EdgeInsets.fromLTRB(16, 10, 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), ), ), ), ], ); }, ), ); final isLight = Theme.of(context).brightness == Brightness.light; final primaryTextColor = isLight ? AppColors.lightOnSurface : AppColors.darkOnSurface; final secondaryTextColor = isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant; final accentColor = isLight ? AppColors.brandBlueStrong : AppColors.brandBlue; final backgroundColor = isLight ? AppColors.lightBackground : AppColors.darkBackground; if (!widget.showAppBar) { return Container( color: backgroundColor, child: content, ); } return Scaffold( backgroundColor: backgroundColor, appBar: AppBar( backgroundColor: backgroundColor, elevation: 0, leading: IconButton( icon: Icon(Icons.arrow_back_rounded, color: primaryTextColor), onPressed: () { if (context.canPop()) { context.pop(); } else { context.go('/social'); } }, ), title: Row( children: [ Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( color: accentColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(8), ), child: Icon( Icons.dynamic_feed_rounded, color: accentColor, size: 20, ), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( 'Community Feed', overflow: TextOverflow.ellipsis, style: TextStyle( fontFamily: AppTypography.fontHeadline, fontSize: 16, fontWeight: FontWeight.w700, letterSpacing: 0.2, color: primaryTextColor, ), ), Text( 'SQUAD TELEMETRY', overflow: TextOverflow.ellipsis, style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 9, fontWeight: FontWeight.w700, letterSpacing: 0.8, color: secondaryTextColor, ), ), ], ), ), ], ), actions: [ IconButton( icon: Icon( _isSearchExpanded ? Icons.search_off_rounded : Icons.search_rounded, color: _isSearchExpanded ? accentColor : secondaryTextColor, ), tooltip: 'Search Routes', onPressed: () { setState(() { _isSearchExpanded = !_isSearchExpanded; if (!_isSearchExpanded) { _searchController.clear(); ref.read(feedSearchQueryProvider.notifier).state = ''; } }); }, ), IconButton( icon: Icon(Icons.refresh_rounded, color: secondaryTextColor), tooltip: 'Refresh Feed', onPressed: () { ref.invalidate(socialFeedNotifierProvider); ref.invalidate(socialFeedStreamProvider); }, ), const SizedBox(width: 4), ], ), body: content, ); } Widget _buildTopFilterSection( BuildContext context, FeedFilter activeFilter, RidingDiscipline activeDiscipline, String searchQuery, MotorSignalThemeColors colors, ) { final isLight = Theme.of(context).brightness == Brightness.light; final primaryTextColor = isLight ? AppColors.lightOnSurface : AppColors.darkOnSurface; final secondaryTextColor = isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant; final accentColor = isLight ? AppColors.brandBlueStrong : AppColors.brandBlue; return Container( padding: const EdgeInsets.fromLTRB(16, 10, 16, 8), color: Colors.transparent, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Row 1: Status LED & Search toggle Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.center, children: [ // Status LED pill Flexible( child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: colors.surfaceContainer, borderRadius: BorderRadius.circular(20), border: Border.all( color: accentColor.withValues(alpha: isLight ? 0.5 : 0.3), ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 8, height: 8, decoration: BoxDecoration( color: accentColor, shape: BoxShape.circle, ), ), const SizedBox(width: 8), Flexible( child: Text( 'SQUAD FEED', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 0.6, color: accentColor, ), ), ), ], ), ), ), // Search toggle button IconButton( key: const ValueKey('feed_search_toggle_button'), icon: Icon( _isSearchExpanded ? Icons.close_rounded : Icons.search_rounded, color: accentColor, size: 20, ), tooltip: 'Search Feed', onPressed: () { setState(() { _isSearchExpanded = !_isSearchExpanded; if (!_isSearchExpanded) { _searchController.clear(); ref.read(feedSearchQueryProvider.notifier).state = ''; } }); }, ), ], ), // Optional Search Input Field if (_isSearchExpanded) ...[ const SizedBox(height: 8), Container( height: 42, decoration: BoxDecoration( color: colors.surfaceContainer, borderRadius: BorderRadius.circular(10), border: Border.all( color: colors.outlineVariant, ), ), child: TextField( controller: _searchController, autofocus: true, style: TextStyle( fontFamily: AppTypography.fontBody, fontSize: 13, color: primaryTextColor, ), decoration: InputDecoration( hintText: 'Search rider, route, bike, or region...', hintStyle: TextStyle( fontFamily: AppTypography.fontBody, fontSize: 13, color: secondaryTextColor, ), prefixIcon: Icon( Icons.search_rounded, size: 18, color: secondaryTextColor, ), suffixIcon: _searchController.text.isNotEmpty ? IconButton( icon: const Icon(Icons.close_rounded, size: 16), color: secondaryTextColor, onPressed: () { _searchController.clear(); ref.read(feedSearchQueryProvider.notifier).state = ''; setState(() {}); }, ) : null, border: InputBorder.none, contentPadding: const EdgeInsets.symmetric(vertical: 10), ), onChanged: (val) { ref.read(feedSearchQueryProvider.notifier).state = val; setState(() {}); }, ), ), ], const SizedBox(height: 8), // Unified Single Streamlined Filter Row (Streams + Disciplines) SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ _buildFilterTab( tab: FeedFilter.allActivity, title: 'All Activity', isSelected: activeFilter == FeedFilter.allActivity, ), const SizedBox(width: 4), _buildFilterTab( tab: FeedFilter.clubOnly, title: 'Club Only', isSelected: activeFilter == FeedFilter.clubOnly, ), const SizedBox(width: 4), _buildFilterTab( tab: FeedFilter.nearbyPacks, title: 'Nearby Packs', isSelected: activeFilter == FeedFilter.nearbyPacks, ), const SizedBox(width: 4), _buildFilterTab( tab: FeedFilter.forYou, title: 'For You', isSelected: activeFilter == FeedFilter.forYou, ), const SizedBox(width: 4), _buildFilterTab( tab: FeedFilter.following, title: 'Following', isSelected: activeFilter == FeedFilter.following, ), const SizedBox(width: 8), Container( width: 1, height: 20, color: colors.outlineVariant, ), const SizedBox(width: 8), _buildDisciplineChip( discipline: RidingDiscipline.all, label: 'All Styles', isSelected: activeDiscipline == RidingDiscipline.all, ), const SizedBox(width: 6), _buildDisciplineChip( discipline: RidingDiscipline.canyonSport, label: '⚡ Sport', isSelected: activeDiscipline == RidingDiscipline.canyonSport, ), const SizedBox(width: 6), _buildDisciplineChip( discipline: RidingDiscipline.advDualSport, label: '🌲 ADV & Dirt', isSelected: activeDiscipline == RidingDiscipline.advDualSport, ), const SizedBox(width: 6), _buildDisciplineChip( discipline: RidingDiscipline.touring, label: '🛣️ Touring', isSelected: activeDiscipline == RidingDiscipline.touring, ), const SizedBox(width: 6), _buildDisciplineChip( discipline: RidingDiscipline.trackDay, label: '🏁 Track Day', isSelected: activeDiscipline == RidingDiscipline.trackDay, ), const SizedBox(width: 6), _buildDisciplineChip( discipline: RidingDiscipline.cruiser, label: '🏍️ Cruiser', isSelected: activeDiscipline == RidingDiscipline.cruiser, ), ], ), ), // Filter Summary & Reset Bar (When disciplined or searched) if (activeDiscipline != RidingDiscipline.all || searchQuery.isNotEmpty) ...[ const SizedBox(height: 6), Row( children: [ Icon( Icons.filter_alt_outlined, size: 13, color: accentColor, ), const SizedBox(width: 4), Text( 'Filtered by: ${activeDiscipline != RidingDiscipline.all ? activeDiscipline.displayName : ''}${activeDiscipline != RidingDiscipline.all && searchQuery.isNotEmpty ? ', ' : ''}${searchQuery.isNotEmpty ? '"$searchQuery"' : ''}', style: TextStyle( fontFamily: AppTypography.fontBody, fontSize: 11, color: secondaryTextColor, ), ), const Spacer(), GestureDetector( onTap: () { ref.read(feedDisciplineFilterProvider.notifier).state = RidingDiscipline.all; ref.read(feedSearchQueryProvider.notifier).state = ''; _searchController.clear(); setState(() { _isSearchExpanded = false; }); }, child: Text( 'Reset', style: TextStyle( fontFamily: AppTypography.fontHeadline, fontSize: 11, fontWeight: FontWeight.w700, color: accentColor, ), ), ), ], ), ], ], ), ); } Widget _buildFilterTab({ required FeedFilter tab, required String title, required bool isSelected, }) { final colors = context.motorColors; final isLight = Theme.of(context).brightness == Brightness.light; final selectedBg = isLight ? AppColors.brandBlueStrong : AppColors.brandBlue; final selectedFg = AppColors.onBrand; final unselectedFg = isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant; return InkWell( borderRadius: BorderRadius.circular(10), onTap: () { ref.read(feedFilterStateProvider.notifier).state = tab; }, child: AnimatedContainer( duration: const Duration(milliseconds: 180), curve: Curves.easeInOut, height: 36, padding: const EdgeInsets.symmetric(horizontal: 10), alignment: Alignment.center, decoration: BoxDecoration( color: isSelected ? selectedBg : colors.surfaceContainer, borderRadius: BorderRadius.circular(10), border: Border.all( color: isSelected ? selectedBg : colors.outlineVariant, ), ), child: Text( title, style: TextStyle( fontFamily: AppTypography.fontHeadline, fontSize: 11.5, fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600, letterSpacing: 0.1, color: isSelected ? selectedFg : unselectedFg, ), ), ), ); } Widget _buildDisciplineChip({ required RidingDiscipline discipline, required String label, required bool isSelected, }) { final colors = context.motorColors; final isLight = Theme.of(context).brightness == Brightness.light; final selectedBg = isLight ? AppColors.brandBlueStrong : AppColors.brandBlue; final selectedFg = AppColors.onBrand; final unselectedFg = isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant; return InkWell( borderRadius: BorderRadius.circular(8), onTap: () { ref.read(feedDisciplineFilterProvider.notifier).state = discipline; }, child: AnimatedContainer( duration: const Duration(milliseconds: 160), height: 36, padding: const EdgeInsets.symmetric(horizontal: 10), alignment: Alignment.center, decoration: BoxDecoration( color: isSelected ? selectedBg : colors.surfaceContainer, borderRadius: BorderRadius.circular(8), border: Border.all( color: isSelected ? selectedBg : colors.outlineVariant, width: isSelected ? 1.2 : 1.0, ), ), child: Text( label, style: TextStyle( fontFamily: AppTypography.fontHeadline, fontSize: 11.5, fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600, color: isSelected ? selectedFg : unselectedFg, ), ), ), ); } Widget _buildEmptyFeedState( BuildContext context, FeedFilter activeFilter, RidingDiscipline activeDiscipline, String searchQuery, MotorSignalThemeColors colors, ) { final hasCustomFilters = activeDiscipline != RidingDiscipline.all || searchQuery.isNotEmpty; final isLight = Theme.of(context).brightness == Brightness.light; final primaryTextColor = isLight ? AppColors.lightOnSurface : AppColors.darkOnSurface; final secondaryTextColor = isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant; final accentColor = isLight ? AppColors.brandBlueStrong : AppColors.brandBlue; return LayoutBuilder( builder: (context, constraints) { return SingleChildScrollView( physics: const ClampingScrollPhysics(), padding: const EdgeInsets.only(bottom: 80), child: ConstrainedBox( constraints: BoxConstraints( minHeight: constraints.maxHeight > 80 ? constraints.maxHeight - 80 : constraints.maxHeight, ), child: Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.dynamic_feed_outlined, size: 56, color: secondaryTextColor, ), const SizedBox(height: 16), Text( hasCustomFilters ? 'No matching ride sessions' : 'No posts in ${activeFilter.displayName}', style: TextStyle( fontFamily: AppTypography.fontHeadline, fontSize: 16, fontWeight: FontWeight.w700, color: primaryTextColor, ), ), const SizedBox(height: 6), Text( hasCustomFilters ? 'Try relaxing your style filters or search query to discover more community rides.' : 'Hit "Share Recent Session" below to publish telemetry, or check out group rides and clubs.', textAlign: TextAlign.center, style: TextStyle( fontFamily: AppTypography.fontBody, fontSize: 13, color: secondaryTextColor, ), ), const SizedBox(height: 16), Wrap( spacing: 8, runSpacing: 8, alignment: WrapAlignment.center, children: [ OutlinedButton.icon( key: const ValueKey('empty_feed_group_rides_button'), icon: const Icon(Icons.two_wheeler_rounded, size: 16), label: const Text('Group Rides'), style: OutlinedButton.styleFrom( foregroundColor: accentColor, side: BorderSide( color: accentColor.withValues(alpha: 0.5), ), ), onPressed: () => context.push('/group-rides'), ), OutlinedButton.icon( key: const ValueKey('empty_feed_clubs_button'), icon: const Icon(Icons.groups_rounded, size: 16), label: const Text('Clubs'), style: OutlinedButton.styleFrom( foregroundColor: accentColor, side: BorderSide( color: accentColor.withValues(alpha: 0.5), ), ), onPressed: () => context.push('/social/clubs'), ), OutlinedButton.icon( key: const ValueKey('empty_feed_friends_button'), icon: const Icon(Icons.person_add_rounded, size: 16), label: const Text('Friends'), style: OutlinedButton.styleFrom( foregroundColor: accentColor, side: BorderSide( color: accentColor.withValues(alpha: 0.5), ), ), onPressed: () => context.push('/social/friends'), ), ], ), if (hasCustomFilters) ...[ const SizedBox(height: 12), OutlinedButton.icon( icon: const Icon(Icons.refresh_rounded, size: 16), label: const Text('Reset All Filters'), style: OutlinedButton.styleFrom( foregroundColor: accentColor, side: BorderSide( color: accentColor.withValues(alpha: 0.5), ), ), onPressed: () { ref.read(feedDisciplineFilterProvider.notifier).state = RidingDiscipline.all; ref.read(feedSearchQueryProvider.notifier).state = ''; _searchController.clear(); setState(() { _isSearchExpanded = false; }); }, ), ], ], ), ), ), ), ); }, ); } Widget _buildStickyShareButton(BuildContext context) { final isLight = Theme.of(context).brightness == Brightness.light; final buttonBg = isLight ? AppColors.brandBlueStrong : AppColors.brandBlue; final buttonFg = AppColors.onBrand; return Container( height: 52, decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), color: buttonBg, boxShadow: [ BoxShadow( color: buttonBg.withValues(alpha: 0.3), blurRadius: 16, offset: const Offset(0, 4), ), ], ), child: ElevatedButton( key: const ValueKey('feed_share_session_button'), style: ElevatedButton.styleFrom( backgroundColor: Colors.transparent, shadowColor: Colors.transparent, foregroundColor: buttonFg, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), elevation: 0, ), onPressed: () => ShareSessionModal.show(context), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: const [ Icon(Icons.add_circle_outline_rounded, size: 20), SizedBox(width: 8), Flexible( child: Text( 'SHARE RECENT SESSION', overflow: TextOverflow.ellipsis, style: TextStyle( fontFamily: AppTypography.fontHeadline, fontSize: 13.5, fontWeight: FontWeight.w800, letterSpacing: 0.8, ), ), ), ], ), ), ); } } Color _getDisciplineColor(RidingDiscipline discipline, {bool isLight = false}) { if (isLight) { switch (discipline) { case RidingDiscipline.canyonSport: return AppColors.semanticWarningLight; case RidingDiscipline.advDualSport: return AppColors.semanticSuccessLight; case RidingDiscipline.touring: return AppColors.brandBlueStrong; case RidingDiscipline.trackDay: return AppColors.semanticDanger; case RidingDiscipline.cruiser: return AppColors.brandBlueStrong; case RidingDiscipline.all: return AppColors.brandBlueStrong; } } switch (discipline) { case RidingDiscipline.canyonSport: return AppColors.hazardAmber; case RidingDiscipline.advDualSport: return AppColors.telemetryGreen; case RidingDiscipline.touring: return AppColors.brandBlue; case RidingDiscipline.trackDay: return AppColors.semanticDangerDark; case RidingDiscipline.cruiser: return AppColors.brandBlue; case RidingDiscipline.all: return AppColors.brandBlue; } } /// Authoritative Feed Item Card (`SocialFeedCard`). class _SocialFeedCard extends ConsumerStatefulWidget { final SocialFeedItem item; const _SocialFeedCard({super.key, required this.item}); @override ConsumerState<_SocialFeedCard> createState() => _SocialFeedCardState(); } class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> with SingleTickerProviderStateMixin { late AnimationController _bumpAnimController; late Animation _bumpScaleAnimation; int? _optimisticBumpCount; bool? _optimisticIsBumped; @override void initState() { super.initState(); _bumpAnimController = AnimationController( vsync: this, duration: const Duration(milliseconds: 160), ); _bumpScaleAnimation = Tween(begin: 1.0, end: 1.15).animate( CurvedAnimation(parent: _bumpAnimController, curve: Curves.easeOut), ); } @override void didUpdateWidget(covariant _SocialFeedCard oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.item.fistBumpCount != widget.item.fistBumpCount || oldWidget.item.fistBumpUserIds != widget.item.fistBumpUserIds) { _optimisticBumpCount = null; _optimisticIsBumped = null; } } @override void dispose() { _bumpAnimController.dispose(); super.dispose(); } String _formatRelativeTime(DateTime dt) { final now = DateTime.now(); final diff = now.difference(dt); if (diff.inMinutes < 1) return 'Just now'; if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; if (diff.inHours < 24) return '${diff.inHours}h ago'; return '${diff.inDays}d ago'; } String _formatDuration(int seconds) { final mins = seconds ~/ 60; if (mins < 60) { final secs = seconds % 60; return '${mins}m ${secs}s'; } final hrs = mins ~/ 60; final remMins = mins % 60; return '${hrs}h ${remMins}m'; } void _handleFistBump() { final user = ref.read(currentUserProvider); final uid = user?.uid ?? 'uid_rider'; final currentlyBumped = _optimisticIsBumped ?? widget.item.isFistBumpedBy(uid); final currentCount = _optimisticBumpCount ?? widget.item.fistBumpCount; setState(() { _optimisticIsBumped = !currentlyBumped; _optimisticBumpCount = currentlyBumped ? (currentCount - 1).clamp(0, 999999) : currentCount + 1; }); _bumpAnimController.forward().then((_) { if (mounted) _bumpAnimController.reverse(); }); ref.read(socialFeedServiceProvider).toggleFistBump( postId: widget.item.id, userUid: uid, ); } void _showGpxExportSnackBar( BuildContext context, MotorSignalThemeColors colors, Color accentColor, SocialFeedItem item, ) { final isLight = Theme.of(context).brightness == Brightness.light; final primaryTextColor = isLight ? AppColors.lightOnSurface : AppColors.darkOnSurface; final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; ScaffoldMessenger.of(context).showSnackBar( SnackBar( backgroundColor: colors.surfaceContainerHigh, behavior: SnackBarBehavior.floating, margin: EdgeInsets.fromLTRB( 16, 0, 16, isLandscape ? 8 : 72, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: BorderSide(color: colors.outlineVariant), ), content: Row( children: [ const Icon( Icons.download_done_rounded, color: AppColors.semanticSuccess, ), const SizedBox(width: 10), Expanded( child: Text( 'GPX track exported for ${item.rideTitle}', maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontFamily: AppTypography.fontBody, color: primaryTextColor, ), ), ), ], ), ), ); } @override Widget build(BuildContext context) { final colors = context.motorColors; final isLight = Theme.of(context).brightness == Brightness.light; final primaryTextColor = isLight ? AppColors.lightOnSurface : AppColors.darkOnSurface; final secondaryTextColor = isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant; final accentColor = isLight ? AppColors.brandBlueStrong : AppColors.brandBlue; final demoColor = isLight ? AppColors.semanticWarningLight : AppColors.hazardAmber; final disciplineColor = _getDisciplineColor(widget.item.discipline, isLight: isLight); final bumpedBg = isLight ? AppColors.brandBlueStrong : AppColors.brandBlue; final bumpedFg = AppColors.onBrand; final item = widget.item; final user = ref.watch(currentUserProvider); final isBumped = _optimisticIsBumped ?? item.isFistBumpedBy(user?.uid ?? 'uid_rider'); final bumpCount = _optimisticBumpCount ?? item.fistBumpCount; return Container( decoration: BoxDecoration( color: colors.surfaceContainer, borderRadius: BorderRadius.circular(18), border: Border.all(color: colors.outlineVariant), boxShadow: [ BoxShadow( color: colors.glowShadow, blurRadius: 14, offset: const Offset(0, 6), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Card Header: Author info, badge, discipline, club, overflow Padding( padding: const EdgeInsets.fromLTRB(16, 14, 12, 10), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Helmet Avatar with online indicator Stack( children: [ CircleAvatar( radius: 22, backgroundColor: colors.surfaceContainerHigh, child: Icon( Icons.sports_motorsports_rounded, color: accentColor, size: 26, ), ), Positioned( bottom: 0, right: 0, child: Container( width: 11, height: 11, decoration: BoxDecoration( color: accentColor, shape: BoxShape.circle, border: Border.all( color: colors.surfaceContainer, width: 1.5, ), ), ), ), ], ), const SizedBox(width: 12), // Author handle, badge, discipline badge, club, timestamp Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap( crossAxisAlignment: WrapCrossAlignment.center, spacing: 6, runSpacing: 4, children: [ Text( '@${item.authorHandle}', style: TextStyle( fontFamily: AppTypography.fontHeadline, fontSize: 15, fontWeight: FontWeight.w700, color: primaryTextColor, ), ), if (item.isDemo) ...[ Container( key: const ValueKey('feed_demo_pill'), padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2), decoration: BoxDecoration( color: demoColor.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(4), border: Border.all( color: demoColor.withValues(alpha: 0.4), ), ), child: Text( 'DEMO SAMPLE', style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 8.5, fontWeight: FontWeight.w800, letterSpacing: 0.5, color: demoColor, ), ), ), ], if (item.authorBadge != null) ...[ Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2), decoration: BoxDecoration( color: accentColor.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(4), ), child: Text( item.authorBadge!, style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 9, fontWeight: FontWeight.w800, letterSpacing: 0.6, color: accentColor, ), ), ), ], if (item.discipline != RidingDiscipline.all) ...[ Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2), decoration: BoxDecoration( color: disciplineColor.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(4), border: Border.all( color: disciplineColor.withValues(alpha: 0.35), width: 0.8, ), ), child: Text( item.discipline.displayName.toUpperCase(), style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 8.5, fontWeight: FontWeight.w800, letterSpacing: 0.5, color: disciplineColor, ), ), ), ], ], ), if (item.clubName != null) ...[ const SizedBox(height: 2), Text( item.clubName!, overflow: TextOverflow.ellipsis, style: TextStyle( fontFamily: AppTypography.fontBody, fontSize: 12, color: secondaryTextColor, ), ), ], const SizedBox(height: 2), Text( item.locationName.isEmpty ? _formatRelativeTime(item.timestamp) : '${_formatRelativeTime(item.timestamp)} • ${item.locationName}', style: TextStyle( fontFamily: AppTypography.fontBody, fontSize: 12.5, fontWeight: FontWeight.w500, color: secondaryTextColor, ), ), ], ), ), // Overflow options menu PopupMenuButton( icon: Icon( Icons.more_vert, color: secondaryTextColor, size: 20, ), color: colors.surfaceContainerHigh, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: BorderSide(color: colors.outlineVariant), ), onSelected: (val) { if (val == 'export_gpx') { _showGpxExportSnackBar(context, colors, accentColor, item); return; } ScaffoldMessenger.of(context).showSnackBar( SnackBar( backgroundColor: colors.surfaceContainerHigh, content: Row( children: [ Expanded( child: Text( 'Action: $val', maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(color: primaryTextColor), ), ), ], ), duration: const Duration(seconds: 1), ), ); }, itemBuilder: (ctx) => [ PopupMenuItem( value: 'copy_link', child: Text( 'Copy Route Link', style: TextStyle(color: primaryTextColor, fontSize: 13), ), ), PopupMenuItem( value: 'export_gpx', child: Text( 'Export GPX File', style: TextStyle(color: primaryTextColor, fontSize: 13), ), ), PopupMenuItem( value: 'report', child: Text( 'Report Content', style: TextStyle(color: AppColors.semanticDanger, fontSize: 13), ), ), ], ), ], ), ), // Route Title & Motorcycle Tag Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item.rideTitle, style: TextStyle( fontFamily: AppTypography.fontHeadline, fontSize: 18, fontWeight: FontWeight.w700, letterSpacing: -0.3, color: primaryTextColor, ), ), const SizedBox(height: 6), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(6), border: Border.all(color: colors.outlineVariant), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.sports_motorsports_rounded, size: 14, color: accentColor, ), const SizedBox(width: 6), Flexible( child: Text( item.bikeModel, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 11.5, fontWeight: FontWeight.w600, color: primaryTextColor, ), ), ), if (item.regionName != null) ...[ const SizedBox(width: 6), Container( width: 3, height: 3, decoration: BoxDecoration( color: secondaryTextColor, shape: BoxShape.circle, ), ), const SizedBox(width: 6), Text( item.regionName!, style: TextStyle( fontFamily: AppTypography.fontBody, fontSize: 11, color: secondaryTextColor, ), ), ], ], ), ), ], ), ), const SizedBox(height: 12), // Telemetry Inset Strip (Stitch 2x2 Inset Grid) Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(14), border: Border.all(color: colors.outlineVariant), ), child: Column( children: [ Row( children: [ _buildMetricCell( context: context, label: 'DISTANCE', value: item.distanceMiles.toStringAsFixed(1), unit: 'MI', valueColor: primaryTextColor, ), const SizedBox(width: 6), _buildMetricCell( context: context, label: 'DURATION', value: _formatDuration(item.durationSeconds), unit: '', valueColor: primaryTextColor, ), ], ), const SizedBox(height: 6), Row( children: [ _buildMetricCell( context: context, label: 'MAX LEAN', value: '${item.maxLeanAngleDeg.toStringAsFixed(0)}°', unit: item.maxLeanSide, valueColor: primaryTextColor, indicatorColor: accentColor, ), const SizedBox(width: 6), _buildMetricCell( context: context, label: 'PACE', value: item.paceFactor.split(' ').first, unit: item.paceFactor.split(' ').length > 1 ? item.paceFactor.split(' ').sublist(1).join(' ') : '', valueColor: primaryTextColor, ), ], ), ], ), ), ), const SizedBox(height: 12), // Visual Snapshot Module (Route Vector Track or Scenic Photo) Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: item.snapshotType == FeedSnapshotType.scenicPhoto ? _buildScenicPhotoModule(context, item) : _buildRouteTrackModule(context, item), ), const SizedBox(height: 12), // Kudos & Action Bar (Fist Bump, Comments, GPX, Inspect) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 14), child: Column( children: [ Row( children: [ // Fist Bump Interactive Button Expanded( flex: 3, child: ScaleTransition( scale: _bumpScaleAnimation, child: InkWell( key: ValueKey('fist_bump_button_${item.id}'), borderRadius: BorderRadius.circular(12), onTap: _handleFistBump, child: AnimatedContainer( duration: const Duration(milliseconds: 180), height: 48, decoration: BoxDecoration( color: isBumped ? bumpedBg : colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), border: Border.all( color: isBumped ? bumpedBg : colors.outlineVariant, ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('👊', style: TextStyle(fontSize: 18)), const SizedBox(width: 8), Flexible( child: Text( '$bumpCount Bumps', overflow: TextOverflow.ellipsis, style: TextStyle( fontFamily: AppTypography.fontHeadline, fontSize: 13, fontWeight: FontWeight.w700, color: isBumped ? bumpedFg : primaryTextColor, ), ), ), ], ), ), ), ), ), const SizedBox(width: 8), // Comments Action Button Expanded( flex: 2, child: InkWell( borderRadius: BorderRadius.circular(12), onTap: () => FeedCommentsBottomSheet.show(context, item), child: Container( height: 48, decoration: BoxDecoration( color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), border: Border.all(color: colors.outlineVariant), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.chat_bubble_outline_rounded, size: 18, color: accentColor, ), const SizedBox(width: 6), Text( '${item.comments.length}', style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 13, fontWeight: FontWeight.w700, color: primaryTextColor, ), ), ], ), ), ), ), const SizedBox(width: 8), // GPX Export Button InkWell( key: ValueKey('gpx_export_button_${item.id}'), borderRadius: BorderRadius.circular(12), onTap: () { _showGpxExportSnackBar(context, colors, accentColor, item); }, child: Container( width: 48, height: 48, decoration: BoxDecoration( color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), border: Border.all(color: colors.outlineVariant), ), child: Icon( Icons.download_rounded, size: 20, color: primaryTextColor, ), ), ), ], ), const SizedBox(height: 8), // Telemetry Deep Dive Inspector Trigger InkWell( key: ValueKey('inspect_telemetry_btn_${item.id}'), borderRadius: BorderRadius.circular(12), onTap: () { FeedTelemetryInspectorModal.show(context, item); }, child: Container( height: 44, width: double.infinity, decoration: BoxDecoration( color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), border: Border.all( color: accentColor.withValues(alpha: 0.35), ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.bolt_rounded, size: 18, color: accentColor, ), const SizedBox(width: 8), Flexible( child: Text( 'INSPECT TELEMETRY GRAPH', overflow: TextOverflow.ellipsis, style: TextStyle( fontFamily: AppTypography.fontHeadline, fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 0.8, color: accentColor, ), ), ), ], ), ), ), ], ), ), ], ), ); } Widget _buildMetricCell({ required BuildContext context, required String label, required String value, required String unit, required Color valueColor, Color? indicatorColor, }) { final colors = context.motorColors; final isLight = Theme.of(context).brightness == Brightness.light; final secondaryTextColor = isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant; return Expanded( child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), decoration: BoxDecoration( color: colors.surfaceContainer, borderRadius: BorderRadius.circular(10), border: Border.all(color: colors.outlineVariant, width: 0.8), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 10.5, fontWeight: FontWeight.w700, letterSpacing: 0.6, color: secondaryTextColor, ), ), ), if (indicatorColor != null) ...[ Container( width: 5, height: 5, decoration: BoxDecoration( color: indicatorColor, shape: BoxShape.circle, ), ), ], ], ), const SizedBox(height: 4), Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ Flexible( child: Text( value, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 15.5, fontWeight: FontWeight.w800, color: valueColor, ), ), ), if (unit.isNotEmpty) ...[ const SizedBox(width: 4), Flexible( child: Text( unit, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 10, fontWeight: FontWeight.w700, color: secondaryTextColor, ), ), ), ], ], ), ], ), ), ); } Widget _buildRouteTrackModule(BuildContext context, SocialFeedItem item) { final colors = context.motorColors; final isLight = Theme.of(context).brightness == Brightness.light; final primaryTextColor = isLight ? AppColors.lightOnSurface : AppColors.darkOnSurface; final secondaryTextColor = isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant; final accentColor = isLight ? AppColors.brandBlueStrong : AppColors.brandBlue; final containerBg = isLight ? AppColors.lightSurfaceContainerHighest : AppColors.darkSurface; final borderColor = colors.outlineVariant; final overlayBg = (isLight ? AppColors.lightSurface : AppColors.darkSurfaceContainer) .withValues(alpha: 0.88); final hasValidTrack = item.hasRecordedRoute; return Container( height: 160, decoration: BoxDecoration( color: containerBg, borderRadius: BorderRadius.circular(14), border: Border.all(color: borderColor), ), child: Stack( children: [ // Route Vector Canvas Painter Positioned.fill( child: ClipRRect( borderRadius: BorderRadius.circular(14), child: CustomPaint( painter: _FeedRoutePainter( points: hasValidTrack ? item.routePoints : const [], maxLean: item.maxLeanAngleDeg, accentColor: accentColor, gridColor: isLight ? colors.outlineVariant : AppColors.darkOutlineVariant, finishColor: secondaryTextColor, ), ), ), ), if (!hasValidTrack) Positioned.fill( child: Center( child: Container( key: ValueKey('feed_route_no_gps_state_${item.id}'), margin: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 6, ), decoration: BoxDecoration( color: overlayBg, borderRadius: BorderRadius.circular(8), border: Border.all( color: borderColor, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.gps_off_rounded, size: 14, color: secondaryTextColor, ), const SizedBox(width: 6), Flexible( child: Text( 'NO GPS TRACK RECORDED', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 10, fontWeight: FontWeight.w700, letterSpacing: 0.5, color: primaryTextColor, ), ), ), ], ), ), ), ), // Elevation Pill Overlay Positioned( bottom: 8, left: 10, child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: overlayBg, borderRadius: BorderRadius.circular(6), border: Border.all( color: accentColor.withValues(alpha: 0.2), ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.landscape_rounded, size: 13, color: accentColor, ), const SizedBox(width: 5), Text( 'ELV +${item.elevationFt} FT', style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 10, fontWeight: FontWeight.w700, color: primaryTextColor, ), ), ], ), ), ), // GPS Verified Badge (strictly requires verified GPS trace criteria) if (item.hasVerifiedGps) ...[ Positioned( top: 8, right: 10, child: Container( padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), decoration: BoxDecoration( color: overlayBg, borderRadius: BorderRadius.circular(6), border: Border.all( color: accentColor.withValues(alpha: 0.2), ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.verified_rounded, size: 12, color: accentColor), const SizedBox(width: 4), Text( 'GPS VERIFIED', style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 9, fontWeight: FontWeight.w800, color: accentColor, ), ), ], ), ), ), ], ], ), ); } Widget _buildScenicPhotoModule(BuildContext context, SocialFeedItem item) { final colors = context.motorColors; final isLight = Theme.of(context).brightness == Brightness.light; final secondaryTextColor = isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant; final accentColor = isLight ? AppColors.brandBlueStrong : AppColors.brandBlue; final containerBg = isLight ? AppColors.lightSurfaceContainerHighest : AppColors.darkSurface; final borderColor = colors.outlineVariant; final overlayBg = (isLight ? AppColors.lightSurface : AppColors.darkSurfaceContainer) .withValues(alpha: 0.88); return Container( height: 180, decoration: BoxDecoration( color: containerBg, borderRadius: BorderRadius.circular(14), border: Border.all(color: borderColor), ), child: ClipRRect( borderRadius: BorderRadius.circular(14), child: Stack( fit: StackFit.expand, children: [ // Atmospheric gradient container mimicking canyon overlook Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: isLight ? [ AppColors.lightSurfaceContainerHigh, AppColors.lightSurfaceContainerHighest, AppColors.lightOutlineVariant, ] : [ AppColors.darkSurfaceContainer, AppColors.darkSurface, AppColors.darkBackground, ], ), ), child: Center( child: Icon( Icons.landscape_rounded, size: 64, color: secondaryTextColor, ), ), ), // Bottom gradient vignette Positioned.fill( child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.transparent, Colors.black.withValues(alpha: 0.75), ], ), ), ), ), // Lookout caption & GPS badge Positioned( left: 10, right: 10, bottom: 10, child: Wrap( alignment: WrapAlignment.spaceBetween, crossAxisAlignment: WrapCrossAlignment.center, spacing: 6, runSpacing: 4, children: [ Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4), decoration: BoxDecoration( color: overlayBg, borderRadius: BorderRadius.circular(6), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.photo_camera_rounded, size: 13, color: accentColor, ), const SizedBox(width: 5), Text( item.photoCaption ?? 'Scenic Overlook', style: TextStyle( fontFamily: AppTypography.fontHeadline, fontSize: 11, fontWeight: FontWeight.w600, color: isLight ? AppColors.lightOnSurface : Colors.white, ), ), ], ), ), if (item.hasVerifiedGps) Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4), decoration: BoxDecoration( color: overlayBg, borderRadius: BorderRadius.circular(6), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.verified_rounded, size: 13, color: accentColor, ), const SizedBox(width: 4), Text( 'GPS VERIFIED', style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 10, fontWeight: FontWeight.w800, color: accentColor, ), ), ], ), ), ], ), ), ], ), ), ); } } /// Custom vector route painter for the community social feed route preview. /// Plots recorded [points] normalized to canvas bounds. Does not fabricate /// synthetic curves when fewer than 2 points exist. class _FeedRoutePainter extends CustomPainter { final List points; final double maxLean; final Color accentColor; final Color gridColor; final Color finishColor; const _FeedRoutePainter({ required this.points, required this.maxLean, required this.accentColor, required this.gridColor, required this.finishColor, }); @override void paint(Canvas canvas, Size size) { final w = size.width; final h = size.height; // 1. Grid lines matrix overlay final gridPaint = Paint() ..color = gridColor ..strokeWidth = 0.8; const gridSize = 24.0; for (double x = 0; x < w; x += gridSize) { canvas.drawLine(Offset(x, 0), Offset(x, h), gridPaint); } for (double y = 0; y < h; y += gridSize) { canvas.drawLine(Offset(0, y), Offset(w, y), gridPaint); } if (points.length < 2) { return; } // 2. Normalize recorded GPS points into canvas coordinates double minLat = points.first.latitude; double maxLat = points.first.latitude; double minLng = points.first.longitude; double maxLng = points.first.longitude; for (final pt in points) { if (pt.latitude < minLat) minLat = pt.latitude; if (pt.latitude > maxLat) maxLat = pt.latitude; if (pt.longitude < minLng) minLng = pt.longitude; if (pt.longitude > maxLng) maxLng = pt.longitude; } const padX = 28.0; const padY = 26.0; final usableW = (w - padX * 2).clamp(1.0, w); final usableH = (h - padY * 2).clamp(1.0, h); final latSpan = (maxLat - minLat).abs(); final lngSpan = (maxLng - minLng).abs(); final mappedPoints = []; for (var i = 0; i < points.length; i++) { final pt = points[i]; final normX = lngSpan < 1e-6 ? (i / (points.length - 1)) : (pt.longitude - minLng) / lngSpan; final normY = latSpan < 1e-6 ? 0.5 : 1.0 - ((pt.latitude - minLat) / latSpan); mappedPoints.add( Offset(padX + normX * usableW, padY + normY * usableH), ); } final path = Path()..moveTo(mappedPoints.first.dx, mappedPoints.first.dy); for (var i = 1; i < mappedPoints.length; i++) { path.lineTo(mappedPoints[i].dx, mappedPoints[i].dy); } // Glow blur final glowPaint = Paint() ..color = accentColor.withValues(alpha: 0.25) ..strokeWidth = 8 ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round; canvas.drawPath(path, glowPaint); // Active trail final trailPaint = Paint() ..color = accentColor ..strokeWidth = 3.5 ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round; canvas.drawPath(path, trailPaint); final startOffset = mappedPoints.first; final finishOffset = mappedPoints.last; final apexOffset = mappedPoints[mappedPoints.length ~/ 2]; // Start marker with ring final startPaint = Paint()..color = accentColor; canvas.drawCircle(startOffset, 4.5, startPaint); final ringPaint = Paint() ..color = accentColor.withValues(alpha: 0.6) ..style = PaintingStyle.stroke ..strokeWidth = 1.5; canvas.drawCircle(startOffset, 8.0, ringPaint); // Apex marker (only if maxLean > 0) final textPainter = TextPainter(textDirection: TextDirection.ltr); if (maxLean > 0) { final apexPaint = Paint()..color = accentColor; canvas.drawCircle(apexOffset, 4.0, apexPaint); textPainter.text = TextSpan( text: '${maxLean.toStringAsFixed(0)}° APEX', style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 9, fontWeight: FontWeight.w800, color: accentColor, ), ); textPainter.layout(); textPainter.paint( canvas, Offset( (apexOffset.dx - textPainter.width / 2).clamp(4.0, w - textPainter.width - 4.0), (apexOffset.dy - 16).clamp(4.0, h - textPainter.height - 4.0), ), ); } // Finish marker final finishPaint = Paint()..color = finishColor; canvas.drawCircle(finishOffset, 4.0, finishPaint); textPainter.text = TextSpan( text: 'FINISH', style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 9, fontWeight: FontWeight.w700, color: finishColor, ), ); textPainter.layout(); textPainter.paint( canvas, Offset( (finishOffset.dx - textPainter.width / 2).clamp(4.0, w - textPainter.width - 4.0), (finishOffset.dy + 8).clamp(4.0, h - textPainter.height - 4.0), ), ); } @override bool shouldRepaint(covariant _FeedRoutePainter oldDelegate) { return oldDelegate.maxLean != maxLean || oldDelegate.points != points || oldDelegate.accentColor != accentColor || oldDelegate.gridColor != gridColor || oldDelegate.finishColor != finishColor; } }