From b005bb00ae30e06622f4df045e7fe25e9c1d4790 Mon Sep 17 00:00:00 2001 From: Billie Ponder Date: Sat, 17 Oct 2026 14:35:33 -0400 Subject: [PATCH] fix(android): resolve BUG-001 through BUG-005 (#78) * fix(dashboard): ensure Touring map follows rider and supports recenter (#48) * fix(social): make shared session telemetry read-only and authoritative (#50) * fix(social): use recorded telemetry for feed charts and verify GPS badge (#51) * fix(social): standardize community feed typography and theme colors (#49) * fix(social): prevent horizontal overflow in GPX export snackbar (#52) - Wrap GPX export snackbar message Text in Expanded with maxLines: 2 and TextOverflow.ellipsis - Add ValueKey('gpx_export_button_') for deterministic testing - Wrap narrow feed header/metric/empty-state Row labels in Flexible to prevent overflow under 320px/360px at 1.5x text scale - Add BUG-005 widget tests across 320x640, 360x800, and 800x480 viewports at 1.5x text scale * fix(social): harden share modal, inspector theming, and PR #78 accuracy (#49, #50, #51, #52) - Require a selected recorded Ride in ShareSessionModal before enabling publish - Theme ShareSessionModal and FeedTelemetryInspectorModal for Light and Dark modes - Use LayoutBuilder width for FeedTelemetryInspectorModal scrubber drag/tap - Route PopupMenuButton export_gpx action through the overflow-safe GPX snackbar - Enforce telemetrySamples >= 2 when provided to verifyGpsCriteria * fix(android): remediate map follow smoothness, orientation controls, starting location, and landscape overflow (#79, #80, #81, #52) * fix(android): stop Pro upsell sheet overflowing in landscape (#52 / BUG-005) The "Forensic CSV & PDF Export" Pro upsell sheet showed "BOTTOM OVERFLOWED BY 90 PIXELS" in landscape on a Pixel 8a. showProUpgradeSheet built a non-scrolling Column inside a default modal bottom sheet, which Flutter caps at 9/16 of the screen height (about 230 dp in landscape) - shorter than the header, description and two actions. Use isScrollControlled and wrap the content in a SingleChildScrollView so the sheet can take the full height and never overflows. This also covers every other caller (garage, add motorcycle, ride detail). Tests: pro_upgrade_sheet_landscape_test covers 914x411 (Pixel 8a), 800x360 and 780x360 at 1.0x and 1.3x text scale plus NOT NOW dismissal. Verified to fail on the old code (RenderFlex overflowed 64-144 px) and pass on the fix. Full suite 1700 passed, flutter analyze clean. Not validated on the physical device yet. Co-Authored-By: Claude Sonnet 5.5 * fix(social): require telemetry evidence, geodesic GPS threshold, and 3 MPH lean gate (#50, #51) --------- Co-authored-by: Developer Co-authored-by: Claude Sonnet 5.5 --- .../widgets/dashboard_map_panel.dart | 181 ++- .../entitlements/widgets/pro_lock_badge.dart | 6 +- lib/features/map/map_screen.dart | 14 +- .../social/feed/social_feed_screen.dart | 877 +++++++------ .../widgets/feed_comments_bottom_sheet.dart | 97 +- .../feed_telemetry_inspector_modal.dart | 499 +++++--- .../feed/widgets/share_session_modal.dart | 719 +++++++---- lib/services/map/aws_map_provider.dart | 127 +- lib/services/map/google_map_provider.dart | 137 ++- lib/services/map/map_providers.dart | 66 + .../map/open_street_map_provider.dart | 127 +- .../routes/aws_location_route_provider.dart | 60 + lib/services/social/social_feed_models.dart | 294 ++++- lib/services/social/social_feed_service.dart | 89 +- test/canonical_dashboard_responsive_test.dart | 195 +++ test/community_social_feed_test.dart | 1092 ++++++++++++++++- test/map_smoothness_and_orientation_test.dart | 63 + test/pro_upgrade_sheet_landscape_test.dart | 101 ++ 18 files changed, 3796 insertions(+), 948 deletions(-) create mode 100644 test/map_smoothness_and_orientation_test.dart create mode 100644 test/pro_upgrade_sheet_landscape_test.dart diff --git a/lib/features/dashboard/widgets/dashboard_map_panel.dart b/lib/features/dashboard/widgets/dashboard_map_panel.dart index 74af053..b3e21eb 100644 --- a/lib/features/dashboard/widgets/dashboard_map_panel.dart +++ b/lib/features/dashboard/widgets/dashboard_map_panel.dart @@ -5,15 +5,18 @@ import '../../../core/theme/app_typography.dart'; import '../../../services/alerts/road_alert_models.dart'; import '../../../services/alerts/road_alert_service.dart'; import '../../../services/map/map_provider_interface.dart'; +import '../../../services/map/map_providers.dart'; import '../../../services/map/unified_map_view.dart'; import '../../../services/navigation/navigation_session_service.dart'; import '../../../services/telemetry_service.dart'; +import '../../rides/ride_state_machine.dart'; /// Embedded tactical map panel for Touring mode and responsive dashboard views. /// /// Subscribes to vehicle coordinates and heading via [telemetryStreamProvider] -/// and alert pins via [activeAlertsStreamProvider]. -class DashboardMapPanel extends ConsumerWidget { +/// and alert pins via [activeAlertsStreamProvider], maintains camera follow +/// state, and provides a recenter control after manual pan. +class DashboardMapPanel extends ConsumerStatefulWidget { final double zoom; const DashboardMapPanel({ @@ -22,7 +25,43 @@ class DashboardMapPanel extends ConsumerWidget { }); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _DashboardMapPanelState(); +} + +class _DashboardMapPanelState extends ConsumerState { + static const AppLatLng _defaultFallbackCenter = AppLatLng(37.7749, -122.4194); + AppLatLng? _lastKnownRiderPos; + + @override + void initState() { + super.initState(); + // Ensure follow mode is active whenever the Touring map panel is mounted. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + ref.read(mapFollowStateProvider.notifier).recenter(); + } + }); + } + + bool _isValidCoord(double? lat, double? lng) { + return lat != null && + lng != null && + lat.isFinite && + lng.isFinite && + (lat != 0.0 || lng != 0.0); + } + + @override + Widget build(BuildContext context) { + // Re-enable camera follow automatically when a ride session starts. + ref.listen(rideStateMachineProvider, (previous, next) { + if (previous?.state != RideState.riding && + next.state == RideState.riding && + mounted) { + ref.read(mapFollowStateProvider.notifier).recenter(); + } + }); + final vehicleCoords = ref.watch( telemetryStreamProvider.select( (s) => ( @@ -34,20 +73,22 @@ class DashboardMapPanel extends ConsumerWidget { ), ); - final centerLat = (vehicleCoords.lat != null && vehicleCoords.lat != 0.0) - ? vehicleCoords.lat! - : 37.7749; - final centerLng = (vehicleCoords.lng != null && vehicleCoords.lng != 0.0) - ? vehicleCoords.lng! - : -122.4194; + final hasCoordinates = _isValidCoord(vehicleCoords.lat, vehicleCoords.lng); + if (hasCoordinates) { + _lastKnownRiderPos = AppLatLng(vehicleCoords.lat!, vehicleCoords.lng!); + } + final activeCenter = _lastKnownRiderPos ?? _defaultFallbackCenter; final isFresh = vehicleCoords.isGpsFresh; - final hasCoordinates = vehicleCoords.lat != null && vehicleCoords.lat != 0.0; + final isFollowMode = ref.watch(mapFollowStateProvider); + final orientation = ref.watch(mapOrientationProvider); + final isNorthUp = orientation == MapOrientation.northUp; + final effectiveBearing = isNorthUp ? 0.0 : vehicleCoords.heading; final vehicleMarker = AppMapMarker( id: 'dash_vehicle', - position: AppLatLng(centerLat, centerLng), - heading: vehicleCoords.heading, + position: activeCenter, + heading: isNorthUp ? vehicleCoords.heading : 0.0, iconWidget: Icon( Icons.navigation, color: isFresh @@ -64,7 +105,7 @@ class DashboardMapPanel extends ConsumerWidget { for (final alert in activeAlerts) { mapMarkers.add( AppMapMarker( - id: 'dash_alert__', + id: 'dash_alert_${alert.id}', position: AppLatLng(alert.latitude, alert.longitude), heading: alert.heading ?? 0.0, label: alert.category.displayName, @@ -116,14 +157,69 @@ class DashboardMapPanel extends ConsumerWidget { Positioned.fill( child: UnifiedMapView( key: const ValueKey('dash_map_x'), - center: AppLatLng(centerLat, centerLng), - zoom: zoom, - bearing: 0.0, + center: activeCenter, + zoom: widget.zoom, + bearing: effectiveBearing, markers: mapMarkers, polylines: polylines, ), ), - if (!isFresh && hasCoordinates) + Positioned( + top: 10, + right: 10, + child: Material( + color: Colors.black.withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(20), + child: InkWell( + key: const ValueKey('dashboard_map_orientation_button'), + borderRadius: BorderRadius.circular(20), + onTap: () { + ref.read(mapOrientationProvider.notifier).toggle(); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isNorthUp + ? Colors.white24 + : AppColors.cyberCyan, + width: 1.2, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isNorthUp ? Icons.explore : Icons.navigation, + size: 14, + color: isNorthUp + ? Colors.white70 + : AppColors.cyberCyan, + ), + const SizedBox(width: 5), + Text( + isNorthUp ? 'NORTH UP' : 'HEADING UP', + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 10, + fontWeight: FontWeight.w800, + letterSpacing: 0.6, + color: isNorthUp + ? Colors.white70 + : AppColors.cyberCyan, + ), + ), + ], + ), + ), + ), + ), + ), + if (!isFresh && (hasCoordinates || _lastKnownRiderPos != null)) Positioned( top: 10, left: 10, @@ -154,6 +250,56 @@ class DashboardMapPanel extends ConsumerWidget { ), ), ), + if (!isFollowMode) + Positioned( + bottom: 10, + right: 10, + child: Material( + color: Colors.black.withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(20), + child: InkWell( + key: const ValueKey('dashboard_map_recenter_button'), + borderRadius: BorderRadius.circular(20), + onTap: () { + ref.read(mapFollowStateProvider.notifier).recenter(); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: AppColors.cyberCyan, + width: 1.2, + ), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.my_location, + size: 14, + color: AppColors.cyberCyan, + ), + SizedBox(width: 5), + Text( + 'RECENTER', + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 10, + fontWeight: FontWeight.w800, + letterSpacing: 0.6, + color: AppColors.cyberCyan, + ), + ), + ], + ), + ), + ), + ), + ), ], ), ); @@ -162,3 +308,4 @@ class DashboardMapPanel extends ConsumerWidget { ); } } + diff --git a/lib/features/entitlements/widgets/pro_lock_badge.dart b/lib/features/entitlements/widgets/pro_lock_badge.dart index 36dde19..e40e237 100644 --- a/lib/features/entitlements/widgets/pro_lock_badge.dart +++ b/lib/features/entitlements/widgets/pro_lock_badge.dart @@ -168,13 +168,17 @@ Future showProUpgradeSheet( await showModalBottomSheet( context: context, + // A default modal sheet is capped at 9/16 of the screen height, which in landscape + // (~230 dp on a phone) is shorter than this content. Allow the full height and make + // the content scrollable so it can never overflow (Issue #52 / BUG-005). + isScrollControlled: true, backgroundColor: const Color(0xFF0F0F0F), shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (ctx) { return SafeArea( - child: Padding( + child: SingleChildScrollView( padding: const EdgeInsets.all(20.0), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/features/map/map_screen.dart b/lib/features/map/map_screen.dart index 154bdc4..86e50ed 100644 --- a/lib/features/map/map_screen.dart +++ b/lib/features/map/map_screen.dart @@ -48,7 +48,6 @@ class _MapScreenState extends ConsumerState { bool _showCornering = true; double _currentZoom = 17.0; - bool _isNorthUp = true; String _destinationQuery = ''; late final TextEditingController _searchController; Timer? _searchDebounce; @@ -907,6 +906,7 @@ class _MapScreenState extends ConsumerState { final isFollowMode = ref.watch(mapFollowStateProvider); final layerSettings = ref.watch(mapLayerSettingsProvider); + final isNorthUp = ref.watch(mapOrientationProvider) == MapOrientation.northUp; final colors = context.motorColors; final theme = Theme.of(context); @@ -1084,7 +1084,7 @@ class _MapScreenState extends ConsumerState { final motorcycleMarker = AppMapMarker( id: 'motorcycle_live', position: currentPos, - heading: _isNorthUp ? currentHeading : 0.0, + heading: isNorthUp ? currentHeading : 0.0, iconWidget: Icon( Icons.navigation, color: colors.cyberCyan, @@ -1189,7 +1189,7 @@ class _MapScreenState extends ConsumerState { key: const ValueKey('live_map_view'), center: currentPos, zoom: _currentZoom, - bearing: _isNorthUp ? 0.0 : currentHeading, + bearing: isNorthUp ? 0.0 : currentHeading, markers: mapMarkers, polylines: livePolylines, ), @@ -1280,10 +1280,10 @@ class _MapScreenState extends ConsumerState { const SizedBox(height: 8), _HudIconButton( key: const ValueKey('compass_orient_button'), - icon: _isNorthUp ? Icons.explore : Icons.navigation, - tooltip: _isNorthUp ? 'North Up' : 'Heading Up', - color: _isNorthUp ? Colors.white70 : colors.cyberCyan, - onTap: () => setState(() => _isNorthUp = !_isNorthUp), + icon: isNorthUp ? Icons.explore : Icons.navigation, + tooltip: isNorthUp ? 'North Up' : 'Heading Up', + color: isNorthUp ? Colors.white70 : colors.cyberCyan, + onTap: () => ref.read(mapOrientationProvider.notifier).toggle(), ), const SizedBox(height: 8), _HudIconButton( diff --git a/lib/features/social/feed/social_feed_screen.dart b/lib/features/social/feed/social_feed_screen.dart index d36d801..180c158 100644 --- a/lib/features/social/feed/social_feed_screen.dart +++ b/lib/features/social/feed/social_feed_screen.dart @@ -120,20 +120,26 @@ class _SocialFeedScreenState extends ConsumerState { ), ); + final isLight = Theme.of(context).brightness == Brightness.light; + final secondaryTextColor = + isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant; + final accentColor = + isLight ? AppColors.brandBlueStrong : colors.brandCyanBright; + if (!widget.showAppBar) { return Container( - color: Theme.of(context).colorScheme.surface, + color: colors.surfaceCanvas, child: content, ); } return Scaffold( - backgroundColor: Theme.of(context).colorScheme.surface, + backgroundColor: colors.surfaceCanvas, appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.surface, + backgroundColor: colors.surfaceCanvas, elevation: 0, leading: IconButton( - icon: const Icon(Icons.arrow_back_rounded, color: Colors.white), + icon: Icon(Icons.arrow_back_rounded, color: colors.textPrimary), onPressed: () { if (context.canPop()) { context.pop(); @@ -147,12 +153,12 @@ class _SocialFeedScreenState extends ConsumerState { Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( - color: AppColors.electricCyan.withValues(alpha: 0.15), + color: accentColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(8), ), - child: const Icon( + child: Icon( Icons.dynamic_feed_rounded, - color: AppColors.electricCyan, + color: accentColor, size: 20, ), ), @@ -162,7 +168,7 @@ class _SocialFeedScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - const Text( + Text( 'Community Feed', overflow: TextOverflow.ellipsis, style: TextStyle( @@ -170,7 +176,7 @@ class _SocialFeedScreenState extends ConsumerState { fontSize: 16, fontWeight: FontWeight.w700, letterSpacing: 0.2, - color: Colors.white, + color: colors.textPrimary, ), ), Text( @@ -181,7 +187,7 @@ class _SocialFeedScreenState extends ConsumerState { fontSize: 9, fontWeight: FontWeight.w700, letterSpacing: 0.8, - color: const Color(0xFF94A3B8), + color: secondaryTextColor, ), ), ], @@ -193,9 +199,7 @@ class _SocialFeedScreenState extends ConsumerState { IconButton( icon: Icon( _isSearchExpanded ? Icons.search_off_rounded : Icons.search_rounded, - color: _isSearchExpanded - ? AppColors.electricCyan - : const Color(0xFF94A3B8), + color: _isSearchExpanded ? accentColor : secondaryTextColor, ), tooltip: 'Search Routes', onPressed: () { @@ -209,7 +213,7 @@ class _SocialFeedScreenState extends ConsumerState { }, ), IconButton( - icon: const Icon(Icons.refresh_rounded, color: Color(0xFF94A3B8)), + icon: Icon(Icons.refresh_rounded, color: secondaryTextColor), tooltip: 'Refresh Feed', onPressed: () { ref.invalidate(socialFeedNotifierProvider); @@ -230,9 +234,15 @@ class _SocialFeedScreenState extends ConsumerState { String searchQuery, MotorSignalThemeColors colors, ) { + final isLight = Theme.of(context).brightness == Brightness.light; + final secondaryTextColor = + isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant; + final accentColor = + isLight ? AppColors.brandBlueStrong : colors.brandCyanBright; + return Container( padding: const EdgeInsets.fromLTRB(16, 10, 16, 8), - color: Theme.of(context).colorScheme.surface, + color: colors.surfaceCanvas, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -242,51 +252,44 @@ class _SocialFeedScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.center, children: [ // Status LED pill - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: Theme.of(context).brightness == Brightness.light - ? const Color(0xFFE0F7FA) - : const Color(0xFF161B22), - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: AppColors.electricCyan.withValues( - alpha: Theme.of(context).brightness == Brightness.light ? 0.6 : 0.25, + Flexible( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: colors.surfaceCardSecondary, + 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: const BoxDecoration( - color: AppColors.electricCyan, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: AppColors.electricCyan, - blurRadius: 6, - spreadRadius: 1, + 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 & CANYON FEED', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + color: accentColor, ), - ], + ), ), - ), - const SizedBox(width: 8), - Text( - 'SQUAD & CANYON FEED', - style: TextStyle( - fontFamily: AppTypography.fontTelemetry, - fontSize: 11, - fontWeight: FontWeight.w700, - letterSpacing: 0.6, - color: Theme.of(context).brightness == Brightness.light - ? const Color(0xFF006064) - : const Color(0xFF9CF0FF), - ), - ), - ], + ], + ), ), ), @@ -295,7 +298,7 @@ class _SocialFeedScreenState extends ConsumerState { key: const ValueKey('feed_search_toggle_button'), icon: Icon( _isSearchExpanded ? Icons.close_rounded : Icons.search_rounded, - color: AppColors.electricCyan, + color: accentColor, size: 20, ), tooltip: 'Search Feed', @@ -318,36 +321,36 @@ class _SocialFeedScreenState extends ConsumerState { Container( height: 42, decoration: BoxDecoration( - color: const Color(0xFF161B22), + color: colors.surfaceCardPrimary, borderRadius: BorderRadius.circular(10), border: Border.all( - color: AppColors.electricCyan.withValues(alpha: 0.3), + color: accentColor.withValues(alpha: 0.4), ), ), child: TextField( controller: _searchController, autofocus: true, - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontBody, fontSize: 13, - color: Colors.white, + color: colors.textPrimary, ), decoration: InputDecoration( hintText: 'Search rider, route, bike, or region...', - hintStyle: const TextStyle( + hintStyle: TextStyle( fontFamily: AppTypography.fontBody, fontSize: 13, - color: Color(0xFF64748B), + color: secondaryTextColor, ), - prefixIcon: const Icon( + prefixIcon: Icon( Icons.search_rounded, size: 18, - color: Color(0xFF94A3B8), + color: secondaryTextColor, ), suffixIcon: _searchController.text.isNotEmpty ? IconButton( icon: const Icon(Icons.close_rounded, size: 16), - color: const Color(0xFF94A3B8), + color: secondaryTextColor, onPressed: () { _searchController.clear(); ref.read(feedSearchQueryProvider.notifier).state = ''; @@ -406,7 +409,7 @@ class _SocialFeedScreenState extends ConsumerState { Container( width: 1, height: 20, - color: colors.outlineVariant.withValues(alpha: 0.4), + color: colors.surfaceBorder, ), const SizedBox(width: 8), _buildDisciplineChip( @@ -453,18 +456,18 @@ class _SocialFeedScreenState extends ConsumerState { const SizedBox(height: 6), Row( children: [ - const Icon( + Icon( Icons.filter_alt_outlined, size: 13, - color: AppColors.electricCyan, + color: accentColor, ), const SizedBox(width: 4), Text( 'Filtered by: ${activeDiscipline != RidingDiscipline.all ? activeDiscipline.displayName : ''}${activeDiscipline != RidingDiscipline.all && searchQuery.isNotEmpty ? ', ' : ''}${searchQuery.isNotEmpty ? '"$searchQuery"' : ''}', - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontBody, fontSize: 11, - color: Color(0xFF94A3B8), + color: secondaryTextColor, ), ), const Spacer(), @@ -478,13 +481,13 @@ class _SocialFeedScreenState extends ConsumerState { _isSearchExpanded = false; }); }, - child: const Text( + child: Text( 'Reset', style: TextStyle( fontFamily: AppTypography.fontHeadline, fontSize: 11, fontWeight: FontWeight.w700, - color: AppColors.electricCyan, + color: accentColor, ), ), ), @@ -501,6 +504,13 @@ class _SocialFeedScreenState extends ConsumerState { required String title, required bool isSelected, }) { + final colors = context.motorColors; + final isLight = Theme.of(context).brightness == Brightness.light; + final selectedBg = isLight ? AppColors.brandBlueStrong : AppColors.electricCyan; + final selectedFg = isLight ? AppColors.onBrand : const Color(0xFF00272E); + final unselectedFg = + isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant; + return InkWell( borderRadius: BorderRadius.circular(10), onTap: () { @@ -510,33 +520,23 @@ class _SocialFeedScreenState extends ConsumerState { duration: const Duration(milliseconds: 180), curve: Curves.easeInOut, height: 36, - padding: const EdgeInsets.symmetric(horizontal: 8), + padding: const EdgeInsets.symmetric(horizontal: 10), alignment: Alignment.center, decoration: BoxDecoration( - color: isSelected ? AppColors.electricCyan : Colors.transparent, + color: isSelected ? selectedBg : colors.surfaceCardSecondary, borderRadius: BorderRadius.circular(10), - boxShadow: isSelected - ? [ - BoxShadow( - color: AppColors.electricCyan.withValues(alpha: 0.35), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ] - : null, + border: Border.all( + color: isSelected ? selectedBg : colors.surfaceBorder, + ), ), child: Text( title, style: TextStyle( fontFamily: AppTypography.fontHeadline, fontSize: 11.5, - fontWeight: FontWeight.w700, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600, letterSpacing: 0.1, - color: isSelected - ? const Color(0xFF00363D) - : (Theme.of(context).brightness == Brightness.light - ? const Color(0xFF334155) - : const Color(0xFFCBD5E1)), + color: isSelected ? selectedFg : unselectedFg, ), ), ), @@ -548,8 +548,12 @@ class _SocialFeedScreenState extends ConsumerState { required String label, required bool isSelected, }) { - final chipColor = _getDisciplineColor(discipline); + final colors = context.motorColors; final isLight = Theme.of(context).brightness == Brightness.light; + final selectedBg = isLight ? AppColors.brandBlueStrong : AppColors.electricCyan; + final selectedFg = isLight ? AppColors.onBrand : const Color(0xFF00272E); + final unselectedFg = + isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant; return InkWell( borderRadius: BorderRadius.circular(8), @@ -558,16 +562,14 @@ class _SocialFeedScreenState extends ConsumerState { }, child: AnimatedContainer( duration: const Duration(milliseconds: 160), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + height: 36, + padding: const EdgeInsets.symmetric(horizontal: 10), + alignment: Alignment.center, decoration: BoxDecoration( - color: isSelected - ? chipColor.withValues(alpha: 0.22) - : (isLight ? const Color(0xFFF1F5F9) : const Color(0xFF161B22)), + color: isSelected ? selectedBg : colors.surfaceCardSecondary, borderRadius: BorderRadius.circular(8), border: Border.all( - color: isSelected - ? chipColor.withValues(alpha: 0.7) - : (isLight ? const Color(0xFFCBD5E1) : const Color(0xFF263238)), + color: isSelected ? selectedBg : colors.surfaceBorder, width: isSelected ? 1.2 : 1.0, ), ), @@ -576,10 +578,8 @@ class _SocialFeedScreenState extends ConsumerState { style: TextStyle( fontFamily: AppTypography.fontHeadline, fontSize: 11.5, - fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, - color: isSelected - ? (isLight ? const Color(0xFF0F172A) : Colors.white) - : (isLight ? const Color(0xFF475569) : const Color(0xFF94A3B8)), + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600, + color: isSelected ? selectedFg : unselectedFg, ), ), ), @@ -595,18 +595,34 @@ class _SocialFeedScreenState extends ConsumerState { ) { final hasCustomFilters = activeDiscipline != RidingDiscipline.all || searchQuery.isNotEmpty; + final isLight = Theme.of(context).brightness == Brightness.light; + final secondaryTextColor = + isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant; + final accentColor = + isLight ? AppColors.brandBlueStrong : colors.brandCyanBright; - return Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.dynamic_feed_outlined, - size: 56, - color: const Color(0xFF64748B), + 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 @@ -616,7 +632,7 @@ class _SocialFeedScreenState extends ConsumerState { fontFamily: AppTypography.fontHeadline, fontSize: 16, fontWeight: FontWeight.w700, - color: Theme.of(context).colorScheme.onSurface, + color: colors.textPrimary, ), ), const SizedBox(height: 6), @@ -628,7 +644,7 @@ class _SocialFeedScreenState extends ConsumerState { style: TextStyle( fontFamily: AppTypography.fontBody, fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: secondaryTextColor, ), ), const SizedBox(height: 16), @@ -642,9 +658,9 @@ class _SocialFeedScreenState extends ConsumerState { icon: const Icon(Icons.two_wheeler_rounded, size: 16), label: const Text('Group Rides'), style: OutlinedButton.styleFrom( - foregroundColor: AppColors.electricCyan, + foregroundColor: accentColor, side: BorderSide( - color: AppColors.electricCyan.withValues(alpha: 0.5), + color: accentColor.withValues(alpha: 0.5), ), ), onPressed: () => context.push('/group-rides'), @@ -654,9 +670,9 @@ class _SocialFeedScreenState extends ConsumerState { icon: const Icon(Icons.groups_rounded, size: 16), label: const Text('Clubs'), style: OutlinedButton.styleFrom( - foregroundColor: AppColors.electricCyan, + foregroundColor: accentColor, side: BorderSide( - color: AppColors.electricCyan.withValues(alpha: 0.5), + color: accentColor.withValues(alpha: 0.5), ), ), onPressed: () => context.push('/social/clubs'), @@ -666,9 +682,9 @@ class _SocialFeedScreenState extends ConsumerState { icon: const Icon(Icons.person_add_rounded, size: 16), label: const Text('Friends'), style: OutlinedButton.styleFrom( - foregroundColor: AppColors.electricCyan, + foregroundColor: accentColor, side: BorderSide( - color: AppColors.electricCyan.withValues(alpha: 0.5), + color: accentColor.withValues(alpha: 0.5), ), ), onPressed: () => context.push('/social/friends'), @@ -681,9 +697,9 @@ class _SocialFeedScreenState extends ConsumerState { icon: const Icon(Icons.refresh_rounded, size: 16), label: const Text('Reset All Filters'), style: OutlinedButton.styleFrom( - foregroundColor: AppColors.electricCyan, + foregroundColor: accentColor, side: BorderSide( - color: AppColors.electricCyan.withValues(alpha: 0.5), + color: accentColor.withValues(alpha: 0.5), ), ), onPressed: () { @@ -700,20 +716,26 @@ class _SocialFeedScreenState extends ConsumerState { ], ), ), + ), + ), +); + }, ); } Widget _buildStickyShareButton(BuildContext context) { + final isLight = Theme.of(context).brightness == Brightness.light; + final buttonBg = isLight ? AppColors.brandBlueStrong : AppColors.electricCyan; + final buttonFg = isLight ? AppColors.onBrand : const Color(0xFF00272E); + return Container( height: 52, decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), - gradient: const LinearGradient( - colors: [Color(0xFF00E5FF), Color(0xFF00B0FF)], - ), + color: buttonBg, boxShadow: [ BoxShadow( - color: const Color(0xFF00E5FF).withValues(alpha: 0.35), + color: buttonBg.withValues(alpha: 0.3), blurRadius: 16, offset: const Offset(0, 4), ), @@ -724,7 +746,7 @@ class _SocialFeedScreenState extends ConsumerState { style: ElevatedButton.styleFrom( backgroundColor: Colors.transparent, shadowColor: Colors.transparent, - foregroundColor: const Color(0xFF00272E), + foregroundColor: buttonFg, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), @@ -755,18 +777,34 @@ class _SocialFeedScreenState extends ConsumerState { } } -Color _getDisciplineColor(RidingDiscipline discipline) { +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 const Color(0xFFF59E0B); + return AppColors.hazardAmber; case RidingDiscipline.advDualSport: - return const Color(0xFF10B981); + return AppColors.telemetryGreen; case RidingDiscipline.touring: - return const Color(0xFF38BDF8); + return AppColors.electricCyan; case RidingDiscipline.trackDay: - return const Color(0xFFF43F5E); + return AppColors.semanticDangerDark; case RidingDiscipline.cruiser: - return const Color(0xFFA855F7); + return AppColors.electricCyan; case RidingDiscipline.all: return AppColors.electricCyan; } @@ -861,8 +899,67 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> ); } + void _showGpxExportSnackBar( + BuildContext context, + MotorSignalThemeColors colors, + Color accentColor, + SocialFeedItem item, + ) { + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: colors.surfaceCardSecondary, + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.fromLTRB( + 16, + 0, + 16, + isLandscape ? 8 : 72, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: colors.surfaceBorder), + ), + content: Row( + children: [ + Icon( + Icons.download_done_rounded, + color: accentColor, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'GPX track exported for ${item.rideTitle}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: AppTypography.fontBody, + color: colors.textPrimary, + ), + ), + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { + final colors = context.motorColors; + final isLight = Theme.of(context).brightness == Brightness.light; + final secondaryTextColor = + isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant; + final accentColor = + isLight ? AppColors.brandBlueStrong : colors.brandCyanBright; + final demoColor = + isLight ? AppColors.semanticWarningLight : AppColors.hazardAmber; + final disciplineColor = + _getDisciplineColor(widget.item.discipline, isLight: isLight); + final bumpedBg = isLight ? AppColors.brandBlueStrong : AppColors.electricCyan; + final bumpedFg = isLight ? AppColors.onBrand : const Color(0xFF00272E); + final item = widget.item; final user = ref.watch(currentUserProvider); final isBumped = @@ -871,14 +968,14 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> return Container( decoration: BoxDecoration( - color: const Color(0xFF161B22), + color: colors.surfaceCardPrimary, borderRadius: BorderRadius.circular(18), - border: Border.all(color: const Color(0xFF263238)), - boxShadow: const [ + border: Border.all(color: colors.surfaceBorder), + boxShadow: [ BoxShadow( - color: Colors.black45, + color: colors.glowShadow, blurRadius: 14, - offset: Offset(0, 6), + offset: const Offset(0, 6), ), ], ), @@ -896,10 +993,10 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> children: [ CircleAvatar( radius: 22, - backgroundColor: const Color(0xFF222936), - child: const Icon( + backgroundColor: colors.surfaceCardSecondary, + child: Icon( Icons.sports_motorsports_rounded, - color: AppColors.electricCyan, + color: accentColor, size: 26, ), ), @@ -910,16 +1007,12 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> width: 11, height: 11, decoration: BoxDecoration( - color: AppColors.electricCyan, + color: accentColor, shape: BoxShape.circle, border: Border.all( - color: const Color(0xFF161B22), width: 1.5), - boxShadow: const [ - BoxShadow( - color: AppColors.electricCyan, - blurRadius: 4, - ), - ], + color: colors.surfaceCardPrimary, + width: 1.5, + ), ), ), ), @@ -939,11 +1032,11 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> children: [ Text( '@${item.authorHandle}', - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontHeadline, fontSize: 15, fontWeight: FontWeight.w700, - color: Color(0xFFF1F5F9), + color: colors.textPrimary, ), ), if (item.isDemo) ...[ @@ -952,20 +1045,20 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: const Color(0xFFF59E0B).withValues(alpha: 0.2), + color: demoColor.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(4), border: Border.all( - color: const Color(0xFFF59E0B).withValues(alpha: 0.4), + color: demoColor.withValues(alpha: 0.4), ), ), - child: const Text( + child: Text( 'DEMO SAMPLE', style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 8.5, fontWeight: FontWeight.w800, letterSpacing: 0.5, - color: Color(0xFFF59E0B), + color: demoColor, ), ), ), @@ -975,18 +1068,17 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: AppColors.electricCyan - .withValues(alpha: 0.18), + color: accentColor.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(4), ), child: Text( item.authorBadge!, - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 9, fontWeight: FontWeight.w800, letterSpacing: 0.6, - color: AppColors.electricCyan, + color: accentColor, ), ), ), @@ -996,12 +1088,10 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: _getDisciplineColor(item.discipline) - .withValues(alpha: 0.18), + color: disciplineColor.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(4), border: Border.all( - color: _getDisciplineColor(item.discipline) - .withValues(alpha: 0.35), + color: disciplineColor.withValues(alpha: 0.35), width: 0.8, ), ), @@ -1012,7 +1102,7 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> fontSize: 8.5, fontWeight: FontWeight.w800, letterSpacing: 0.5, - color: _getDisciplineColor(item.discipline), + color: disciplineColor, ), ), ), @@ -1024,21 +1114,21 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> Text( item.clubName!, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontBody, fontSize: 12, - color: Color(0xFF94A3B8), + color: secondaryTextColor, ), ), ], const SizedBox(height: 2), Text( '${_formatRelativeTime(item.timestamp)} • ${item.locationName}', - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontBody, fontSize: 12.5, fontWeight: FontWeight.w500, - color: Color(0xFFCBD5E1), + color: secondaryTextColor, ), ), ], @@ -1047,37 +1137,61 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> // Overflow options menu PopupMenuButton( - icon: const Icon(Icons.more_vert, - color: Color(0xFF849396), size: 20), - color: const Color(0xFF181C21), + icon: Icon( + Icons.more_vert, + color: secondaryTextColor, + size: 20, + ), + color: colors.surfaceCardSecondary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), - side: const BorderSide(color: Color(0xFF272A30)), + side: BorderSide(color: colors.surfaceBorder), ), onSelected: (val) { + if (val == 'export_gpx') { + _showGpxExportSnackBar(context, colors, accentColor, item); + return; + } ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Action: $val'), + backgroundColor: colors.surfaceCardSecondary, + content: Row( + children: [ + Expanded( + child: Text( + 'Action: $val', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: colors.textPrimary), + ), + ), + ], + ), duration: const Duration(seconds: 1), ), ); }, itemBuilder: (ctx) => [ - const PopupMenuItem( + PopupMenuItem( value: 'copy_link', - child: Text('Copy Route Link', - style: TextStyle(color: Colors.white, fontSize: 13)), + child: Text( + 'Copy Route Link', + style: TextStyle(color: colors.textPrimary, fontSize: 13), + ), ), - const PopupMenuItem( + PopupMenuItem( value: 'export_gpx', - child: Text('Export GPX File', - style: TextStyle(color: Colors.white, fontSize: 13)), + child: Text( + 'Export GPX File', + style: TextStyle(color: colors.textPrimary, fontSize: 13), + ), ), - const PopupMenuItem( + PopupMenuItem( value: 'report', - child: Text('Report Content', - style: - TextStyle(color: Colors.redAccent, fontSize: 13)), + child: Text( + 'Report Content', + style: TextStyle(color: colors.dangerRed, fontSize: 13), + ), ), ], ), @@ -1093,12 +1207,12 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> children: [ Text( item.rideTitle, - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontHeadline, fontSize: 18, fontWeight: FontWeight.w700, letterSpacing: -0.3, - color: Colors.white, + color: colors.textPrimary, ), ), const SizedBox(height: 6), @@ -1106,17 +1220,17 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: const Color(0xFF12161D), + color: colors.surfaceCardSecondary, borderRadius: BorderRadius.circular(6), - border: Border.all(color: const Color(0xFF263238)), + border: Border.all(color: colors.surfaceBorder), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon( + Icon( Icons.sports_motorsports_rounded, size: 14, - color: AppColors.electricCyan, + color: accentColor, ), const SizedBox(width: 6), Flexible( @@ -1124,11 +1238,11 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> item.bikeModel, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 11.5, fontWeight: FontWeight.w600, - color: Color(0xFFE2E8F0), + color: colors.textPrimary, ), ), ), @@ -1137,18 +1251,18 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> Container( width: 3, height: 3, - decoration: const BoxDecoration( - color: Color(0xFF64748B), + decoration: BoxDecoration( + color: secondaryTextColor, shape: BoxShape.circle, ), ), const SizedBox(width: 6), Text( item.regionName!, - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontBody, fontSize: 11, - color: Color(0xFF94A3B8), + color: secondaryTextColor, ), ), ], @@ -1166,26 +1280,28 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: const Color(0xFF0F1318), + color: colors.surfaceCardSecondary, borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xFF1E2633)), + border: Border.all(color: colors.surfaceBorder), ), child: Column( children: [ Row( children: [ _buildMetricCell( + context: context, label: 'DISTANCE', value: item.distanceMiles.toStringAsFixed(1), unit: 'MI', - valueColor: Colors.white, + valueColor: colors.textPrimary, ), const SizedBox(width: 6), _buildMetricCell( + context: context, label: 'DURATION', value: _formatDuration(item.durationSeconds), unit: '', - valueColor: Colors.white, + valueColor: colors.textPrimary, ), ], ), @@ -1193,20 +1309,22 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> Row( children: [ _buildMetricCell( + context: context, label: 'MAX LEAN', value: '${item.maxLeanAngleDeg.toStringAsFixed(0)}°', unit: item.maxLeanSide, - valueColor: AppColors.electricCyan, - indicatorColor: AppColors.electricCyan, + valueColor: accentColor, + 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: const Color(0xFFB4C5FF), + valueColor: accentColor, ), ], ), @@ -1246,19 +1364,14 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> height: 48, decoration: BoxDecoration( color: isBumped - ? AppColors.electricCyan - : const Color(0xFF272A30), + ? bumpedBg + : colors.surfaceCardSecondary, borderRadius: BorderRadius.circular(12), - boxShadow: isBumped - ? [ - BoxShadow( - color: AppColors.electricCyan - .withValues(alpha: 0.35), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ] - : null, + border: Border.all( + color: isBumped + ? bumpedBg + : colors.surfaceBorder, + ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -1274,8 +1387,8 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> fontSize: 13, fontWeight: FontWeight.w700, color: isBumped - ? const Color(0xFF00363D) - : Colors.white, + ? bumpedFg + : colors.textPrimary, ), ), ), @@ -1297,25 +1410,26 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> child: Container( height: 48, decoration: BoxDecoration( - color: const Color(0xFF272A30), + color: colors.surfaceCardSecondary, borderRadius: BorderRadius.circular(12), + border: Border.all(color: colors.surfaceBorder), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon( + Icon( Icons.chat_bubble_outline_rounded, size: 18, - color: AppColors.electricCyan, + color: accentColor, ), const SizedBox(width: 6), Text( '${item.comments.length}', - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 13, fontWeight: FontWeight.w700, - color: Colors.white, + color: colors.textPrimary, ), ), ], @@ -1327,36 +1441,23 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> // GPX Export Button InkWell( + key: ValueKey('gpx_export_button_${item.id}'), borderRadius: BorderRadius.circular(12), onTap: () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - backgroundColor: const Color(0xFF1D2025), - content: Row( - children: [ - const Icon(Icons.download_done_rounded, - color: AppColors.electricCyan), - const SizedBox(width: 10), - Text( - 'GPX track exported for ${item.rideTitle}', - style: const TextStyle(color: Colors.white), - ), - ], - ), - ), - ); + _showGpxExportSnackBar(context, colors, accentColor, item); }, child: Container( width: 48, height: 48, decoration: BoxDecoration( - color: const Color(0xFF272A30), + color: colors.surfaceCardSecondary, borderRadius: BorderRadius.circular(12), + border: Border.all(color: colors.surfaceBorder), ), - child: const Icon( + child: Icon( Icons.download_rounded, size: 20, - color: Colors.white, + color: colors.textPrimary, ), ), ), @@ -1375,21 +1476,21 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> height: 44, width: double.infinity, decoration: BoxDecoration( - color: const Color(0xFF181C21), + color: colors.surfaceCardSecondary, borderRadius: BorderRadius.circular(12), border: Border.all( - color: AppColors.electricCyan.withValues(alpha: 0.25), + color: accentColor.withValues(alpha: 0.35), ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, - children: const [ + children: [ Icon( Icons.bolt_rounded, size: 18, - color: AppColors.electricCyan, + color: accentColor, ), - SizedBox(width: 8), + const SizedBox(width: 8), Flexible( child: Text( 'INSPECT TELEMETRY GRAPH', @@ -1399,7 +1500,7 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 0.8, - color: AppColors.electricCyan, + color: accentColor, ), ), ), @@ -1416,19 +1517,25 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> } 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: const Color(0xFF161B22), + color: colors.surfaceCardPrimary, borderRadius: BorderRadius.circular(10), - border: Border.all(color: const Color(0xFF222936), width: 0.8), + border: Border.all(color: colors.surfaceBorder, width: 0.8), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1440,12 +1547,12 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> label, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 10.5, fontWeight: FontWeight.w700, letterSpacing: 0.6, - color: Color(0xFF94A3B8), + color: secondaryTextColor, ), ), ), @@ -1481,15 +1588,17 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> ), if (unit.isNotEmpty) ...[ const SizedBox(width: 4), - Text( - unit, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontFamily: AppTypography.fontTelemetry, - fontSize: 10, - fontWeight: FontWeight.w700, - color: Color(0xFFCBD5E1), + Flexible( + child: Text( + unit, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 10, + fontWeight: FontWeight.w700, + color: secondaryTextColor, + ), ), ), ], @@ -1502,6 +1611,7 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> } Widget _buildRouteTrackModule(BuildContext context, SocialFeedItem item) { + final hasValidTrack = item.hasRecordedRoute; return Container( height: 160, decoration: BoxDecoration( @@ -1517,13 +1627,59 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> borderRadius: BorderRadius.circular(14), child: CustomPaint( painter: _FeedRoutePainter( - points: item.routePoints, + points: hasValidTrack ? item.routePoints : const [], maxLean: item.maxLeanAngleDeg, ), ), ), ), + 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: const Color(0xFF101419).withValues(alpha: 0.88), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: const Color(0xFF2A323D), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon( + Icons.gps_off_rounded, + size: 14, + color: Color(0xFF94A3B8), + ), + 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: Color(0xFFCBD5E1), + ), + ), + ), + ], + ), + ), + ), + ), + // Elevation Pill Overlay Positioned( bottom: 8, @@ -1560,8 +1716,8 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> ), ), - // GPS Verified Badge - if (item.isGpsVerified) ...[ + // GPS Verified Badge (strictly requires verified GPS trace criteria) + if (item.hasVerifiedGps) ...[ Positioned( top: 8, right: 10, @@ -1690,7 +1846,7 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> ], ), ), - if (item.isGpsVerified) + if (item.hasVerifiedGps) Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4), @@ -1698,27 +1854,27 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> color: const Color(0xFF101419).withValues(alpha: 0.85), borderRadius: BorderRadius.circular(6), ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: const [ - Icon( - Icons.verified_rounded, - size: 13, - color: AppColors.electricCyan, - ), - SizedBox(width: 4), - Text( - 'GPS VERIFIED', - style: TextStyle( - fontFamily: AppTypography.fontTelemetry, - fontSize: 10, - fontWeight: FontWeight.w800, + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon( + Icons.verified_rounded, + size: 13, color: AppColors.electricCyan, ), - ), - ], + SizedBox(width: 4), + Text( + 'GPS VERIFIED', + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 10, + fontWeight: FontWeight.w800, + color: AppColors.electricCyan, + ), + ), + ], + ), ), - ), ], ), ), @@ -1730,6 +1886,8 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> } /// 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; @@ -1757,36 +1915,56 @@ class _FeedRoutePainter extends CustomPainter { canvas.drawLine(Offset(0, y), Offset(w, y), gridPaint); } - // 2. High-contrast curved canyon path - final path = Path(); - final p0 = Offset(w * 0.1, h * 0.75); - final p1 = Offset(w * 0.35, h * 0.25); - final p2 = Offset(w * 0.55, h * 0.7); - final p3 = Offset(w * 0.88, h * 0.3); + if (points.length < 2) { + return; + } - path.moveTo(p0.dx, p0.dy); - path.cubicTo( - w * 0.2, h * 0.75, - w * 0.25, h * 0.25, - p1.dx, p1.dy, - ); - path.cubicTo( - w * 0.42, h * 0.25, - w * 0.45, h * 0.7, - p2.dx, p2.dy, - ); - path.cubicTo( - w * 0.65, h * 0.7, - w * 0.72, h * 0.28, - p3.dx, p3.dy, - ); + // 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); + } // Cyan glow blur final glowPaint = Paint() ..color = AppColors.electricCyan.withValues(alpha: 0.25) ..strokeWidth = 8 ..style = PaintingStyle.stroke - ..strokeCap = StrokeCap.round; + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; canvas.drawPath(path, glowPaint); // Active trail @@ -1794,70 +1972,51 @@ class _FeedRoutePainter extends CustomPainter { ..color = const Color(0xFFC3F5FF) ..strokeWidth = 3.5 ..style = PaintingStyle.stroke - ..strokeCap = StrokeCap.round; + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; canvas.drawPath(path, trailPaint); - // High G-Force Apex Segment Highlight (Amber Sector) - final amberPath = Path(); - amberPath.moveTo(p1.dx, p1.dy); - amberPath.cubicTo( - w * 0.42, h * 0.25, - w * 0.45, h * 0.7, - p2.dx, p2.dy, - ); - final amberPaint = Paint() - ..color = const Color(0xFFF59E0B) - ..strokeWidth = 4.5 - ..style = PaintingStyle.stroke - ..strokeCap = StrokeCap.round; - canvas.drawPath(amberPath, amberPaint); + final startOffset = mappedPoints.first; + final finishOffset = mappedPoints.last; + final apexOffset = mappedPoints[mappedPoints.length ~/ 2]; - // Start marker with dashed ring + // Start marker with ring final startPaint = Paint()..color = AppColors.electricCyan; - canvas.drawCircle(p0, 4.5, startPaint); + canvas.drawCircle(startOffset, 4.5, startPaint); final ringPaint = Paint() ..color = AppColors.electricCyan.withValues(alpha: 0.6) ..style = PaintingStyle.stroke ..strokeWidth = 1.5; - canvas.drawCircle(p0, 8.0, ringPaint); + canvas.drawCircle(startOffset, 8.0, ringPaint); - // Apex markers with badges - final apexPaint = Paint()..color = AppColors.electricCyan; - canvas.drawCircle(p1, 4.0, apexPaint); + // Apex marker (only if maxLean > 0) + final textPainter = TextPainter(textDirection: TextDirection.ltr); + if (maxLean > 0) { + final apexPaint = Paint()..color = AppColors.electricCyan; + canvas.drawCircle(apexOffset, 4.0, apexPaint); - final amberDotPaint = Paint()..color = const Color(0xFFF59E0B); - canvas.drawCircle(p2, 4.0, amberDotPaint); + textPainter.text = TextSpan( + text: '${maxLean.toStringAsFixed(0)}° APEX', + style: const TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 9, + fontWeight: FontWeight.w800, + color: Color(0xFFC3F5FF), + ), + ); + 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 = const Color(0xFFBAC9CC); - canvas.drawCircle(p3, 4.0, finishPaint); - - // Text labels for APEX and FINISH - final textPainter = TextPainter(textDirection: TextDirection.ltr); - - textPainter.text = TextSpan( - text: '${maxLean.toStringAsFixed(0)}° APEX', - style: const TextStyle( - fontFamily: AppTypography.fontTelemetry, - fontSize: 9, - fontWeight: FontWeight.w800, - color: Color(0xFFC3F5FF), - ), - ); - textPainter.layout(); - textPainter.paint(canvas, Offset(p1.dx - textPainter.width / 2, p1.dy - 16)); - - textPainter.text = const TextSpan( - text: '48° SWEEP', - style: TextStyle( - fontFamily: AppTypography.fontTelemetry, - fontSize: 9, - fontWeight: FontWeight.w800, - color: Color(0xFFF59E0B), - ), - ); - textPainter.layout(); - textPainter.paint(canvas, Offset(p2.dx - textPainter.width / 2, p2.dy + 8)); + canvas.drawCircle(finishOffset, 4.0, finishPaint); textPainter.text = const TextSpan( text: 'FINISH', @@ -1869,7 +2028,13 @@ class _FeedRoutePainter extends CustomPainter { ), ); textPainter.layout(); - textPainter.paint(canvas, Offset(p3.dx - textPainter.width / 2, p3.dy + 8)); + 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 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 55d8aee..c15a0a8 100644 --- a/lib/features/social/feed/widgets/feed_comments_bottom_sheet.dart +++ b/lib/features/social/feed/widgets/feed_comments_bottom_sheet.dart @@ -100,6 +100,14 @@ class _FeedCommentsBottomSheetState @override Widget build(BuildContext context) { final colors = context.motorColors; + final isLight = Theme.of(context).brightness == Brightness.light; + final secondaryTextColor = + isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant; + final accentColor = + isLight ? AppColors.brandBlueStrong : colors.brandCyanBright; + final sendBg = isLight ? AppColors.brandBlueStrong : AppColors.electricCyan; + final sendFg = isLight ? AppColors.onBrand : const Color(0xFF00363D); + final bottomInset = MediaQuery.of(context).viewInsets.bottom; final feedItems = ref.watch(socialFeedNotifierProvider); final livePost = feedItems.cast().firstWhere( @@ -116,14 +124,14 @@ class _FeedCommentsBottomSheetState ), child: Container( decoration: BoxDecoration( - color: const Color(0xFF101419), + color: colors.surfaceCardPrimary, borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), - border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.5)), - boxShadow: const [ + border: Border.all(color: colors.surfaceBorder), + boxShadow: [ BoxShadow( - color: Colors.black87, + color: colors.glowShadow, blurRadius: 30, - offset: Offset(0, -10), + offset: const Offset(0, -10), ), ], ), @@ -139,7 +147,7 @@ class _FeedCommentsBottomSheetState width: 44, height: 4, decoration: BoxDecoration( - color: colors.outlineVariant, + color: colors.surfaceBorder, borderRadius: BorderRadius.circular(2), ), ), @@ -149,9 +157,9 @@ class _FeedCommentsBottomSheetState padding: const EdgeInsets.fromLTRB(20, 8, 12, 12), child: Row( children: [ - const Icon( + Icon( Icons.chat_bubble_outline_rounded, - color: AppColors.electricCyan, + color: accentColor, size: 20, ), const SizedBox(width: 8), @@ -161,7 +169,7 @@ class _FeedCommentsBottomSheetState fontFamily: AppTypography.fontHeadline, fontSize: 16, fontWeight: FontWeight.w700, - color: Colors.white, + color: colors.textPrimary, ), ), const SizedBox(width: 8), @@ -169,33 +177,35 @@ class _FeedCommentsBottomSheetState padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 2), decoration: BoxDecoration( - color: const Color(0xFF1D2025), + color: colors.surfaceCardSecondary, borderRadius: BorderRadius.circular(12), border: Border.all( - color: AppColors.electricCyan.withValues(alpha: 0.3), + color: accentColor.withValues(alpha: 0.35), ), ), child: Text( '${comments.length}', - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 12, fontWeight: FontWeight.w700, - color: AppColors.electricCyan, + color: accentColor, ), ), ), const Spacer(), IconButton( - icon: const Icon(Icons.close_rounded, - color: Color(0xFF849396)), + icon: Icon( + Icons.close_rounded, + color: secondaryTextColor, + ), onPressed: () => Navigator.of(context).pop(), tooltip: 'Close', ), ], ), ), - const Divider(color: Color(0xFF1D2025), height: 1), + Divider(color: colors.surfaceBorder, height: 1), // Comment List or Empty Flexible( @@ -209,16 +219,16 @@ class _FeedCommentsBottomSheetState Icon( Icons.forum_outlined, size: 40, - color: colors.outline.withValues(alpha: 0.4), + color: secondaryTextColor, ), const SizedBox(height: 12), - const Text( + Text( 'No debrief comments yet', style: TextStyle( fontFamily: AppTypography.fontHeadline, fontSize: 14, fontWeight: FontWeight.w600, - color: Colors.white70, + color: colors.textPrimary, ), ), const SizedBox(height: 4), @@ -228,7 +238,7 @@ class _FeedCommentsBottomSheetState style: TextStyle( fontFamily: AppTypography.fontBody, fontSize: 12, - color: colors.outline, + color: secondaryTextColor, ), ), ], @@ -246,10 +256,10 @@ class _FeedCommentsBottomSheetState return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: const Color(0xFF181C21), + color: colors.surfaceCardSecondary, borderRadius: BorderRadius.circular(12), border: Border.all( - color: const Color(0xFF272A30), + color: colors.surfaceBorder, ), ), child: Column( @@ -259,12 +269,11 @@ class _FeedCommentsBottomSheetState children: [ CircleAvatar( radius: 14, - backgroundColor: - const Color(0xFF272A30), - child: const Icon( + backgroundColor: colors.surfaceCardPrimary, + child: Icon( Icons.sports_motorsports_rounded, size: 16, - color: AppColors.electricCyan, + color: accentColor, ), ), const SizedBox(width: 8), @@ -275,12 +284,12 @@ class _FeedCommentsBottomSheetState child: Text( c.authorDisplayName, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontHeadline, fontSize: 13, fontWeight: FontWeight.w600, - color: Colors.white, + color: colors.textPrimary, ), ), ), @@ -292,7 +301,7 @@ class _FeedCommentsBottomSheetState fontFamily: AppTypography.fontBody, fontSize: 11, - color: colors.outline, + color: secondaryTextColor, ), ), ], @@ -304,7 +313,7 @@ class _FeedCommentsBottomSheetState style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 10, - color: colors.outline, + color: secondaryTextColor, ), ), ], @@ -314,11 +323,11 @@ class _FeedCommentsBottomSheetState padding: const EdgeInsets.only(left: 36), child: Text( c.content, - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontBody, fontSize: 13, height: 1.35, - color: Color(0xFFE0E2EA), + color: colors.textPrimary, ), ), ), @@ -329,7 +338,7 @@ class _FeedCommentsBottomSheetState ), ), - const Divider(color: Color(0xFF1D2025), height: 1), + Divider(color: colors.surfaceBorder, height: 1), // Comment Input Field (Glove Friendly) Padding( @@ -340,28 +349,28 @@ class _FeedCommentsBottomSheetState child: Container( height: 48, decoration: BoxDecoration( - color: const Color(0xFF181C21), + color: colors.surfaceCardSecondary, borderRadius: BorderRadius.circular(12), border: Border.all( - color: const Color(0xFF272A30), + color: colors.surfaceBorder, ), ), child: TextField( controller: _commentController, - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontBody, fontSize: 13, - color: Colors.white, + color: colors.textPrimary, ), - decoration: const InputDecoration( + decoration: InputDecoration( hintText: 'Add comment or pace tip...', hintStyle: TextStyle( fontFamily: AppTypography.fontBody, fontSize: 13, - color: Color(0xFF849396), + color: secondaryTextColor, ), border: InputBorder.none, - contentPadding: EdgeInsets.symmetric( + contentPadding: const EdgeInsets.symmetric( horizontal: 14, vertical: 14, ), @@ -376,8 +385,8 @@ class _FeedCommentsBottomSheetState width: 48, child: ElevatedButton( style: ElevatedButton.styleFrom( - backgroundColor: AppColors.electricCyan, - foregroundColor: const Color(0xFF00363D), + backgroundColor: sendBg, + foregroundColor: sendFg, padding: EdgeInsets.zero, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), @@ -387,12 +396,12 @@ class _FeedCommentsBottomSheetState onPressed: _isSubmitting ? null : () => _handlePostComment(), child: _isSubmitting - ? const SizedBox( + ? SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, - color: Color(0xFF00363D), + color: sendFg, ), ) : const Icon(Icons.send_rounded, size: 20), diff --git a/lib/features/social/feed/widgets/feed_telemetry_inspector_modal.dart b/lib/features/social/feed/widgets/feed_telemetry_inspector_modal.dart index 8b539b2..384eeee 100644 --- a/lib/features/social/feed/widgets/feed_telemetry_inspector_modal.dart +++ b/lib/features/social/feed/widgets/feed_telemetry_inspector_modal.dart @@ -58,19 +58,24 @@ class _FeedTelemetryInspectorModalState @override Widget build(BuildContext context) { 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.electricCyan; final item = widget.feedItem; final mediaQuery = MediaQuery.of(context); final maxHeight = mediaQuery.size.height * 0.90; return Container( constraints: BoxConstraints(maxHeight: maxHeight), - decoration: const BoxDecoration( - color: Color(0xFF0F1318), - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + decoration: BoxDecoration( + color: colors.surfaceCardPrimary, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), border: Border( - top: BorderSide(color: Color(0xFF222933), width: 1.5), - left: BorderSide(color: Color(0xFF222933), width: 1.5), - right: BorderSide(color: Color(0xFF222933), width: 1.5), + top: BorderSide(color: colors.surfaceBorder, width: 1.5), + left: BorderSide(color: colors.surfaceBorder, width: 1.5), + right: BorderSide(color: colors.surfaceBorder, width: 1.5), ), ), child: SafeArea( @@ -86,7 +91,7 @@ class _FeedTelemetryInspectorModalState width: 40, height: 4, decoration: BoxDecoration( - color: const Color(0xFF333E4D), + color: colors.surfaceBorder, borderRadius: BorderRadius.circular(2), ), ), @@ -100,15 +105,15 @@ class _FeedTelemetryInspectorModalState Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: AppColors.electricCyan.withValues(alpha: 0.15), + color: accentColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10), border: Border.all( - color: AppColors.electricCyan.withValues(alpha: 0.35), + color: accentColor.withValues(alpha: 0.35), ), ), - child: const Icon( + child: Icon( Icons.insights_rounded, - color: AppColors.electricCyan, + color: accentColor, size: 20, ), ), @@ -123,11 +128,11 @@ class _FeedTelemetryInspectorModalState child: Text( item.authorDisplayName, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontHeadline, fontSize: 15, fontWeight: FontWeight.w700, - color: Colors.white, + color: colors.textPrimary, ), ), ), @@ -139,19 +144,27 @@ class _FeedTelemetryInspectorModalState vertical: 2, ), decoration: BoxDecoration( - color: AppColors.hazardAmber.withValues(alpha: 0.2), + color: (isLight + ? AppColors.semanticWarningLight + : AppColors.hazardAmber) + .withValues(alpha: 0.2), borderRadius: BorderRadius.circular(4), border: Border.all( - color: AppColors.hazardAmber.withValues(alpha: 0.4), + color: (isLight + ? AppColors.semanticWarningLight + : AppColors.hazardAmber) + .withValues(alpha: 0.4), ), ), child: Text( item.authorBadge!, - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 9, fontWeight: FontWeight.w800, - color: AppColors.hazardAmber, + color: isLight + ? AppColors.semanticWarningLight + : AppColors.hazardAmber, ), ), ), @@ -161,24 +174,24 @@ class _FeedTelemetryInspectorModalState Text( '${item.rideTitle} • ${item.bikeModel}', overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontBody, fontSize: 12, - color: Color(0xFF8B98A5), + color: secondaryTextColor, ), ), ], ), ), IconButton( - icon: const Icon(Icons.close_rounded, color: Colors.white70), + icon: Icon(Icons.close_rounded, color: secondaryTextColor), onPressed: () => Navigator.of(context).pop(), ), ], ), ), - const Divider(color: Color(0xFF1D242E), height: 1), + Divider(color: colors.surfaceBorder, height: 1), // Scrollable Content: Metrics + Graph + Replay Trigger Flexible( @@ -188,11 +201,11 @@ class _FeedTelemetryInspectorModalState crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 2x3 Telemetry Insets Grid - _buildMetricsGrid(item, colors), + _buildMetricsGrid(item, colors, isLight, secondaryTextColor, accentColor), const SizedBox(height: 18), // Trace Selector Tabs - _buildTraceSelector(colors), + _buildTraceSelector(colors, secondaryTextColor, accentColor), const SizedBox(height: 12), // Interactive Telemetry Graph Canvas @@ -200,11 +213,11 @@ class _FeedTelemetryInspectorModalState const SizedBox(height: 16), // Scrubber Details Pill - _buildScrubberReadout(item, colors), + _buildScrubberReadout(item, colors, secondaryTextColor, accentColor), const SizedBox(height: 20), // Launch Full Replay Button - _buildReplayButton(context), + _buildReplayButton(context, colors, accentColor), ], ), ), @@ -215,13 +228,24 @@ class _FeedTelemetryInspectorModalState ); } - Widget _buildMetricsGrid(SocialFeedItem item, MotorSignalThemeColors colors) { + Widget _buildMetricsGrid( + SocialFeedItem item, + MotorSignalThemeColors colors, + bool isLight, + Color secondaryTextColor, + Color accentColor, + ) { + final successColor = + isLight ? AppColors.semanticSuccessLight : const Color(0xFF00E676); + final warnColor = + isLight ? AppColors.semanticWarningLight : AppColors.hazardAmber; + return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( - color: const Color(0xFF141A22), + color: colors.surfaceCardSecondary, borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFF222B38)), + border: Border.all(color: colors.surfaceBorder), ), child: Column( children: [ @@ -232,34 +256,37 @@ class _FeedTelemetryInspectorModalState label: 'PEAK LEAN', value: '${item.maxLeanAngleDeg.toStringAsFixed(0)}°', subtext: '${item.maxLeanSide} APEX', - accentColor: AppColors.electricCyan, + accentColor: accentColor, + secondaryTextColor: secondaryTextColor, icon: Icons.rotate_right_rounded, ), ), - Container(width: 1, height: 48, color: const Color(0xFF242E3D)), + Container(width: 1, height: 48, color: colors.surfaceBorder), Expanded( child: _buildMetricCell( label: 'DISTANCE', value: item.distanceMiles.toStringAsFixed(1), subtext: 'MILES', - accentColor: Colors.white, + accentColor: colors.textPrimary, + secondaryTextColor: secondaryTextColor, icon: Icons.route_rounded, ), ), - Container(width: 1, height: 48, color: const Color(0xFF242E3D)), + Container(width: 1, height: 48, color: colors.surfaceBorder), Expanded( child: _buildMetricCell( label: 'DURATION', value: _formatDuration(item.durationSeconds), subtext: 'MOVING', - accentColor: Colors.white, + accentColor: colors.textPrimary, + secondaryTextColor: secondaryTextColor, icon: Icons.timer_outlined, ), ), ], ), const SizedBox(height: 12), - const Divider(color: Color(0xFF222B38), height: 1), + Divider(color: colors.surfaceBorder, height: 1), const SizedBox(height: 12), Row( children: [ @@ -270,29 +297,30 @@ class _FeedTelemetryInspectorModalState subtext: item.paceFactor.contains(' ') ? item.paceFactor.split(' ').sublist(1).join(' ') : 'PACE', - accentColor: const Color(0xFF00E676), + accentColor: successColor, + secondaryTextColor: secondaryTextColor, icon: Icons.speed_rounded, ), ), - Container(width: 1, height: 48, color: const Color(0xFF242E3D)), + Container(width: 1, height: 48, color: colors.surfaceBorder), Expanded( child: _buildMetricCell( label: 'ELEVATION', value: '+${item.elevationFt}', subtext: 'FEET GAIN', - accentColor: Colors.white, + accentColor: colors.textPrimary, + secondaryTextColor: secondaryTextColor, icon: Icons.terrain_rounded, ), ), - Container(width: 1, height: 48, color: const Color(0xFF242E3D)), + Container(width: 1, height: 48, color: colors.surfaceBorder), Expanded( child: _buildMetricCell( label: 'HAZARDS', value: '${item.hazardCount}', subtext: item.hazardCount == 0 ? 'CLEAR' : 'FLAGGED', - accentColor: item.hazardCount == 0 - ? const Color(0xFF00E676) - : AppColors.hazardAmber, + accentColor: item.hazardCount == 0 ? successColor : warnColor, + secondaryTextColor: secondaryTextColor, icon: Icons.warning_amber_rounded, ), ), @@ -308,6 +336,7 @@ class _FeedTelemetryInspectorModalState required String value, required String subtext, required Color accentColor, + required Color secondaryTextColor, required IconData icon, }) { return Padding( @@ -317,18 +346,18 @@ class _FeedTelemetryInspectorModalState children: [ Row( children: [ - Icon(icon, size: 12, color: const Color(0xFF6B7A8D)), + Icon(icon, size: 12, color: secondaryTextColor), const SizedBox(width: 4), Flexible( child: Text( label, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 8.5, fontWeight: FontWeight.w700, letterSpacing: 0.5, - color: Color(0xFF7B8B9E), + color: secondaryTextColor, ), ), ), @@ -350,12 +379,12 @@ class _FeedTelemetryInspectorModalState ), Text( subtext, - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 8.5, fontWeight: FontWeight.w700, letterSpacing: 0.4, - color: Color(0xFF5D6C7E), + color: secondaryTextColor, ), ), ], @@ -363,13 +392,17 @@ class _FeedTelemetryInspectorModalState ); } - Widget _buildTraceSelector(MotorSignalThemeColors colors) { + Widget _buildTraceSelector( + MotorSignalThemeColors colors, + Color secondaryTextColor, + Color accentColor, + ) { return Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( - color: const Color(0xFF141920), + color: colors.surfaceCardSecondary, borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFF222B38)), + border: Border.all(color: colors.surfaceBorder), ), child: Row( children: [ @@ -377,16 +410,25 @@ class _FeedTelemetryInspectorModalState tab: _InspectorTraceTab.leanApex, label: 'LEAN & APEX', icon: Icons.rotate_left_rounded, + colors: colors, + secondaryTextColor: secondaryTextColor, + accentColor: accentColor, ), _buildTraceTabItem( tab: _InspectorTraceTab.speedGForce, label: 'SPEED & G-FORCE', icon: Icons.show_chart_rounded, + colors: colors, + secondaryTextColor: secondaryTextColor, + accentColor: accentColor, ), _buildTraceTabItem( tab: _InspectorTraceTab.elevation, label: 'ELEVATION', icon: Icons.terrain_rounded, + colors: colors, + secondaryTextColor: secondaryTextColor, + accentColor: accentColor, ), ], ), @@ -397,6 +439,9 @@ class _FeedTelemetryInspectorModalState required _InspectorTraceTab tab, required String label, required IconData icon, + required MotorSignalThemeColors colors, + required Color secondaryTextColor, + required Color accentColor, }) { final isSelected = _selectedTab == tab; return Expanded( @@ -407,10 +452,10 @@ class _FeedTelemetryInspectorModalState duration: const Duration(milliseconds: 180), padding: const EdgeInsets.symmetric(vertical: 8), decoration: BoxDecoration( - color: isSelected ? const Color(0xFF202A36) : Colors.transparent, + color: isSelected ? colors.surfaceCardPrimary : Colors.transparent, borderRadius: BorderRadius.circular(8), border: isSelected - ? Border.all(color: AppColors.electricCyan.withValues(alpha: 0.4)) + ? Border.all(color: accentColor.withValues(alpha: 0.4)) : null, ), child: Row( @@ -419,7 +464,7 @@ class _FeedTelemetryInspectorModalState Icon( icon, size: 13, - color: isSelected ? AppColors.electricCyan : const Color(0xFF6B7A8D), + color: isSelected ? accentColor : secondaryTextColor, ), const SizedBox(width: 5), Flexible( @@ -431,7 +476,7 @@ class _FeedTelemetryInspectorModalState fontSize: 10, fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600, letterSpacing: 0.3, - color: isSelected ? Colors.white : const Color(0xFF7A8B9E), + color: isSelected ? colors.textPrimary : secondaryTextColor, ), ), ), @@ -446,6 +491,7 @@ class _FeedTelemetryInspectorModalState SocialFeedItem item, MotorSignalThemeColors colors, ) { + final hasRecorded = item.hasRecordedTelemetry; return Container( height: 220, decoration: BoxDecoration( @@ -455,33 +501,92 @@ class _FeedTelemetryInspectorModalState ), child: ClipRRect( borderRadius: BorderRadius.circular(16), - child: GestureDetector( - onHorizontalDragUpdate: (details) { - final box = context.findRenderObject() as RenderBox?; - if (box != null) { - final localX = details.localPosition.dx; - setState(() { - _scrubFraction = (localX / 340).clamp(0.0, 1.0); - }); - } - }, - onTapDown: (details) { - final box = context.findRenderObject() as RenderBox?; - if (box != null) { - final localX = details.localPosition.dx; - setState(() { - _scrubFraction = (localX / 340).clamp(0.0, 1.0); - }); - } - }, - child: CustomPaint( - size: const Size(double.infinity, 220), - painter: _TelemetryWaveformPainter( - tab: _selectedTab, - item: item, - scrubFraction: _scrubFraction, + child: Stack( + fit: StackFit.expand, + children: [ + LayoutBuilder( + builder: (context, graphConstraints) { + final graphWidth = + graphConstraints.maxWidth > 0 ? graphConstraints.maxWidth : 340.0; + return GestureDetector( + onHorizontalDragUpdate: hasRecorded + ? (details) { + setState(() { + _scrubFraction = + (details.localPosition.dx / graphWidth) + .clamp(0.0, 1.0); + }); + } + : null, + onTapDown: hasRecorded + ? (details) { + setState(() { + _scrubFraction = + (details.localPosition.dx / graphWidth) + .clamp(0.0, 1.0); + }); + } + : null, + child: CustomPaint( + size: const Size(double.infinity, 220), + painter: _TelemetryWaveformPainter( + tab: _selectedTab, + item: item, + scrubFraction: _scrubFraction, + ), + ), + ); + }, ), - ), + if (!hasRecorded) + Center( + child: Container( + key: const ValueKey('inspector_no_telemetry_state'), + margin: const EdgeInsets.symmetric(horizontal: 24), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + decoration: BoxDecoration( + color: const Color(0xFF141A22).withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFF2A3647)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon( + Icons.sensors_off_rounded, + size: 24, + color: Color(0xFF8B98A5), + ), + SizedBox(height: 8), + Text( + 'NO RECORDED TELEMETRY SAMPLES', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 11, + fontWeight: FontWeight.w800, + letterSpacing: 0.5, + color: Colors.white, + ), + ), + SizedBox(height: 4), + Text( + 'Detailed multi-trace waveforms require recorded ride telemetry points.', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 11, + color: Color(0xFF8B98A5), + ), + ), + ], + ), + ), + ), + ], ), ), ); @@ -490,19 +595,22 @@ class _FeedTelemetryInspectorModalState Widget _buildScrubberReadout( SocialFeedItem item, MotorSignalThemeColors colors, + Color secondaryTextColor, + Color accentColor, ) { + final hasRecorded = item.hasRecordedTelemetry; final scrubDist = (item.distanceMiles * _scrubFraction).toStringAsFixed(1); final scrubPct = (_scrubFraction * 100).toInt(); - // Instantaneous interpolated readings based on scrub point + // Instantaneous interpolated readings from recorded telemetry samples final (speedVal, leanVal, gVal) = _calculateScrubValues(item, _scrubFraction); return Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( - color: const Color(0xFF141920), + color: colors.surfaceCardSecondary, borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFF222B38)), + border: Border.all(color: colors.surfaceBorder), ), child: Column( children: [ @@ -512,22 +620,24 @@ class _FeedTelemetryInspectorModalState Expanded( child: Row( children: [ - const Icon( + Icon( Icons.touch_app_rounded, size: 14, - color: AppColors.electricCyan, + color: accentColor, ), const SizedBox(width: 6), Flexible( child: Text( - 'POINT: $scrubDist MI ($scrubPct%)', + hasRecorded + ? 'POINT: $scrubDist MI ($scrubPct%)' + : 'TELEMETRY TRACE UNAVAILABLE', overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 9.5, fontWeight: FontWeight.w700, letterSpacing: 0.5, - color: AppColors.electricCyan, + color: accentColor, ), ), ), @@ -535,13 +645,13 @@ class _FeedTelemetryInspectorModalState ), ), const SizedBox(width: 8), - const Text( - 'DRAG TO SCRUB', + Text( + hasRecorded ? 'DRAG TO SCRUB' : 'SUMMARY ONLY', style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 8.5, fontWeight: FontWeight.w700, - color: Color(0xFF5D6C7E), + color: secondaryTextColor, ), ), ], @@ -550,11 +660,11 @@ class _FeedTelemetryInspectorModalState Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - _buildScrubStat('SPEED', '$speedVal MPH'), - Container(width: 1, height: 24, color: const Color(0xFF222B38)), - _buildScrubStat('LEAN', '$leanVal°'), - Container(width: 1, height: 24, color: const Color(0xFF222B38)), - _buildScrubStat('LAT G', '${gVal}G'), + _buildScrubStat('SPEED', hasRecorded ? '$speedVal MPH' : '-- MPH', colors, secondaryTextColor), + Container(width: 1, height: 24, color: colors.surfaceBorder), + _buildScrubStat('LEAN', hasRecorded ? '$leanVal°' : '--°', colors, secondaryTextColor), + Container(width: 1, height: 24, color: colors.surfaceBorder), + _buildScrubStat('LAT G', hasRecorded ? '${gVal}G' : '--G', colors, secondaryTextColor), ], ), ], @@ -563,72 +673,99 @@ class _FeedTelemetryInspectorModalState } (int, int, String) _calculateScrubValues(SocialFeedItem item, double frac) { - // Generate realistic wave curves based on session metrics - final baseSpeed = 48.0; - final speedVariation = 26.0 * math.sin(frac * math.pi * 5); - final speed = (baseSpeed + speedVariation).clamp(20, 115).toInt(); + if (!item.hasRecordedTelemetry || item.telemetrySamples.isEmpty) { + return (0, 0, '0.00'); + } - final leanVariation = item.maxLeanAngleDeg * math.sin(frac * math.pi * 7).abs(); - final lean = leanVariation.clamp(0, item.maxLeanAngleDeg).toInt(); + final samples = item.telemetrySamples; + final clamped = frac.clamp(0.0, 1.0); + final exactIdx = clamped * (samples.length - 1); + final lowerIdx = exactIdx.floor().clamp(0, samples.length - 1); + final upperIdx = exactIdx.ceil().clamp(0, samples.length - 1); + final t = exactIdx - lowerIdx; - final gForce = (0.2 + (lean / 60.0) * 0.9).toStringAsFixed(2); + final a = samples[lowerIdx]; + final b = samples[upperIdx]; - return (speed, lean, gForce); + final aLean = FeedTelemetrySample.applyMovingLeanGate( + speedMph: a.speedMph, + leanAngleDeg: a.leanAngleDeg, + ).abs(); + final bLean = FeedTelemetrySample.applyMovingLeanGate( + speedMph: b.speedMph, + leanAngleDeg: b.leanAngleDeg, + ).abs(); + + final speed = (a.speedMph + (b.speedMph - a.speedMph) * t).round(); + final lean = (aLean + (bLean - aLean) * t).round(); + final latG = (a.lateralG.abs() + (b.lateralG.abs() - a.lateralG.abs()) * t) + .toStringAsFixed(2); + + return (speed, lean, latG); } - Widget _buildScrubStat(String label, String value) { + Widget _buildScrubStat( + String label, + String value, + MotorSignalThemeColors colors, + Color secondaryTextColor, + ) { return Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( label, - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 8.5, fontWeight: FontWeight.w700, - color: Color(0xFF6B7A8D), + color: secondaryTextColor, ), ), Text( value, - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 11, fontWeight: FontWeight.w800, - color: Colors.white, + color: colors.textPrimary, ), ), ], ); } - Widget _buildReplayButton(BuildContext context) { + Widget _buildReplayButton( + BuildContext context, + MotorSignalThemeColors colors, + Color accentColor, + ) { return SizedBox( height: 48, child: OutlinedButton.icon( style: OutlinedButton.styleFrom( - backgroundColor: const Color(0xFF161E28), + backgroundColor: colors.surfaceCardSecondary, side: BorderSide( - color: AppColors.electricCyan.withValues(alpha: 0.5), + color: accentColor.withValues(alpha: 0.5), width: 1.5, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), - icon: const Icon( + icon: Icon( Icons.play_circle_fill_rounded, - color: AppColors.electricCyan, + color: accentColor, size: 20, ), - label: const Text( + label: Text( 'OPEN FULL RIDE REPLAY', style: TextStyle( fontFamily: AppTypography.fontHeadline, fontSize: 12, fontWeight: FontWeight.w800, letterSpacing: 0.8, - color: Colors.white, + color: colors.textPrimary, ), ), onPressed: () { @@ -640,7 +777,8 @@ class _FeedTelemetryInspectorModalState } } -/// Custom painter rendering multi-trace telemetry waveforms and apex markers. +/// Custom painter rendering recorded multi-trace telemetry waveforms and apex markers. +/// Never fabricates synthetic curves when recorded telemetry samples are absent. class _TelemetryWaveformPainter extends CustomPainter { final _InspectorTraceTab tab; final SocialFeedItem item; @@ -671,6 +809,10 @@ class _TelemetryWaveformPainter extends CustomPainter { canvas.drawLine(Offset(x, 0), Offset(x, h), gridPaint); } + if (!item.hasRecordedTelemetry || item.telemetrySamples.length < 2) { + return; + } + switch (tab) { case _InspectorTraceTab.leanApex: _paintLeanWaveform(canvas, size); @@ -701,6 +843,7 @@ class _TelemetryWaveformPainter extends CustomPainter { final w = size.width; final h = size.height; final midY = h * 0.52; + final samples = item.telemetrySamples; // Baseline (0° upright) final baselinePaint = Paint() @@ -710,24 +853,30 @@ class _TelemetryWaveformPainter extends CustomPainter { canvas.drawLine(Offset(0, midY), Offset(w, midY), baselinePaint); final path = Path(); - final points = []; - const steps = 100; - final maxLean = item.maxLeanAngleDeg; + var peakIndex = 0; + var peakAbsLean = 0.0; + var peakPoint = Offset(0, midY); - for (var i = 0; i <= steps; i++) { - final t = i / steps; + for (var i = 0; i < samples.length; i++) { + final s = samples[i]; + final t = samples.length == 1 ? 0.0 : i / (samples.length - 1); final x = t * w; - // Multi-sine wave generating cornering sections - final wave = math.sin(t * math.pi * 6) * 0.7 + - math.sin(t * math.pi * 12) * 0.3; - final leanAngle = wave * maxLean; - final y = midY - (leanAngle / 60.0) * (h * 0.38); - points.add(Offset(x, y)); + final leanAngle = FeedTelemetrySample.applyMovingLeanGate( + speedMph: s.speedMph, + leanAngleDeg: s.leanAngleDeg, + ); + final y = midY - (leanAngle / 65.0) * (h * 0.38); + final pt = Offset(x, y); if (i == 0) { path.moveTo(x, y); } else { path.lineTo(x, y); } + if (leanAngle.abs() >= peakAbsLean) { + peakAbsLean = leanAngle.abs(); + peakIndex = i; + peakPoint = pt; + } } // Draw Gradient Area under Lean Curve @@ -754,49 +903,59 @@ class _TelemetryWaveformPainter extends CustomPainter { ..style = PaintingStyle.stroke; canvas.drawPath(path, linePaint); - // Draw Apex Markers (Peak Corners) - final apexMarkers = [ - (0.26, '${item.maxLeanAngleDeg.toStringAsFixed(0)}° APEX', const Color(0xFF00E676)), - (0.68, '${(item.maxLeanAngleDeg * 0.94).toStringAsFixed(0)}° SWEEP', const Color(0xFFFFB300)), - ]; - - for (final marker in apexMarkers) { - final mx = marker.$1 * w; - final wave = math.sin(marker.$1 * math.pi * 6) * 0.7 + - math.sin(marker.$1 * math.pi * 12) * 0.3; - final my = midY - (wave * maxLean / 60.0) * (h * 0.38); - - final dotPaint = Paint()..color = marker.$3; - canvas.drawCircle(Offset(mx, my), 5.0, dotPaint); + if (peakIndex >= 0 && peakAbsLean > 0) { + const markerColor = Color(0xFF00E676); + final dotPaint = Paint()..color = markerColor; + canvas.drawCircle(peakPoint, 5.0, dotPaint); final ringPaint = Paint() - ..color = marker.$3.withValues(alpha: 0.4) + ..color = markerColor.withValues(alpha: 0.4) ..style = PaintingStyle.stroke ..strokeWidth = 3; - canvas.drawCircle(Offset(mx, my), 8.0, ringPaint); + canvas.drawCircle(peakPoint, 8.0, ringPaint); - // Label Pill - _drawTextBadge(canvas, marker.$2, Offset(mx - 24, my - 24), marker.$3); + _drawTextBadge( + canvas, + '${peakAbsLean.toStringAsFixed(0)}° APEX', + Offset( + (peakPoint.dx - 24).clamp(6.0, w - 70.0), + (peakPoint.dy - 24).clamp(6.0, h - 24.0), + ), + markerColor, + ); } } void _paintSpeedWaveform(Canvas canvas, Size size) { final w = size.width; final h = size.height; + final samples = item.telemetrySamples; + + var maxSpeed = 1.0; + for (final s in samples) { + if (s.speedMph > maxSpeed) maxSpeed = s.speedMph; + } + final speedScale = math.max(maxSpeed * 1.15, 30.0); final path = Path(); - const steps = 100; + var peakSpeed = 0.0; + var peakPoint = Offset(w * 0.5, h * 0.5); - for (var i = 0; i <= steps; i++) { - final t = i / steps; + for (var i = 0; i < samples.length; i++) { + final s = samples[i]; + final t = samples.length == 1 ? 0.0 : i / (samples.length - 1); final x = t * w; - final speed = 40.0 + 35.0 * math.sin(t * math.pi * 4).abs() + 15.0 * math.cos(t * math.pi * 8); - final y = h * 0.90 - (speed / 120.0) * (h * 0.75); + final y = (h * 0.90 - (s.speedMph / speedScale) * (h * 0.75)) + .clamp(h * 0.1, h * 0.90); if (i == 0) { path.moveTo(x, y); } else { path.lineTo(x, y); } + if (s.speedMph >= peakSpeed) { + peakSpeed = s.speedMph; + peakPoint = Offset(x, y); + } } final fillPath = Path.from(path) @@ -821,26 +980,49 @@ class _TelemetryWaveformPainter extends CustomPainter { ..style = PaintingStyle.stroke; canvas.drawPath(path, linePaint); - _drawTextBadge(canvas, 'TOP SPEED', Offset(w * 0.5 - 28, h * 0.16), const Color(0xFFFFB300)); + _drawTextBadge( + canvas, + '${peakSpeed.round()} MPH TOP SPEED', + Offset( + (peakPoint.dx - 36).clamp(6.0, w - 100.0), + (peakPoint.dy - 22).clamp(6.0, h - 24.0), + ), + const Color(0xFFFFB300), + ); } void _paintElevationWaveform(Canvas canvas, Size size) { final w = size.width; final h = size.height; + final samples = item.telemetrySamples; + + var minAlt = samples.first.altitudeFt; + var maxAlt = samples.first.altitudeFt; + for (final s in samples) { + if (s.altitudeFt < minAlt) minAlt = s.altitudeFt; + if (s.altitudeFt > maxAlt) maxAlt = s.altitudeFt; + } + final altSpan = (maxAlt - minAlt).abs(); final path = Path(); - const steps = 100; + var crestPoint = Offset(w * 0.5, h * 0.25); + var highestAlt = minAlt; - for (var i = 0; i <= steps; i++) { - final t = i / steps; + for (var i = 0; i < samples.length; i++) { + final s = samples[i]; + final t = samples.length == 1 ? 0.0 : i / (samples.length - 1); final x = t * w; - final elevationNorm = 0.2 + 0.65 * (math.sin(t * math.pi * 2.2) * 0.5 + 0.5); - final y = h * 0.92 - elevationNorm * (h * 0.75); + final norm = altSpan < 1.0 ? 0.5 : (s.altitudeFt - minAlt) / altSpan; + final y = h * 0.92 - (0.15 + norm * 0.70) * (h * 0.75); if (i == 0) { path.moveTo(x, y); } else { path.lineTo(x, y); } + if (s.altitudeFt >= highestAlt) { + highestAlt = s.altitudeFt; + crestPoint = Offset(x, y); + } } final fillPath = Path.from(path) @@ -868,7 +1050,10 @@ class _TelemetryWaveformPainter extends CustomPainter { _drawTextBadge( canvas, '+${item.elevationFt} FT CREST', - Offset(w * 0.68 - 36, h * 0.18), + Offset( + (crestPoint.dx - 36).clamp(6.0, w - 95.0), + (crestPoint.dy - 22).clamp(6.0, h - 24.0), + ), const Color(0xFF448AFF), ); } diff --git a/lib/features/social/feed/widgets/share_session_modal.dart b/lib/features/social/feed/widgets/share_session_modal.dart index 2852482..7ba3a51 100644 --- a/lib/features/social/feed/widgets/share_session_modal.dart +++ b/lib/features/social/feed/widgets/share_session_modal.dart @@ -8,21 +8,36 @@ import '../../../../core/theme/theme_extensions.dart'; import '../../../../database/database.dart'; import '../../../../services/auth/auth_providers.dart'; import '../../../../services/map/map_provider_interface.dart'; +import '../../../../services/routes/route_providers.dart'; import '../../../../services/social/social_feed_models.dart'; import '../../../../services/social/social_feed_providers.dart'; import '../../../garage/garage_providers.dart'; /// Modal dialog / bottom sheet allowing riders to share their recent ride -/// or canyon session to the Community Social Feed. +/// or recorded session to the Community Social Feed. class ShareSessionModal extends ConsumerStatefulWidget { - const ShareSessionModal({super.key}); + final Ride? initialRide; + final List? initialTelemetrySamples; - static Future show(BuildContext context) { + const ShareSessionModal({ + super.key, + this.initialRide, + this.initialTelemetrySamples, + }); + + static Future show( + BuildContext context, { + Ride? initialRide, + List? initialTelemetrySamples, + }) { return showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, - builder: (ctx) => const ShareSessionModal(), + builder: (ctx) => ShareSessionModal( + initialRide: initialRide, + initialTelemetrySamples: initialTelemetrySamples, + ), ); } @@ -32,39 +47,110 @@ class ShareSessionModal extends ConsumerStatefulWidget { class _ShareSessionModalState extends ConsumerState { final _formKey = GlobalKey(); - final _titleController = TextEditingController(text: 'Morning Canyon Carve'); - final _locationController = TextEditingController(text: 'Skyline Blvd & Highway 9'); + final _titleController = TextEditingController(text: 'Morning Ride'); + final _locationController = + TextEditingController(text: 'Location unavailable'); final _bikeController = TextEditingController(); - final _distanceController = TextEditingController(text: '48.5'); - final _durationController = TextEditingController(text: '52'); - final _maxLeanController = TextEditingController(text: '49'); - String _selectedLeanSide = 'RIGHT'; - String _selectedPace = '1.25x Spirited'; - RidingDiscipline _selectedDiscipline = RidingDiscipline.canyonSport; + RidingDiscipline _selectedDiscipline = RidingDiscipline.touring; bool _isSubmitting = false; List _recentRides = []; Ride? _selectedRide; - List _selectedRoutePoints = const [ - AppLatLng(37.3861, -122.2641), - AppLatLng(37.3500, -122.2300), - AppLatLng(37.3100, -122.2000), - AppLatLng(37.2800, -122.2200), - ]; + List _selectedRoutePoints = const []; + List _selectedTelemetrySamples = const []; + + double get _authoritativeDistanceMiles { + final ride = _selectedRide; + if (ride == null) return 0.0; + return ride.distanceMiles > 0 ? ride.distanceMiles : ride.distance; + } + + int get _authoritativeDurationSeconds { + final ride = _selectedRide; + if (ride == null) return 0; + if (ride.movingTime > 0) return ride.movingTime; + if (ride.endTime != null) { + return ride.endTime! + .difference(ride.startTime) + .inSeconds + .clamp(0, 864000); + } + return 0; + } + + int get _authoritativeDurationMinutes => + (_authoritativeDurationSeconds / 60).round(); + + double get _authoritativeMaxLeanDeg { + final ride = _selectedRide; + if (ride == null) return 0.0; + return math.max(ride.maxLeanLeft, ride.maxLeanRight); + } + + String get _authoritativeLeanSide { + final ride = _selectedRide; + if (ride == null || + (ride.maxLeanLeft == 0.0 && ride.maxLeanRight == 0.0)) { + return 'NONE'; + } + return ride.maxLeanRight >= ride.maxLeanLeft ? 'RIGHT' : 'LEFT'; + } + + int get _authoritativeElevationFt { + final ride = _selectedRide; + if (ride == null) return 0; + return (ride.elevationGain * 3.28084).round(); + } + + String get _authoritativePaceFactor { + final ride = _selectedRide; + if (ride == null) return 'N/A'; + final avgSpeed = ride.averageSpeed > 0 + ? ride.averageSpeed + : (_authoritativeDurationSeconds > 0 + ? _authoritativeDistanceMiles / + (_authoritativeDurationSeconds / 3600.0) + : 0.0); + if (avgSpeed >= 45.0) return '1.45x Fast Pace'; + if (avgSpeed >= 30.0) return '1.25x Spirited'; + if (avgSpeed > 0.0) return '1.0x Cruiser'; + return '1.0x Recorded'; + } @override void initState() { super.initState(); + if (widget.initialRide != null) { + _recentRides = [widget.initialRide!]; + _applySelectedRideMetadata(widget.initialRide!); + if (widget.initialTelemetrySamples != null) { + _extractSamples(widget.initialTelemetrySamples!); + } + } WidgetsBinding.instance.addPostFrameCallback((_) async { final defaultBike = ref.read(defaultMotorcycleProvider); - if (defaultBike != null) { - _bikeController.text = - '${defaultBike.year} ${defaultBike.makeName} ${defaultBike.modelName}'.trim(); - } else { - _bikeController.text = '2024 Ducati Streetfighter V4'; + if (_bikeController.text.isEmpty) { + if (defaultBike != null) { + _bikeController.text = + '${defaultBike.year} ${defaultBike.makeName} ${defaultBike.modelName}' + .trim(); + } else { + _bikeController.text = '2024 Ducati Streetfighter V4'; + } } - await _loadRecentRides(); + if (widget.initialRide != null) { + if (widget.initialTelemetrySamples == null) { + await _loadSamplesForRide(widget.initialRide!); + } else { + await _resolveStartingLocation( + widget.initialTelemetrySamples!, + widget.initialRide!.id, + ); + } + } else { + await _loadRecentRides(); + } }); } @@ -72,65 +158,156 @@ class _ShareSessionModalState extends ConsumerState { try { final db = ref.read(databaseProvider); final rides = await (db.select(db.rides) - ..orderBy([(t) => drift.OrderingTerm(expression: t.id, mode: drift.OrderingMode.desc)]) + ..orderBy([ + (t) => drift.OrderingTerm( + expression: t.id, mode: drift.OrderingMode.desc) + ]) ..limit(10)) .get(); if (mounted) { setState(() { _recentRides = rides; - if (rides.isNotEmpty) { - _selectRide(rides.first); - } }); + if (rides.isNotEmpty) { + await _selectRide(rides.first); + } } } catch (_) { // Ignore database retrieval errors on rides } } - Future _selectRide(Ride ride) async { + void _applySelectedRideMetadata(Ride ride) { _selectedRide = ride; - final dist = ride.distanceMiles > 0 ? ride.distanceMiles : ride.distance; - final durMins = ride.movingTime > 0 - ? (ride.movingTime / 60).round() - : (ride.endTime?.difference(ride.startTime).inMinutes ?? 35).clamp(1, 999); - final maxLean = math.max(ride.maxLeanLeft, ride.maxLeanRight); - final side = ride.maxLeanRight >= ride.maxLeanLeft ? 'RIGHT' : 'LEFT'; - - _titleController.text = 'Ride #${ride.id} • ${ride.startTime.month}/${ride.startTime.day} Ride'; - _distanceController.text = dist > 0 ? dist.toStringAsFixed(1) : '24.5'; - _durationController.text = durMins.toString(); - _maxLeanController.text = maxLean > 0 ? maxLean.toStringAsFixed(0) : '38'; - _selectedLeanSide = side; + _titleController.text = + 'Ride #${ride.id} • ${ride.startTime.month}/${ride.startTime.day} Ride'; if (ride.motorcycleMake != null && ride.motorcycleMake!.isNotEmpty) { _bikeController.text = - '${ride.motorcycleYear ?? ''} ${ride.motorcycleMake} ${ride.motorcycleModel ?? ''}'.trim(); + '${ride.motorcycleYear ?? ''} ${ride.motorcycleMake} ${ride.motorcycleModel ?? ''}' + .trim(); + } + } + + void _extractSamples(List samples) { + final validGps = samples + .where((s) => + s.latitude != null && + s.longitude != null && + s.latitude!.isFinite && + s.longitude!.isFinite && + (s.latitude != 0.0 || s.longitude != 0.0)) + .toList(); + + List downsampledRoute = const []; + if (validGps.isNotEmpty) { + final routeStep = + (validGps.length / 40).ceil().clamp(1, validGps.length); + downsampledRoute = [ + for (int i = 0; i < validGps.length; i += routeStep) + AppLatLng(validGps[i].latitude!, validGps[i].longitude!), + if ((validGps.length - 1) % routeStep != 0) + AppLatLng(validGps.last.latitude!, validGps.last.longitude!), + ]; } + List downsampledTelemetry = const []; + if (samples.isNotEmpty) { + final sampleStep = + (samples.length / 60).ceil().clamp(1, samples.length); + final totalIdx = math.max(1, samples.length - 1); + FeedTelemetrySample buildSample(int index) { + final s = samples[index]; + return FeedTelemetrySample( + progress: (index / totalIdx).clamp(0.0, 1.0), + speedMph: s.speed ?? 0.0, + leanAngleDeg: FeedTelemetrySample.applyMovingLeanGate( + speedMph: s.speed, + leanAngleDeg: s.leanAngle, + ), + lateralG: s.lateralG ?? 0.0, + altitudeFt: (s.altitude ?? 0.0) * 3.28084, + latitude: s.latitude, + longitude: s.longitude, + timestamp: s.timestamp, + ); + } + + downsampledTelemetry = [ + for (int i = 0; i < samples.length; i += sampleStep) buildSample(i), + if ((samples.length - 1) % sampleStep != 0) + buildSample(samples.length - 1), + ]; + } + + _selectedRoutePoints = downsampledRoute; + _selectedTelemetrySamples = downsampledTelemetry; + } + + Future _resolveStartingLocation( + List samples, + int rideId, + ) async { + final firstGps = samples.cast().firstWhere( + (s) => + s != null && + s.latitude != null && + s.longitude != null && + s.latitude!.isFinite && + s.longitude!.isFinite && + (s.latitude != 0.0 || s.longitude != 0.0), + orElse: () => null, + ); + + if (firstGps != null) { + try { + final routeProvider = ref.read(awsLocationRouteProviderInstance); + final place = await routeProvider.reverseGeocodeCoarse( + AppLatLng(firstGps.latitude!, firstGps.longitude!), + ); + if (mounted && _selectedRide?.id == rideId) { + if (place != null && place.trim().isNotEmpty) { + _locationController.text = place.trim(); + } else { + _locationController.text = 'Location unavailable'; + } + } + } catch (_) { + if (mounted && _selectedRide?.id == rideId) { + _locationController.text = 'Location unavailable'; + } + } + } else { + if (mounted && _selectedRide?.id == rideId) { + _locationController.text = 'Location unavailable'; + } + } + } + + Future _loadSamplesForRide(Ride ride) async { try { final db = ref.read(databaseProvider); final samples = await (db.select(db.telemetrySamples) ..where((t) => t.rideId.equals(ride.id)) ..orderBy([(t) => drift.OrderingTerm(expression: t.id)])) .get(); - - final validSamples = samples.where((s) => s.latitude != null && s.longitude != null).toList(); - if (validSamples.isNotEmpty) { - final step = (validSamples.length / 25).ceil().clamp(1, validSamples.length); - final downsampled = [ - for (int i = 0; i < validSamples.length; i += step) - AppLatLng(validSamples[i].latitude!, validSamples[i].longitude!) - ]; - if (mounted) { - setState(() { - _selectedRoutePoints = downsampled; - }); - } + if (mounted && _selectedRide?.id == ride.id) { + setState(() { + _extractSamples(samples); + }); + await _resolveStartingLocation(samples, ride.id); } } catch (_) {} - if (mounted) setState(() {}); + } + + Future _selectRide(Ride ride) async { + setState(() { + _applySelectedRideMetadata(ride); + _selectedRoutePoints = const []; + _selectedTelemetrySamples = const []; + }); + await _loadSamplesForRide(ride); } @override @@ -138,14 +315,15 @@ class _ShareSessionModalState extends ConsumerState { _titleController.dispose(); _locationController.dispose(); _bikeController.dispose(); - _distanceController.dispose(); - _durationController.dispose(); - _maxLeanController.dispose(); super.dispose(); } Future _handlePublish() async { - if (!_formKey.currentState!.validate() || _isSubmitting) return; + if (!_formKey.currentState!.validate() || + _isSubmitting || + _selectedRide == null) { + return; + } setState(() => _isSubmitting = true); @@ -155,9 +333,7 @@ class _ShareSessionModalState extends ConsumerState { final authorDisplayName = user?.displayName ?? 'Apex Rider'; final authorHandle = user?.email?.split('@').first ?? 'apex_rider'; - final dist = double.tryParse(_distanceController.text) ?? 35.0; - final durMins = int.tryParse(_durationController.text) ?? 45; - final lean = double.tryParse(_maxLeanController.text) ?? 45.0; + final ride = _selectedRide!; await ref.read(socialFeedServiceProvider).shareRecentRide( authorUid: authorUid, @@ -167,23 +343,26 @@ class _ShareSessionModalState extends ConsumerState { clubName: 'Bay Area Twisties Club', locationName: _locationController.text.trim().isNotEmpty ? _locationController.text.trim() - : 'Skyline Blvd & Highway 9', + : 'Location unavailable', rideTitle: _titleController.text.trim().isNotEmpty ? _titleController.text.trim() - : 'Morning Canyon Carve', + : 'Morning Ride', bikeModel: _bikeController.text.trim().isNotEmpty ? _bikeController.text.trim() : '2024 Ducati Streetfighter V4', - distanceMiles: dist, - durationSeconds: durMins * 60, - maxLeanAngleDeg: lean, - maxLeanSide: _selectedLeanSide, - paceFactor: _selectedPace, + distanceMiles: _authoritativeDistanceMiles, + durationSeconds: _authoritativeDurationSeconds, + maxLeanAngleDeg: _authoritativeMaxLeanDeg, + maxLeanSide: _authoritativeLeanSide, + paceFactor: _authoritativePaceFactor, hazardCount: 0, - elevationFt: 1840, + elevationFt: _authoritativeElevationFt, discipline: _selectedDiscipline, snapshotType: FeedSnapshotType.routeTrack, routePoints: _selectedRoutePoints, + telemetrySamples: _selectedTelemetrySamples, + authoritativeRide: ride, + rideId: ride.id, ); // Reset filter to All Activity so published session is immediately displayed @@ -224,6 +403,12 @@ class _ShareSessionModalState extends ConsumerState { @override Widget build(BuildContext context) { 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.electricCyan; + final buttonFg = isLight ? AppColors.onBrand : const Color(0xFF00363D); final bottomInset = MediaQuery.of(context).viewInsets.bottom; return ConstrainedBox( @@ -232,14 +417,14 @@ class _ShareSessionModalState extends ConsumerState { ), child: Container( decoration: BoxDecoration( - color: const Color(0xFF101419), + color: colors.surfaceCardPrimary, borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), - border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.5)), - boxShadow: const [ + border: Border.all(color: colors.surfaceBorder), + boxShadow: [ BoxShadow( - color: Colors.black87, + color: colors.glowShadow, blurRadius: 30, - offset: Offset(0, -10), + offset: const Offset(0, -10), ), ], ), @@ -264,7 +449,7 @@ class _ShareSessionModalState extends ConsumerState { width: 44, height: 4, decoration: BoxDecoration( - color: colors.outlineVariant, + color: colors.surfaceBorder, borderRadius: BorderRadius.circular(2), ), ), @@ -279,12 +464,12 @@ class _ShareSessionModalState extends ConsumerState { Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: AppColors.electricCyan.withValues(alpha: 0.15), + color: accentColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10), ), - child: const Icon( + child: Icon( Icons.add_circle_outline_rounded, - color: AppColors.electricCyan, + color: accentColor, size: 20, ), ), @@ -293,14 +478,14 @@ class _ShareSessionModalState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( + Text( 'Share Recent Session', overflow: TextOverflow.ellipsis, style: TextStyle( fontFamily: AppTypography.fontHeadline, fontSize: 16, fontWeight: FontWeight.w700, - color: Colors.white, + color: colors.textPrimary, ), ), Text( @@ -309,7 +494,7 @@ class _ShareSessionModalState extends ConsumerState { style: TextStyle( fontFamily: AppTypography.fontBody, fontSize: 12, - color: colors.outline, + color: secondaryTextColor, ), ), ], @@ -319,8 +504,8 @@ class _ShareSessionModalState extends ConsumerState { ), ), IconButton( - icon: const Icon(Icons.close_rounded, - color: Color(0xFF849396)), + icon: Icon(Icons.close_rounded, + color: secondaryTextColor), onPressed: () => Navigator.of(context).pop(), ), ], @@ -338,25 +523,25 @@ class _ShareSessionModalState extends ConsumerState { // Recent Database Rides Selector if (_recentRides.isNotEmpty) ...[ - _buildFieldLabel('SELECT RECORDED SESSION', colors), + _buildFieldLabel('SELECT RECORDED SESSION', colors, secondaryTextColor), Container( padding: const EdgeInsets.symmetric(horizontal: 14), decoration: BoxDecoration( - color: const Color(0xFF161B22), + color: colors.surfaceCardSecondary, borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppColors.electricCyan.withValues(alpha: 0.4)), + border: Border.all(color: accentColor.withValues(alpha: 0.4)), ), child: DropdownButtonHideUnderline( child: DropdownButton( key: const ValueKey('share_session_ride_picker'), value: _selectedRide, isExpanded: true, - dropdownColor: const Color(0xFF161B22), - style: const TextStyle( + dropdownColor: colors.surfaceCardSecondary, + style: TextStyle( fontFamily: AppTypography.fontHeadline, fontSize: 13, fontWeight: FontWeight.w600, - color: Colors.white, + color: colors.textPrimary, ), items: _recentRides.map((ride) { final dist = ride.distanceMiles > 0 ? ride.distanceMiles : ride.distance; @@ -365,7 +550,7 @@ class _ShareSessionModalState extends ConsumerState { value: ride, child: Row( children: [ - const Icon(Icons.two_wheeler_rounded, size: 16, color: AppColors.electricCyan), + Icon(Icons.two_wheeler_rounded, size: 16, color: accentColor), const SizedBox(width: 8), Expanded( child: Text( @@ -387,56 +572,64 @@ class _ShareSessionModalState extends ConsumerState { ], // Ride Title Field - _buildFieldLabel('SESSION / ROUTE TITLE', colors), + _buildFieldLabel('SESSION / ROUTE TITLE', colors, secondaryTextColor), TextFormField( controller: _titleController, - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontBody, fontSize: 14, - color: Colors.white, + color: colors.textPrimary, ), decoration: _buildInputDecoration( - hint: 'e.g. Sunday Skyline & Pescadero Carve'), + hint: 'e.g. Sunday Skyline & Pescadero Carve', + colors: colors, + secondaryTextColor: secondaryTextColor, + accentColor: accentColor, + ), validator: (v) => v == null || v.trim().isEmpty ? 'Enter a ride title' : null, ), const SizedBox(height: 14), // Motorcycle Model Field - _buildFieldLabel('MOTORCYCLE MODEL', colors), + _buildFieldLabel('MOTORCYCLE MODEL', colors, secondaryTextColor), TextFormField( controller: _bikeController, - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontBody, fontSize: 14, - color: Colors.white, + color: colors.textPrimary, ), decoration: _buildInputDecoration( - hint: 'e.g. 2024 Ducati Streetfighter V4'), + hint: 'e.g. 2024 Ducati Streetfighter V4', + colors: colors, + secondaryTextColor: secondaryTextColor, + accentColor: accentColor, + ), validator: (v) => v == null || v.trim().isEmpty ? 'Enter motorcycle model' : null, ), const SizedBox(height: 14), // Riding Discipline / Style Field - _buildFieldLabel('RIDING DISCIPLINE / STYLE', colors), + _buildFieldLabel('RIDING DISCIPLINE / STYLE', colors, secondaryTextColor), Container( padding: const EdgeInsets.symmetric(horizontal: 14), decoration: BoxDecoration( - color: const Color(0xFF161B22), + color: colors.surfaceCardSecondary, borderRadius: BorderRadius.circular(10), - border: Border.all(color: const Color(0xFF263238)), + border: Border.all(color: colors.surfaceBorder), ), child: DropdownButtonHideUnderline( child: DropdownButton( value: _selectedDiscipline, isExpanded: true, - dropdownColor: const Color(0xFF161B22), - style: const TextStyle( + dropdownColor: colors.surfaceCardSecondary, + style: TextStyle( fontFamily: AppTypography.fontHeadline, fontSize: 13, fontWeight: FontWeight.w600, - color: Colors.white, + color: colors.textPrimary, ), items: RidingDiscipline.values .where((d) => d != RidingDiscipline.all) @@ -455,174 +648,113 @@ class _ShareSessionModalState extends ConsumerState { ), const SizedBox(height: 14), - // Location Field - _buildFieldLabel('LOCATION / CANYON', colors), + // Starting Location Field (Privacy-safe) + _buildFieldLabel('STARTING LOCATION', colors, secondaryTextColor), TextFormField( controller: _locationController, - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontBody, fontSize: 14, - color: Colors.white, + color: colors.textPrimary, ), decoration: _buildInputDecoration( - hint: "e.g. Alice's Restaurant / Skyline"), + hint: 'e.g. Saratoga, CA or Trailhead', + colors: colors, + secondaryTextColor: secondaryTextColor, + accentColor: accentColor, + ), validator: (v) => - v == null || v.trim().isEmpty ? 'Enter location' : null, + v == null || v.trim().isEmpty ? 'Enter starting location' : null, ), const SizedBox(height: 14), - // Telemetry Metrics Grid (Distance, Duration, Lean) + // Read-Only Telemetry Metrics Grid (Distance, Duration, Lean, Side, Pace) Row( children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildFieldLabel('DIST (MI)', colors), - TextFormField( - controller: _distanceController, - keyboardType: const TextInputType.numberWithOptions( - decimal: true), - style: const TextStyle( - fontFamily: AppTypography.fontTelemetry, - fontSize: 14, - color: Colors.white, - ), - decoration: _buildInputDecoration(hint: '0.0'), - ), - ], - ), + Icon( + Icons.lock_outline_rounded, + size: 13, + color: accentColor, ), - const SizedBox(width: 10), + const SizedBox(width: 6), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildFieldLabel('TIME (MINS)', colors), - TextFormField( - controller: _durationController, - keyboardType: TextInputType.number, - style: const TextStyle( - fontFamily: AppTypography.fontTelemetry, - fontSize: 14, - color: Colors.white, - ), - decoration: _buildInputDecoration(hint: '0'), - ), - ], - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildFieldLabel('LEAN (DEG)', colors), - TextFormField( - controller: _maxLeanController, - keyboardType: const TextInputType.numberWithOptions( - decimal: true), - style: const TextStyle( - fontFamily: AppTypography.fontTelemetry, - fontSize: 14, - color: AppColors.electricCyan, - ), - decoration: _buildInputDecoration(hint: '0°'), - ), - ], + child: Text( + _selectedRide != null + ? 'RECORDED TELEMETRY (READ-ONLY)' + : 'RECORDED TELEMETRY (NO ACTIVE RIDE SELECTED)', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 10.5, + fontWeight: FontWeight.w700, + letterSpacing: 0.7, + color: accentColor, + ), ), ), ], ), - const SizedBox(height: 14), - - // Lean Side & Pace Segment Selection + const SizedBox(height: 8), Row( + key: const ValueKey('share_session_readonly_telemetry_grid'), children: [ Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildFieldLabel('LEAN APEX SIDE', colors), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: const Color(0xFF181C21), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: const Color(0xFF272A30)), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _selectedLeanSide, - isExpanded: true, - dropdownColor: const Color(0xFF181C21), - style: const TextStyle( - fontFamily: AppTypography.fontTelemetry, - fontSize: 12, - color: Colors.white, - ), - items: const [ - DropdownMenuItem( - value: 'RIGHT', child: Text('RIGHT')), - DropdownMenuItem( - value: 'LEFT', child: Text('LEFT')), - DropdownMenuItem( - value: 'PEAK', child: Text('PEAK')), - ], - onChanged: (val) { - if (val != null) { - setState(() => _selectedLeanSide = val); - } - }, - ), - ), - ), - ], + child: _buildReadOnlyTelemetryTile( + key: const ValueKey('share_session_readonly_distance'), + label: 'DIST (MI)', + value: _authoritativeDistanceMiles.toStringAsFixed(1), + colors: colors, + secondaryTextColor: secondaryTextColor, + accentColor: accentColor, ), ), const SizedBox(width: 10), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildFieldLabel('PACE FACTOR', colors), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: const Color(0xFF181C21), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: const Color(0xFF272A30)), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _selectedPace, - isExpanded: true, - dropdownColor: const Color(0xFF181C21), - style: const TextStyle( - fontFamily: AppTypography.fontTelemetry, - fontSize: 12, - color: Colors.white, - ), - items: const [ - DropdownMenuItem( - value: '1.0x Cruiser', - child: Text('1.0x Cruiser')), - DropdownMenuItem( - value: '1.25x Spirited', - child: Text('1.25x Spirited')), - DropdownMenuItem( - value: '1.45x Fast Pace', - child: Text('1.45x Fast Pace')), - ], - onChanged: (val) { - if (val != null) { - setState(() => _selectedPace = val); - } - }, - ), - ), - ), - ], + child: _buildReadOnlyTelemetryTile( + key: const ValueKey('share_session_readonly_duration'), + label: 'TIME (MINS)', + value: _authoritativeDurationMinutes.toString(), + colors: colors, + secondaryTextColor: secondaryTextColor, + accentColor: accentColor, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _buildReadOnlyTelemetryTile( + key: const ValueKey('share_session_readonly_lean'), + label: 'LEAN (DEG)', + value: '${_authoritativeMaxLeanDeg.toStringAsFixed(0)}°', + colors: colors, + secondaryTextColor: secondaryTextColor, + accentColor: accentColor, + highlight: true, + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: _buildReadOnlyTelemetryTile( + key: const ValueKey('share_session_readonly_lean_side'), + label: 'LEAN APEX SIDE', + value: _authoritativeLeanSide, + colors: colors, + secondaryTextColor: secondaryTextColor, + accentColor: accentColor, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _buildReadOnlyTelemetryTile( + key: const ValueKey('share_session_readonly_pace'), + label: 'PACE FACTOR', + value: _authoritativePaceFactor, + colors: colors, + secondaryTextColor: secondaryTextColor, + accentColor: accentColor, ), ), ], @@ -640,21 +772,22 @@ class _ShareSessionModalState extends ConsumerState { width: double.infinity, child: ElevatedButton( style: ElevatedButton.styleFrom( - backgroundColor: AppColors.electricCyan, - foregroundColor: const Color(0xFF00363D), + backgroundColor: accentColor, + foregroundColor: buttonFg, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), elevation: 4, ), - onPressed: _isSubmitting ? null : _handlePublish, + onPressed: + (_isSubmitting || _selectedRide == null) ? null : _handlePublish, child: _isSubmitting - ? const SizedBox( + ? SizedBox( width: 22, height: 22, child: CircularProgressIndicator( strokeWidth: 2.5, - color: Color(0xFF00363D), + color: buttonFg, ), ) : Row( @@ -687,44 +820,102 @@ class _ShareSessionModalState extends ConsumerState { ); } - Widget _buildFieldLabel(String label, MotorSignalThemeColors colors) { + Widget _buildReadOnlyTelemetryTile({ + required Key key, + required String label, + required String value, + required MotorSignalThemeColors colors, + required Color secondaryTextColor, + required Color accentColor, + bool highlight = false, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildFieldLabel(label, colors, secondaryTextColor), + Container( + key: key, + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11), + decoration: BoxDecoration( + color: colors.surfaceCardSecondary, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: colors.surfaceBorder), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + value, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 13, + fontWeight: FontWeight.w700, + color: highlight ? accentColor : colors.textPrimary, + ), + ), + ), + Icon( + Icons.lock_outline_rounded, + size: 13, + color: secondaryTextColor, + ), + ], + ), + ), + ], + ); + } + + Widget _buildFieldLabel( + String label, + MotorSignalThemeColors colors, + Color secondaryTextColor, + ) { return Padding( padding: const EdgeInsets.only(bottom: 6), child: Text( label, - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 0.8, - color: Color(0xFF94A3B8), + color: secondaryTextColor, ), ), ); } - InputDecoration _buildInputDecoration({required String hint}) { + InputDecoration _buildInputDecoration({ + required String hint, + required MotorSignalThemeColors colors, + required Color secondaryTextColor, + required Color accentColor, + }) { return InputDecoration( hintText: hint, - hintStyle: const TextStyle( + hintStyle: TextStyle( fontFamily: AppTypography.fontBody, fontSize: 13, - color: Color(0xFF64748B), + color: secondaryTextColor, ), filled: true, - fillColor: const Color(0xFF161B22), + fillColor: colors.surfaceCardSecondary, contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), border: OutlineInputBorder( borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: Color(0xFF263238)), + borderSide: BorderSide(color: colors.surfaceBorder), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: Color(0xFF263238)), + borderSide: BorderSide(color: colors.surfaceBorder), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: AppColors.electricCyan, width: 1.5), + borderSide: BorderSide(color: accentColor, width: 1.5), ), ); } diff --git a/lib/services/map/aws_map_provider.dart b/lib/services/map/aws_map_provider.dart index 3c00cad..adc38b0 100644 --- a/lib/services/map/aws_map_provider.dart +++ b/lib/services/map/aws_map_provider.dart @@ -4,6 +4,7 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart' as ll; import '../routes/aws_location_route_provider.dart'; import 'map_provider_interface.dart'; +import 'map_providers.dart'; /// AppMapProvider implementation backed by Amazon Location Service (AWS Maps V2 raster satellite). class AwsMapProvider implements AppMapProvider { @@ -98,8 +99,10 @@ class _AwsMapView extends StatefulWidget { State<_AwsMapView> createState() => _AwsMapViewState(); } -class _AwsMapViewState extends State<_AwsMapView> { +class _AwsMapViewState extends State<_AwsMapView> + with TickerProviderStateMixin { late final MapController _mapController; + AnimationController? _animController; ll.LatLng? _lastFollowPos; DateTime? _lastFollowTime; @@ -107,6 +110,56 @@ class _AwsMapViewState extends State<_AwsMapView> { void initState() { super.initState(); _mapController = MapController(); + _lastFollowPos = ll.LatLng(widget.center.latitude, widget.center.longitude); + } + + void _animateCameraTo({ + required ll.LatLng target, + required double targetZoom, + required double targetBearing, + required Duration duration, + Curve curve = Curves.easeOutCubic, + }) { + if (!mounted) return; + + _animController?.stop(); + _animController?.dispose(); + _animController = null; + + final startCenter = _mapController.camera.center; + final startZoom = _mapController.camera.zoom; + final startBearing = _mapController.camera.rotation; + + final bearingDiff = shortestAngleDiff(startBearing, targetBearing); + final endBearing = startBearing + bearingDiff; + + _animController = AnimationController( + vsync: this, + duration: duration, + ); + + final curved = CurvedAnimation(parent: _animController!, curve: curve); + + final startLat = startCenter.latitude; + final startLng = startCenter.longitude; + final endLat = target.latitude; + final endLng = target.longitude; + + _animController!.addListener(() { + if (!mounted) return; + final t = curved.value; + final lat = startLat + (endLat - startLat) * t; + final lng = startLng + (endLng - startLng) * t; + final z = startZoom + (targetZoom - startZoom) * t; + final b = startBearing + (endBearing - startBearing) * t; + + try { + _mapController.move(ll.LatLng(lat, lng), z); + _mapController.rotate(normalizeAngle(b)); + } catch (_) {} + }); + + _animController!.forward(); } @override @@ -124,48 +177,56 @@ class _AwsMapViewState extends State<_AwsMapView> { ? 1.0 : (centerLl.latitude - _lastFollowPos!.latitude).abs() + (centerLl.longitude - _lastFollowPos!.longitude).abs(); + final hasMoved = posDelta > 0.000005; - final shouldMove = justBecameFollowMode || - _lastFollowPos == null || - zoomChanged || - (posDelta > 0.00005 && - (_lastFollowTime == null || - now.difference(_lastFollowTime!).inMilliseconds > 500)); + final intervalMs = _lastFollowTime == null + ? 250 + : now.difference(_lastFollowTime!).inMilliseconds.clamp(100, 450); - if (shouldMove) { + final isInitialOrLargeJump = + justBecameFollowMode || _lastFollowPos == null || posDelta > 0.008; + + if (isInitialOrLargeJump) { + _animController?.stop(); _lastFollowPos = centerLl; _lastFollowTime = now; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - try { - _mapController.move(centerLl, widget.zoom); - } catch (_) {} - } - }); + try { + _mapController.move(centerLl, widget.zoom); + _mapController.rotate(normalizeAngle(widget.bearing)); + } catch (_) {} + } else if (hasMoved || bearingChanged || zoomChanged) { + _lastFollowPos = centerLl; + _lastFollowTime = now; + _animateCameraTo( + target: centerLl, + targetZoom: widget.zoom, + targetBearing: widget.bearing, + duration: Duration(milliseconds: intervalMs), + curve: Curves.linear, + ); + } + } else { + if (bearingChanged) { + _animateCameraTo( + target: _mapController.camera.center, + targetZoom: widget.zoom, + targetBearing: widget.bearing, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + ); + } else if (zoomChanged) { + try { + _mapController.move(_mapController.camera.center, widget.zoom); + } catch (_) {} } - } else if (zoomChanged) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - try { - _mapController.move(_mapController.camera.center, widget.zoom); - } catch (_) {} - } - }); - } - - if (bearingChanged) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - try { - _mapController.rotate(widget.bearing); - } catch (_) {} - } - }); } } @override void dispose() { + _animController?.stop(); + _animController?.dispose(); + _animController = null; _mapController.dispose(); super.dispose(); } diff --git a/lib/services/map/google_map_provider.dart b/lib/services/map/google_map_provider.dart index 4cd2bf4..2528a11 100644 --- a/lib/services/map/google_map_provider.dart +++ b/lib/services/map/google_map_provider.dart @@ -2,10 +2,9 @@ import 'package:flutter/material.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart' as gmaps; import '../../core/theme/map_style.dart'; import 'map_provider_interface.dart'; +import 'map_providers.dart'; class GoogleMapProvider implements AppMapProvider { - gmaps.GoogleMapController? _controller; - @override AppMapType get type => AppMapType.googleMaps; @@ -25,15 +24,122 @@ class GoogleMapProvider implements AppMapProvider { required ValueChanged? onPositionChanged, required VoidCallback? onUserInteraction, }) { - final target = gmaps.LatLng(center.latitude, center.longitude); + return _GoogleMapView( + center: center, + zoom: zoom, + bearing: bearing, + markers: markers, + polylines: polylines, + isFollowMode: isFollowMode, + onPositionChanged: onPositionChanged, + onUserInteraction: onUserInteraction, + ); + } +} - if (isFollowMode && _controller != null) { - _controller!.animateCamera( - gmaps.CameraUpdate.newLatLngZoom(target, zoom), +class _GoogleMapView extends StatefulWidget { + final AppLatLng center; + final double zoom; + final double bearing; + final List markers; + final List polylines; + final bool isFollowMode; + final ValueChanged? onPositionChanged; + final VoidCallback? onUserInteraction; + + const _GoogleMapView({ + required this.center, + required this.zoom, + this.bearing = 0.0, + required this.markers, + required this.polylines, + required this.isFollowMode, + required this.onPositionChanged, + required this.onUserInteraction, + }); + + @override + State<_GoogleMapView> createState() => _GoogleMapViewState(); +} + +class _GoogleMapViewState extends State<_GoogleMapView> { + gmaps.GoogleMapController? _controller; + gmaps.LatLng? _lastFollowPos; + DateTime? _lastFollowTime; + bool _isProgrammaticMove = false; + + static const int _followThrottleMs = 250; + + @override + void initState() { + super.initState(); + _lastFollowPos = gmaps.LatLng(widget.center.latitude, widget.center.longitude); + } + + Future _moveCamera(gmaps.LatLng target, double zoom, double bearing) async { + final controller = _controller; + if (controller == null) return; + _lastFollowPos = target; + _lastFollowTime = DateTime.now(); + _isProgrammaticMove = true; + try { + await controller.animateCamera( + gmaps.CameraUpdate.newCameraPosition( + gmaps.CameraPosition( + target: target, + zoom: zoom, + bearing: normalizeAngle(bearing), + ), + ), ); + } catch (_) { + } finally { + _isProgrammaticMove = false; } + } - final gMarkers = markers.map((m) { + @override + void didUpdateWidget(covariant _GoogleMapView oldWidget) { + super.didUpdateWidget(oldWidget); + if (_controller == null) return; + + final target = gmaps.LatLng(widget.center.latitude, widget.center.longitude); + final zoomChanged = (oldWidget.zoom - widget.zoom).abs() > 0.01; + final bearingChanged = (oldWidget.bearing - widget.bearing).abs() > 0.1; + final justBecameFollowMode = !oldWidget.isFollowMode && widget.isFollowMode; + + if (widget.isFollowMode) { + final now = DateTime.now(); + final posDelta = _lastFollowPos == null + ? 1.0 + : (target.latitude - _lastFollowPos!.latitude).abs() + + (target.longitude - _lastFollowPos!.longitude).abs(); + final hasMoved = posDelta > 0.00001; + final elapsedMs = _lastFollowTime == null + ? _followThrottleMs + 1 + : now.difference(_lastFollowTime!).inMilliseconds; + + if (justBecameFollowMode || + _lastFollowPos == null || + zoomChanged || + bearingChanged || + (hasMoved && (elapsedMs >= _followThrottleMs || posDelta > 0.001))) { + _moveCamera(target, widget.zoom, widget.bearing); + } + } + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final target = gmaps.LatLng(widget.center.latitude, widget.center.longitude); + + final gMarkers = widget.markers.map((m) { return gmaps.Marker( markerId: gmaps.MarkerId(m.id), position: gmaps.LatLng(m.position.latitude, m.position.longitude), @@ -42,7 +148,7 @@ class GoogleMapProvider implements AppMapProvider { ); }).toSet(); - final gPolylines = polylines.map((p) { + final gPolylines = widget.polylines.map((p) { return gmaps.Polyline( polylineId: gmaps.PolylineId(p.id), points: p.points.map((pt) => gmaps.LatLng(pt.latitude, pt.longitude)).toList(), @@ -58,7 +164,8 @@ class GoogleMapProvider implements AppMapProvider { key: ValueKey('google_map_$sizeKey'), initialCameraPosition: gmaps.CameraPosition( target: target, - zoom: zoom, + zoom: widget.zoom, + bearing: widget.bearing, ), markers: gMarkers, polylines: gPolylines, @@ -68,13 +175,21 @@ class GoogleMapProvider implements AppMapProvider { compassEnabled: false, onMapCreated: (controller) { _controller = controller; + if (widget.isFollowMode) { + _moveCamera(target, widget.zoom, widget.bearing); + } }, onCameraMoveStarted: () { - onUserInteraction?.call(); + if (!_isProgrammaticMove) { + widget.onUserInteraction?.call(); + } }, onCameraMove: (position) { - onPositionChanged?.call(AppLatLng(position.target.latitude, position.target.longitude)); + widget.onPositionChanged?.call( + AppLatLng(position.target.latitude, position.target.longitude), + ); }, ); } } + diff --git a/lib/services/map/map_providers.dart b/lib/services/map/map_providers.dart index a57e2c1..9f45c87 100644 --- a/lib/services/map/map_providers.dart +++ b/lib/services/map/map_providers.dart @@ -94,3 +94,69 @@ final mapFollowStateProvider = StateNotifierProvider((ref) { return MapFollowNotifier(); }); + +/// Map orientation mode: North Up vs Heading Up. +enum MapOrientation { + northUp, + headingUp; + + String get displayName => this == northUp ? 'North Up' : 'Heading Up'; +} + +/// Computes the shortest angular difference in degrees from [from] to [to]. +/// Result is in range (-180.0, 180.0]. +/// Handles circular transitions across the 0°/360° discontinuity: +/// e.g. from 359° to 1° yields +2°, not -358°. +double shortestAngleDiff(double from, double to) { + return ((to - from + 540.0) % 360.0) - 180.0; +} + +/// Normalizes an angle in degrees into [0.0, 360.0). +double normalizeAngle(double angle) { + return (angle % 360.0 + 360.0) % 360.0; +} + +class MapOrientationNotifier extends StateNotifier { + static const String _prefKey = 'selected_map_orientation'; + + MapOrientationNotifier() : super(MapOrientation.northUp) { + _loadFromPrefs(); + } + + Future _loadFromPrefs() async { + try { + final prefs = await SharedPreferences.getInstance(); + final savedVal = prefs.getString(_prefKey); + if (savedVal == 'headingUp') { + state = MapOrientation.headingUp; + } else if (savedVal == 'northUp') { + state = MapOrientation.northUp; + } + } catch (_) {} + } + + Future toggle() async { + final next = state == MapOrientation.northUp + ? MapOrientation.headingUp + : MapOrientation.northUp; + state = next; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefKey, next.name); + } catch (_) {} + } + + Future setOrientation(MapOrientation orientation) async { + state = orientation; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefKey, orientation.name); + } catch (_) {} + } +} + +final mapOrientationProvider = + StateNotifierProvider((ref) { + return MapOrientationNotifier(); +}); + diff --git a/lib/services/map/open_street_map_provider.dart b/lib/services/map/open_street_map_provider.dart index 6884da6..6b6ff6d 100644 --- a/lib/services/map/open_street_map_provider.dart +++ b/lib/services/map/open_street_map_provider.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart' as ll; import 'map_provider_interface.dart'; +import 'map_providers.dart'; class OpenStreetMapProvider implements AppMapProvider { @override @@ -65,8 +66,10 @@ class _OsmMapView extends StatefulWidget { State<_OsmMapView> createState() => _OsmMapViewState(); } -class _OsmMapViewState extends State<_OsmMapView> { +class _OsmMapViewState extends State<_OsmMapView> + with TickerProviderStateMixin { late final MapController _mapController; + AnimationController? _animController; ll.LatLng? _lastFollowPos; DateTime? _lastFollowTime; @@ -74,6 +77,56 @@ class _OsmMapViewState extends State<_OsmMapView> { void initState() { super.initState(); _mapController = MapController(); + _lastFollowPos = ll.LatLng(widget.center.latitude, widget.center.longitude); + } + + void _animateCameraTo({ + required ll.LatLng target, + required double targetZoom, + required double targetBearing, + required Duration duration, + Curve curve = Curves.easeOutCubic, + }) { + if (!mounted) return; + + _animController?.stop(); + _animController?.dispose(); + _animController = null; + + final startCenter = _mapController.camera.center; + final startZoom = _mapController.camera.zoom; + final startBearing = _mapController.camera.rotation; + + final bearingDiff = shortestAngleDiff(startBearing, targetBearing); + final endBearing = startBearing + bearingDiff; + + _animController = AnimationController( + vsync: this, + duration: duration, + ); + + final curved = CurvedAnimation(parent: _animController!, curve: curve); + + final startLat = startCenter.latitude; + final startLng = startCenter.longitude; + final endLat = target.latitude; + final endLng = target.longitude; + + _animController!.addListener(() { + if (!mounted) return; + final t = curved.value; + final lat = startLat + (endLat - startLat) * t; + final lng = startLng + (endLng - startLng) * t; + final z = startZoom + (targetZoom - startZoom) * t; + final b = startBearing + (endBearing - startBearing) * t; + + try { + _mapController.move(ll.LatLng(lat, lng), z); + _mapController.rotate(normalizeAngle(b)); + } catch (_) {} + }); + + _animController!.forward(); } @override @@ -90,48 +143,56 @@ class _OsmMapViewState extends State<_OsmMapView> { ? 1.0 : (centerLl.latitude - _lastFollowPos!.latitude).abs() + (centerLl.longitude - _lastFollowPos!.longitude).abs(); + final hasMoved = posDelta > 0.000005; - final shouldMove = justBecameFollowMode || - _lastFollowPos == null || - zoomChanged || - (posDelta > 0.00005 && - (_lastFollowTime == null || - now.difference(_lastFollowTime!).inMilliseconds > 500)); + final intervalMs = _lastFollowTime == null + ? 250 + : now.difference(_lastFollowTime!).inMilliseconds.clamp(100, 450); - if (shouldMove) { + final isInitialOrLargeJump = + justBecameFollowMode || _lastFollowPos == null || posDelta > 0.008; + + if (isInitialOrLargeJump) { + _animController?.stop(); _lastFollowPos = centerLl; _lastFollowTime = now; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - try { - _mapController.move(centerLl, widget.zoom); - } catch (_) {} - } - }); + try { + _mapController.move(centerLl, widget.zoom); + _mapController.rotate(normalizeAngle(widget.bearing)); + } catch (_) {} + } else if (hasMoved || bearingChanged || zoomChanged) { + _lastFollowPos = centerLl; + _lastFollowTime = now; + _animateCameraTo( + target: centerLl, + targetZoom: widget.zoom, + targetBearing: widget.bearing, + duration: Duration(milliseconds: intervalMs), + curve: Curves.linear, + ); + } + } else { + if (bearingChanged) { + _animateCameraTo( + target: _mapController.camera.center, + targetZoom: widget.zoom, + targetBearing: widget.bearing, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + ); + } else if (zoomChanged) { + try { + _mapController.move(_mapController.camera.center, widget.zoom); + } catch (_) {} } - } else if (zoomChanged) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - try { - _mapController.move(_mapController.camera.center, widget.zoom); - } catch (_) {} - } - }); - } - - if (bearingChanged) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - try { - _mapController.rotate(widget.bearing); - } catch (_) {} - } - }); } } @override void dispose() { + _animController?.stop(); + _animController?.dispose(); + _animController = null; _mapController.dispose(); super.dispose(); } diff --git a/lib/services/routes/aws_location_route_provider.dart b/lib/services/routes/aws_location_route_provider.dart index 2f7a5e6..06ff35d 100644 --- a/lib/services/routes/aws_location_route_provider.dart +++ b/lib/services/routes/aws_location_route_provider.dart @@ -253,6 +253,66 @@ class AwsLocationRouteProvider implements RouteProvider { } } + /// Privacy-safe coarse reverse geocode for a geographic point. + /// + /// Returns a coarse region label (e.g. "Saratoga, CA") suitable for privacy-safe + /// starting location labels without exposing house numbers, street names, or exact GPS coordinates. + /// + /// Returns null if unavailable or if reverse-geocoding fails. + Future reverseGeocodeCoarse(AppLatLng position) async { + try { + final uri = Uri.parse( + 'https://places.geo.$region.amazonaws.com/v2/reverse-geocode?key=$apiKey', + ); + final response = await _client + .post( + uri, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'QueryPosition': [position.longitude, position.latitude], + 'Filter': { + 'IncludeCountries': ['USA'], + }, + }), + ) + .timeout(const Duration(seconds: 6)); + + if (response.statusCode != 200) return null; + + final data = jsonDecode(response.body); + final items = data['ResultItems'] as List? ?? []; + if (items.isEmpty) return null; + + final first = items.first as Map; + final address = first['Address'] as Map?; + if (address == null) return null; + + // Extract coarse locality and region/state — strictly avoid street addresses or exact house numbers + final locality = address['Locality']?.toString().trim(); + final regionStr = address['Region']?.toString().trim(); + final subRegion = address['SubRegion']?.toString().trim(); + + if (locality != null && + locality.isNotEmpty && + regionStr != null && + regionStr.isNotEmpty) { + return '$locality, $regionStr'; + } else if (locality != null && locality.isNotEmpty) { + return locality; + } else if (subRegion != null && + subRegion.isNotEmpty && + regionStr != null && + regionStr.isNotEmpty) { + return '$subRegion, $regionStr'; + } else if (regionStr != null && regionStr.isNotEmpty) { + return regionStr; + } + return null; + } catch (_) { + return null; + } + } + @override Future calculateRoute( List stops, { diff --git a/lib/services/social/social_feed_models.dart b/lib/services/social/social_feed_models.dart index 789741d..b61b564 100644 --- a/lib/services/social/social_feed_models.dart +++ b/lib/services/social/social_feed_models.dart @@ -1,5 +1,6 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter/foundation.dart'; +import 'package:geolocator/geolocator.dart'; import '../map/map_provider_interface.dart'; DateTime? _parseDateTime(dynamic value) { @@ -134,10 +135,103 @@ enum FeedSnapshotType { scenicPhoto, } +/// A recorded telemetry sample point attached to a shared social feed session. +@immutable +class FeedTelemetrySample { + /// Matches RideStateMachine._processStats 3.0 MPH moving gate and 65.0° + /// physical bound so stationary kickstand/dismount frames never enter + /// published lean traces or apex calculations. + static const double movingLeanGateSpeedMph = 3.0; + static const double maxPhysicalLeanDeg = 65.0; + + static double applyMovingLeanGate({ + required double? speedMph, + required double? leanAngleDeg, + }) { + if (speedMph == null || + !speedMph.isFinite || + speedMph < movingLeanGateSpeedMph || + leanAngleDeg == null || + !leanAngleDeg.isFinite || + leanAngleDeg.abs() > maxPhysicalLeanDeg) { + return 0.0; + } + return leanAngleDeg; + } + + final double progress; // 0.0 to 1.0 along the session + final double speedMph; + final double leanAngleDeg; // signed degrees (-left, +right) + final double lateralG; + final double altitudeFt; + final double? latitude; + final double? longitude; + final DateTime? timestamp; + + const FeedTelemetrySample({ + required this.progress, + required this.speedMph, + required this.leanAngleDeg, + this.lateralG = 0.0, + this.altitudeFt = 0.0, + this.latitude, + this.longitude, + this.timestamp, + }); + + FeedTelemetrySample withMovingLeanGate() { + final gatedLean = applyMovingLeanGate( + speedMph: speedMph, + leanAngleDeg: leanAngleDeg, + ); + if (gatedLean == leanAngleDeg) return this; + return FeedTelemetrySample( + progress: progress, + speedMph: speedMph, + leanAngleDeg: gatedLean, + lateralG: lateralG, + altitudeFt: altitudeFt, + latitude: latitude, + longitude: longitude, + timestamp: timestamp, + ); + } + + Map toJson() => { + 'progress': progress, + 'speedMph': speedMph, + 'leanAngleDeg': leanAngleDeg, + 'lateralG': lateralG, + 'altitudeFt': altitudeFt, + if (latitude != null) 'lat': latitude, + if (longitude != null) 'lng': longitude, + if (timestamp != null) 'timestamp': _dateTimeToFirestore(timestamp), + }; + + factory FeedTelemetrySample.fromJson(Map json) { + return FeedTelemetrySample( + progress: ((json['progress'] as num?)?.toDouble() ?? 0.0).clamp(0.0, 1.0), + speedMph: (json['speedMph'] as num?)?.toDouble() ?? 0.0, + leanAngleDeg: (json['leanAngleDeg'] as num?)?.toDouble() ?? 0.0, + lateralG: (json['lateralG'] as num?)?.toDouble() ?? 0.0, + altitudeFt: (json['altitudeFt'] as num?)?.toDouble() ?? 0.0, + latitude: (json['lat'] as num?)?.toDouble(), + longitude: (json['lng'] as num?)?.toDouble(), + timestamp: _parseDateTime(json['timestamp']), + ); + } +} + /// A community social feed item (Stitch SOC-01). @immutable class SocialFeedItem { + /// Minimum geodesic displacement required for GPS verification: 0.05 miles (~80.47 meters). + static const double minimumVerifiedDisplacementMiles = 0.05; + static const double minimumVerifiedDisplacementMeters = + minimumVerifiedDisplacementMiles * 1609.344; + final String id; + final int? rideId; final String authorUid; final String authorDisplayName; final String authorHandle; @@ -153,7 +247,7 @@ class SocialFeedItem { final double distanceMiles; final int durationSeconds; final double maxLeanAngleDeg; - final String maxLeanSide; // 'RIGHT', 'LEFT', 'PEAK' + final String maxLeanSide; // 'RIGHT', 'LEFT', 'PEAK', 'NONE' final String paceFactor; // e.g. '1.32x Spirited' final int hazardCount; // e.g. 0 final int elevationFt; // e.g. +2140 @@ -163,9 +257,10 @@ class SocialFeedItem { final String? regionName; final bool isFeatured; - // Visual Snapshot Module + // Visual Snapshot & Recorded Telemetry Module final FeedSnapshotType snapshotType; final List routePoints; + final List telemetrySamples; final String? photoUrl; final String? photoCaption; final bool isGpsVerified; @@ -178,6 +273,7 @@ class SocialFeedItem { const SocialFeedItem({ required this.id, + this.rideId, required this.authorUid, required this.authorDisplayName, required this.authorHandle, @@ -194,21 +290,164 @@ class SocialFeedItem { this.maxLeanSide = 'RIGHT', this.paceFactor = '1.2x Spirited', this.hazardCount = 0, - this.elevationFt = 1200, + this.elevationFt = 0, this.discipline = RidingDiscipline.canyonSport, this.regionName, this.isFeatured = false, this.snapshotType = FeedSnapshotType.routeTrack, this.routePoints = const [], + this.telemetrySamples = const [], this.photoUrl, this.photoCaption, - this.isGpsVerified = true, + this.isGpsVerified = false, this.isDemo = false, this.fistBumpCount = 0, this.fistBumpUserIds = const [], this.comments = const [], }); + /// Evaluates whether a set of ride metrics, GPS route coordinates, and + /// recorded telemetry samples satisfy authentic GPS verification criteria. + static bool verifyGpsCriteria({ + required bool isDemo, + required double distanceMiles, + required int durationSeconds, + required List routePoints, + List telemetrySamples = const [], + }) { + if (isDemo) return false; + if (!distanceMiles.isFinite || distanceMiles <= 0.0) return false; + if (durationSeconds <= 0) return false; + if (telemetrySamples.length < 2) return false; + + double? firstProgress; + double? prevProgress; + DateTime? firstTimestamp; + DateTime? prevTimestamp; + int timestampCount = 0; + final validSamplePoints = []; + + for (final sample in telemetrySamples) { + if (!sample.progress.isFinite || + sample.progress < 0.0 || + sample.progress > 1.0 || + !sample.speedMph.isFinite || + sample.speedMph < 0.0 || + !sample.leanAngleDeg.isFinite || + !sample.lateralG.isFinite || + !sample.altitudeFt.isFinite) { + return false; + } + if (prevProgress != null && sample.progress < prevProgress) { + return false; + } + firstProgress ??= sample.progress; + prevProgress = sample.progress; + + if (sample.timestamp != null) { + if (prevTimestamp != null && + sample.timestamp!.isBefore(prevTimestamp)) { + return false; + } + firstTimestamp ??= sample.timestamp; + prevTimestamp = sample.timestamp; + timestampCount++; + } + + final lat = sample.latitude; + final lng = sample.longitude; + if (lat != null && + lng != null && + lat.isFinite && + lng.isFinite && + lat.abs() <= 90.0 && + lng.abs() <= 180.0 && + (lat != 0.0 || lng != 0.0)) { + validSamplePoints.add(AppLatLng(lat, lng)); + } + } + + // Require a positive recorded span across telemetry samples. + if (firstProgress == null || + prevProgress == null || + (prevProgress - firstProgress) <= 0.0) { + return false; + } + + if (timestampCount > 0) { + if (timestampCount < 2 || + firstTimestamp == null || + prevTimestamp == null || + prevTimestamp.difference(firstTimestamp).inMilliseconds <= 0) { + return false; + } + } + + if (!_hasMinimumGeodesicDisplacement(validSamplePoints)) { + return false; + } + + final validPoints = routePoints.where((p) { + return p.latitude.isFinite && + p.longitude.isFinite && + p.latitude.abs() <= 90.0 && + p.longitude.abs() <= 180.0 && + (p.latitude != 0.0 || p.longitude != 0.0); + }).toList(); + + return _hasMinimumGeodesicDisplacement(validPoints); + } + + /// Require at least two coordinates separated by >= 0.05 miles (~80.47 m) + /// measured geodesically via [Geolocator.distanceBetween]. + static bool _hasMinimumGeodesicDisplacement(List points) { + if (points.length < 2) return false; + for (int i = 0; i < points.length - 1; i++) { + final origin = points[i]; + for (int j = i + 1; j < points.length; j++) { + final target = points[j]; + final distMeters = Geolocator.distanceBetween( + origin.latitude, + origin.longitude, + target.latitude, + target.longitude, + ); + if (distMeters.isFinite && + distMeters >= minimumVerifiedDisplacementMeters) { + return true; + } + } + } + return false; + } + + /// True only when this post is flagged as GPS-verified AND backed by valid, + /// non-demo GPS route coordinates, recorded telemetry samples, positive + /// distance, and positive duration. + bool get hasVerifiedGps => + isGpsVerified && + verifyGpsCriteria( + isDemo: isDemo, + distanceMiles: distanceMiles, + durationSeconds: durationSeconds, + routePoints: routePoints, + telemetrySamples: telemetrySamples, + ); + + /// True when the post is a non-demo ride containing recorded route coordinates that can be plotted. + bool get hasRecordedRoute => + !isDemo && + routePoints + .where((p) => + p.latitude.isFinite && + p.longitude.isFinite && + (p.latitude != 0.0 || p.longitude != 0.0)) + .length >= + 2; + + /// True when the post contains recorded telemetry waveform samples. + bool get hasRecordedTelemetry => !isDemo && telemetrySamples.length >= 2; + bool isFistBumpedBy(String? uid) { if (uid == null) return false; return fistBumpUserIds.contains(uid); @@ -216,6 +455,7 @@ class SocialFeedItem { SocialFeedItem copyWith({ String? id, + int? rideId, String? authorUid, String? authorDisplayName, String? authorHandle, @@ -238,6 +478,7 @@ class SocialFeedItem { bool? isFeatured, FeedSnapshotType? snapshotType, List? routePoints, + List? telemetrySamples, String? photoUrl, String? photoCaption, bool? isGpsVerified, @@ -248,6 +489,7 @@ class SocialFeedItem { }) { return SocialFeedItem( id: id ?? this.id, + rideId: rideId ?? this.rideId, authorUid: authorUid ?? this.authorUid, authorDisplayName: authorDisplayName ?? this.authorDisplayName, authorHandle: authorHandle ?? this.authorHandle, @@ -270,6 +512,7 @@ class SocialFeedItem { isFeatured: isFeatured ?? this.isFeatured, snapshotType: snapshotType ?? this.snapshotType, routePoints: routePoints ?? this.routePoints, + telemetrySamples: telemetrySamples ?? this.telemetrySamples, photoUrl: photoUrl ?? this.photoUrl, photoCaption: photoCaption ?? this.photoCaption, isGpsVerified: isGpsVerified ?? this.isGpsVerified, @@ -282,6 +525,7 @@ class SocialFeedItem { Map toJson() => { 'id': id, + if (rideId != null) 'rideId': rideId, 'authorUid': authorUid, 'authorDisplayName': authorDisplayName, 'authorHandle': authorHandle, @@ -303,10 +547,14 @@ class SocialFeedItem { if (regionName != null) 'regionName': regionName, 'isFeatured': isFeatured, 'snapshotType': snapshotType.name, - 'routePoints': routePoints.map((p) => {'lat': p.latitude, 'lng': p.longitude}).toList(), + 'routePoints': routePoints + .map((p) => {'lat': p.latitude, 'lng': p.longitude}) + .toList(), + if (telemetrySamples.isNotEmpty) + 'telemetrySamples': telemetrySamples.map((s) => s.toJson()).toList(), if (photoUrl != null) 'photoUrl': photoUrl, if (photoCaption != null) 'photoCaption': photoCaption, - 'isGpsVerified': isGpsVerified, + 'isGpsVerified': hasVerifiedGps, 'isDemo': isDemo, 'fistBumpCount': fistBumpCount, 'fistBumpUserIds': fistBumpUserIds, @@ -326,14 +574,35 @@ class SocialFeedItem { } } + final telemetryList = json['telemetrySamples'] as List? ?? []; + final samples = telemetryList + .whereType() + .map((s) => FeedTelemetrySample.fromJson(Map.from(s)) + .withMovingLeanGate()) + .toList(); + final commentList = json['comments'] as List? ?? []; final comments = commentList .whereType>() .map((c) => FeedPostComment.fromJson(c)) .toList(); + final distanceMiles = (json['distanceMiles'] as num?)?.toDouble() ?? 0.0; + final durationSeconds = (json['durationSeconds'] as num?)?.toInt() ?? 0; + final isDemo = json['isDemo'] as bool? ?? false; + final rawVerified = json['isGpsVerified'] as bool? ?? false; + final verified = rawVerified && + verifyGpsCriteria( + isDemo: isDemo, + distanceMiles: distanceMiles, + durationSeconds: durationSeconds, + routePoints: points, + telemetrySamples: samples, + ); + return SocialFeedItem( id: json['id'] as String? ?? '', + rideId: (json['rideId'] as num?)?.toInt(), authorUid: json['authorUid'] as String? ?? '', authorDisplayName: json['authorDisplayName'] as String? ?? 'Rider', authorHandle: json['authorHandle'] as String? ?? 'rider', @@ -344,8 +613,8 @@ class SocialFeedItem { timestamp: _parseDateTime(json['timestamp']) ?? DateTime.now(), rideTitle: json['rideTitle'] as String? ?? 'Afternoon Carve', bikeModel: json['bikeModel'] as String? ?? 'Motorcycle', - distanceMiles: (json['distanceMiles'] as num?)?.toDouble() ?? 0.0, - durationSeconds: (json['durationSeconds'] as num?)?.toInt() ?? 0, + distanceMiles: distanceMiles, + durationSeconds: durationSeconds, maxLeanAngleDeg: (json['maxLeanAngleDeg'] as num?)?.toDouble() ?? 0.0, maxLeanSide: json['maxLeanSide'] as String? ?? 'RIGHT', paceFactor: json['paceFactor'] as String? ?? '1.0x', @@ -363,13 +632,16 @@ class SocialFeedItem { ? FeedSnapshotType.scenicPhoto : FeedSnapshotType.routeTrack, routePoints: points, + telemetrySamples: samples, photoUrl: json['photoUrl'] as String?, photoCaption: json['photoCaption'] as String?, - isGpsVerified: json['isGpsVerified'] as bool? ?? true, - isDemo: json['isDemo'] as bool? ?? false, + isGpsVerified: verified, + isDemo: isDemo, fistBumpCount: (json['fistBumpCount'] as num?)?.toInt() ?? 0, - fistBumpUserIds: (json['fistBumpUserIds'] as List?)?.cast() ?? [], + fistBumpUserIds: + (json['fistBumpUserIds'] as List?)?.cast() ?? [], comments: comments, ); } } + diff --git a/lib/services/social/social_feed_service.dart b/lib/services/social/social_feed_service.dart index 3ed6294..7f3a5d3 100644 --- a/lib/services/social/social_feed_service.dart +++ b/lib/services/social/social_feed_service.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:cloud_firestore/cloud_firestore.dart'; +import '../../database/database.dart'; import '../map/map_provider_interface.dart'; import 'social_feed_models.dart'; @@ -363,7 +365,7 @@ class SocialFeedService { result = result.where((i) => i.distanceMiles >= minDistanceMiles).toList(); } if (verifiedGpsOnly == true) { - result = result.where((i) => i.isGpsVerified).toList(); + result = result.where((i) => i.hasVerifiedGps).toList(); } return result; @@ -433,6 +435,11 @@ class SocialFeedService { } /// Share a recent completed ride session to the community feed. + /// + /// When [authoritativeRide] is provided, telemetry metrics (distance, + /// duration, max lean angle, lean side, elevation gain, and ride ID) are + /// derived strictly from [authoritativeRide] so manual/forged telemetry + /// overrides cannot be injected at the submission layer. Future shareRecentRide({ required String authorUid, required String authorDisplayName, @@ -443,21 +450,76 @@ class SocialFeedService { required String locationName, required String rideTitle, required String bikeModel, - required double distanceMiles, - required int durationSeconds, - required double maxLeanAngleDeg, + double distanceMiles = 0.0, + int durationSeconds = 0, + double maxLeanAngleDeg = 0.0, String maxLeanSide = 'RIGHT', - String paceFactor = '1.2x Spirited', + String paceFactor = '1.0x Cruiser', int hazardCount = 0, - int elevationFt = 1500, + int elevationFt = 0, RidingDiscipline discipline = RidingDiscipline.canyonSport, String? regionName, bool isFeatured = false, FeedSnapshotType snapshotType = FeedSnapshotType.routeTrack, List routePoints = const [], + List telemetrySamples = const [], + Ride? authoritativeRide, + int? rideId, + bool? isGpsVerified, }) async { + double resolvedDistance = distanceMiles; + int resolvedDurationSeconds = durationSeconds; + double resolvedMaxLean = maxLeanAngleDeg; + String resolvedLeanSide = maxLeanSide; + int resolvedElevationFt = elevationFt; + int? resolvedRideId = rideId; + + if (authoritativeRide != null) { + resolvedRideId = authoritativeRide.id; + resolvedDistance = authoritativeRide.distanceMiles > 0 + ? authoritativeRide.distanceMiles + : authoritativeRide.distance; + resolvedDurationSeconds = authoritativeRide.movingTime > 0 + ? authoritativeRide.movingTime + : (authoritativeRide.endTime != null + ? authoritativeRide.endTime! + .difference(authoritativeRide.startTime) + .inSeconds + .clamp(0, 864000) + : 0); + resolvedMaxLean = math.max( + authoritativeRide.maxLeanLeft, + authoritativeRide.maxLeanRight, + ); + if (authoritativeRide.maxLeanLeft == 0 && + authoritativeRide.maxLeanRight == 0) { + resolvedLeanSide = 'NONE'; + } else { + resolvedLeanSide = + authoritativeRide.maxLeanRight >= authoritativeRide.maxLeanLeft + ? 'RIGHT' + : 'LEFT'; + } + resolvedElevationFt = + (authoritativeRide.elevationGain * 3.28084).round(); + } + + final gatedTelemetrySamples = telemetrySamples + .map((s) => s.withMovingLeanGate()) + .toList(growable: false); + + final meetsGpsVerification = SocialFeedItem.verifyGpsCriteria( + isDemo: false, + distanceMiles: resolvedDistance, + durationSeconds: resolvedDurationSeconds, + routePoints: routePoints, + telemetrySamples: gatedTelemetrySamples, + ); + final effectiveGpsVerified = (isGpsVerified ?? true) && meetsGpsVerification; + final newPost = SocialFeedItem( id: 'feed_post_${DateTime.now().millisecondsSinceEpoch}', + rideId: resolvedRideId, authorUid: authorUid, authorDisplayName: authorDisplayName, authorHandle: authorHandle, @@ -468,19 +530,21 @@ class SocialFeedService { timestamp: DateTime.now(), rideTitle: rideTitle, bikeModel: bikeModel, - distanceMiles: distanceMiles, - durationSeconds: durationSeconds, - maxLeanAngleDeg: maxLeanAngleDeg, - maxLeanSide: maxLeanSide, + distanceMiles: resolvedDistance, + durationSeconds: resolvedDurationSeconds, + maxLeanAngleDeg: resolvedMaxLean, + maxLeanSide: resolvedLeanSide, paceFactor: paceFactor, hazardCount: hazardCount, - elevationFt: elevationFt, + elevationFt: resolvedElevationFt, discipline: discipline, regionName: regionName, isFeatured: isFeatured, snapshotType: snapshotType, routePoints: routePoints, - isGpsVerified: true, + telemetrySamples: gatedTelemetrySamples, + isGpsVerified: effectiveGpsVerified, + isDemo: false, fistBumpCount: 1, fistBumpUserIds: [authorUid], comments: const [], @@ -504,3 +568,4 @@ class SocialFeedService { _localFeedController.close(); } } + diff --git a/test/canonical_dashboard_responsive_test.dart b/test/canonical_dashboard_responsive_test.dart index 1a8c55d..4ede00b 100644 --- a/test/canonical_dashboard_responsive_test.dart +++ b/test/canonical_dashboard_responsive_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -20,6 +21,8 @@ import 'package:motorcycle_dash/models/telemetry_sample.dart'; import 'package:motorcycle_dash/services/alerts/road_alert_models.dart'; import 'package:motorcycle_dash/services/alerts/road_alert_service.dart'; import 'package:motorcycle_dash/services/location_service.dart'; +import 'package:motorcycle_dash/services/map/map_providers.dart'; +import 'package:motorcycle_dash/services/map/unified_map_view.dart'; import 'package:motorcycle_dash/services/telemetry_service.dart'; void main() { @@ -344,6 +347,198 @@ void main() { expect(find.text('Enable'), findsOneWidget); }); }); + + group('BUG-001: Touring Map Rider Follow & Recenter Regression Tests', () { + testWidgets( + 'Touring map follows incoming GPS coordinates in portrait and landscape, pauses on manual pan, and resumes on recenter', + (tester) async { + await prefs.setString('dashboardLayout', 'touring'); + configureViewport(tester, 400, 800); + addTearDown(() => tester.view.resetPhysicalSize()); + + await tester.pumpWidget(buildTestDashboard()); + await tester.pump(); + + expect(find.byType(DashboardMapPanel), findsOneWidget); + expect(find.byType(FlutterMap), findsOneWidget); + expect( + find.byKey(const ValueKey('dashboard_map_recenter_button')), + findsNothing, + ); + + // Emit initial GPS fix within 50ms of mount (previously dropped by 500ms cooldown) + telemetryController.add( + TelemetrySample( + timestamp: DateTime.now(), + speed: 35.0, + heading: 90.0, + latitude: 34.0522, + longitude: -118.2437, + gpsAccuracy: 5.0, + gpsAgeMs: 100, + ), + ); + await tester.pump(const Duration(milliseconds: 50)); + + var unifiedMap = tester.widget( + find.byType(UnifiedMapView), + ); + var flutterMap = tester.widget(find.byType(FlutterMap)); + expect(unifiedMap.center.latitude, closeTo(34.0522, 0.0001)); + expect(unifiedMap.center.longitude, closeTo(-118.2437, 0.0001)); + expect( + flutterMap.mapController!.camera.center.latitude, + closeTo(34.0522, 0.0001), + ); + expect( + flutterMap.mapController!.camera.center.longitude, + closeTo(-118.2437, 0.0001), + ); + + // Emit rapid subsequent GPS fix within 100ms (tests trailing timer follow) + telemetryController.add( + TelemetrySample( + timestamp: DateTime.now(), + speed: 45.0, + heading: 95.0, + latitude: 34.0526, + longitude: -118.2431, + gpsAccuracy: 5.0, + gpsAgeMs: 80, + ), + ); + await tester.pump(const Duration(milliseconds: 50)); + // Advance past the 250ms trailing follow timer window + await tester.pump(const Duration(milliseconds: 300)); + + flutterMap = tester.widget(find.byType(FlutterMap)); + expect( + flutterMap.mapController!.camera.center.latitude, + closeTo(34.0526, 0.0001), + ); + expect( + flutterMap.mapController!.camera.center.longitude, + closeTo(-118.2431, 0.0001), + ); + + // Simulate user manual pan / gesture interaction on the Touring map + final element = tester.element(find.byType(DashboardMapPanel)); + final container = ProviderScope.containerOf(element); + container.read(mapFollowStateProvider.notifier).userInteracted(); + flutterMap.mapController!.move( + flutterMap.mapController!.camera.center, + 16.5, + ); + await tester.pump(); + + // Recenter button must appear when follow mode is disabled + final recenterFinder = find.byKey( + const ValueKey('dashboard_map_recenter_button'), + ); + expect(recenterFinder, findsOneWidget); + + // While panned (follow mode false), incoming GPS updates move the rider marker + // but do NOT hijack the user's panned camera center + final pannedCameraCenter = flutterMap.mapController!.camera.center; + telemetryController.add( + TelemetrySample( + timestamp: DateTime.now(), + speed: 50.0, + heading: 100.0, + latitude: 34.0600, + longitude: -118.2500, + gpsAccuracy: 5.0, + gpsAgeMs: 80, + ), + ); + await tester.pump(const Duration(milliseconds: 300)); + + flutterMap = tester.widget(find.byType(FlutterMap)); + expect( + flutterMap.mapController!.camera.center.latitude, + closeTo(pannedCameraCenter.latitude, 0.0001), + ); + expect( + flutterMap.mapController!.camera.center.longitude, + closeTo(pannedCameraCenter.longitude, 0.0001), + ); + + // Tap RECENTER button -> camera snaps back to latest rider position (34.0600, -118.2500) + await tester.tap(recenterFinder); + await tester.pump(); + + expect(recenterFinder, findsNothing); + flutterMap = tester.widget(find.byType(FlutterMap)); + expect( + flutterMap.mapController!.camera.center.latitude, + closeTo(34.0600, 0.0001), + ); + expect( + flutterMap.mapController!.camera.center.longitude, + closeTo(-118.2500, 0.0001), + ); + + // Rotate to landscape Touring mode and verify GPS follow continues cleanly + configureViewport(tester, 800, 360); + await tester.pump(); + + telemetryController.add( + TelemetrySample( + timestamp: DateTime.now(), + speed: 55.0, + heading: 110.0, + latitude: 34.0700, + longitude: -118.2600, + gpsAccuracy: 5.0, + gpsAgeMs: 80, + ), + ); + await tester.pump(const Duration(milliseconds: 300)); + + flutterMap = tester.widget(find.byType(FlutterMap)); + expect( + flutterMap.mapController!.camera.center.latitude, + closeTo(34.0700, 0.0001), + ); + expect( + flutterMap.mapController!.camera.center.longitude, + closeTo(-118.2600, 0.0001), + ); + }, + ); + + testWidgets( + 'Touring map orientation button toggles between North Up and Heading Up (#80)', + (tester) async { + await prefs.setString('dashboardLayout', 'touring'); + configureViewport(tester, 400, 800); + addTearDown(() => tester.view.resetPhysicalSize()); + + await tester.pumpWidget(buildTestDashboard()); + await tester.pump(); + + final orientBtn = find.byKey( + const ValueKey('dashboard_map_orientation_button'), + ); + expect(orientBtn, findsOneWidget); + expect(find.text('NORTH UP'), findsOneWidget); + + // Tap orientation button to switch to Heading Up + await tester.tap(orientBtn); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + + expect(find.text('HEADING UP'), findsOneWidget); + + // Tap again to switch back to North Up + await tester.tap(orientBtn); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + + expect(find.text('NORTH UP'), findsOneWidget); + }, + ); + }); } class _TestRideStateMachine extends StateNotifier diff --git a/test/community_social_feed_test.dart b/test/community_social_feed_test.dart index 323c6f7..cd6c036 100644 --- a/test/community_social_feed_test.dart +++ b/test/community_social_feed_test.dart @@ -2,8 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:motorcycle_dash/core/theme/theme_extensions.dart'; +import 'package:motorcycle_dash/database/database.dart'; import 'package:motorcycle_dash/features/social/feed/social_feed_screen.dart'; import 'package:motorcycle_dash/features/social/feed/widgets/feed_comments_bottom_sheet.dart'; +import 'package:motorcycle_dash/features/social/feed/widgets/feed_telemetry_inspector_modal.dart'; import 'package:motorcycle_dash/features/social/feed/widgets/share_session_modal.dart'; import 'package:motorcycle_dash/features/social/social_hub_screen.dart'; import 'package:motorcycle_dash/services/map/map_provider_interface.dart'; @@ -307,10 +309,29 @@ void main() { expect(find.text('Marcus Vance'), findsOneWidget); }); - testWidgets('ShareSessionModal renders and allows form submission', (tester) async { + testWidgets('ShareSessionModal renders and allows form submission when a ride is selected', (tester) async { await tester.binding.setSurfaceSize(const Size(400, 900)); final service = SocialFeedService(); + final ride = Ride( + id: 12, + startTime: DateTime(2026, 10, 12, 8, 0), + endTime: DateTime(2026, 10, 12, 8, 45), + distance: 28.5, + distanceMiles: 28.5, + movingTime: 2700, + stoppedTime: 0, + topSpeed: 72.0, + topSpeedMph: 72.0, + averageSpeed: 38.0, + maxLeanLeft: 44.0, + maxLeanRight: 41.0, + maxAccelG: 0.45, + maxBrakingG: 0.52, + elevationGain: 320.0, + elevationLoss: 110.0, + ); + await tester.pumpWidget( _createTestApp( overrides: [ @@ -318,7 +339,7 @@ void main() { ], child: Builder( builder: (context) => ElevatedButton( - onPressed: () => ShareSessionModal.show(context), + onPressed: () => ShareSessionModal.show(context, initialRide: ride), child: const Text('Open Share Modal'), ), ), @@ -385,4 +406,1071 @@ void main() { expect(find.text('OPEN FULL RIDE REPLAY'), findsOneWidget); }); }); + + group('BUG-003: Share Recent Session Read-Only Telemetry Integrity Tests', () { + test( + 'SocialFeedService.shareRecentRide enforces authoritativeRide telemetry over forged input parameters', + () async { + final service = SocialFeedService(); + addTearDown(service.dispose); + + final recordedRide = Ride( + id: 77, + startTime: DateTime(2026, 10, 10, 8, 0), + endTime: DateTime(2026, 10, 10, 8, 42), + distance: 31.4, + distanceMiles: 31.4, + movingTime: 2520, // 42 mins + stoppedTime: 0, + topSpeed: 74.5, + topSpeedMph: 74.5, + averageSpeed: 44.8, + maxLeanLeft: 47.0, + maxLeanRight: 39.0, + maxAccelG: 0.55, + maxBrakingG: 0.62, + elevationGain: 300.0, // ~984 ft + elevationLoss: 100.0, + ); + + final published = await service.shareRecentRide( + authorUid: 'uid_1', + authorDisplayName: 'Test Rider', + authorHandle: 'test_rider', + locationName: 'Highway 9', + rideTitle: 'Morning Run', + bikeModel: 'Yamaha R1', + // Attempt to pass forged telemetry values alongside authoritativeRide + distanceMiles: 999.9, + durationSeconds: 99999, + maxLeanAngleDeg: 88.0, + maxLeanSide: 'RIGHT', + elevationFt: 50000, + authoritativeRide: recordedRide, + routePoints: const [ + AppLatLng(37.2500, -122.1200), + AppLatLng(37.2900, -122.1600), + ], + ); + + expect(published.rideId, 77); + expect(published.distanceMiles, closeTo(31.4, 0.001)); + expect(published.durationSeconds, 2520); + expect(published.maxLeanAngleDeg, closeTo(47.0, 0.001)); + expect(published.maxLeanSide, 'LEFT'); + expect(published.elevationFt, (300.0 * 3.28084).round()); + }, + ); + + testWidgets( + 'ShareSessionModal renders telemetry as read-only tiles and publishes authoritative Ride values', + (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 900)); + + final service = SocialFeedService(); + addTearDown(service.dispose); + + final recordedRide = Ride( + id: 42, + startTime: DateTime(2026, 10, 12, 9, 0), + endTime: DateTime(2026, 10, 12, 9, 42), + distance: 31.4, + distanceMiles: 31.4, + movingTime: 2520, // 42 mins + stoppedTime: 0, + topSpeed: 78.0, + topSpeedMph: 78.0, + averageSpeed: 45.2, + maxLeanLeft: 47.0, + maxLeanRight: 41.0, + maxAccelG: 0.5, + maxBrakingG: 0.6, + elevationGain: 450.0, + elevationLoss: 150.0, + motorcycleYear: 2024, + motorcycleMake: 'Aprilia', + motorcycleModel: 'RSV4 Factory', + ); + + await tester.pumpWidget( + _createTestApp( + overrides: [ + socialFeedServiceProvider.overrideWithValue(service), + ], + child: Builder( + builder: (context) => ElevatedButton( + onPressed: () => ShareSessionModal.show( + context, + initialRide: recordedRide, + ), + child: const Text('Open Share Modal'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Share Modal')); + await tester.pumpAndSettle(); + + // Only 3 editable TextFormFields exist (Title, Motorcycle Model, Location) + // Telemetry fields (Distance, Time, Lean) are NOT editable TextFormFields + expect(find.byType(TextFormField), findsNWidgets(3)); + + // Verify read-only telemetry tiles display the authoritative Ride metrics + expect( + find.byKey(const ValueKey('share_session_readonly_distance')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('share_session_readonly_duration')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('share_session_readonly_lean')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('share_session_readonly_lean_side')), + findsOneWidget, + ); + expect(find.text('31.4'), findsOneWidget); + expect(find.text('42'), findsOneWidget); + expect(find.text('47°'), findsOneWidget); + expect(find.text('LEFT'), findsOneWidget); + + // Edit metadata (Title) and publish + await tester.enterText( + find.byType(TextFormField).first, + 'Verified Highway 9 Dawn Patrol', + ); + await tester.tap(find.text('PUBLISH TO COMMUNITY FEED')); + await tester.pumpAndSettle(); + + final feedItems = await service.getFeedStream().first; + final latest = feedItems.first; + expect(latest.rideTitle, 'Verified Highway 9 Dawn Patrol'); + expect(latest.distanceMiles, closeTo(31.4, 0.001)); + expect(latest.durationSeconds, 2520); + expect(latest.maxLeanAngleDeg, closeTo(47.0, 0.001)); + expect(latest.maxLeanSide, 'LEFT'); + }, + ); + }); + + group('BUG-004: Published Ride Real Telemetry & GPS Verified Badge Tests', () { + test( + 'SocialFeedItem.verifyGpsCriteria and hasVerifiedGps reject demo posts, forged JSON, and zero-displacement routes', + () { + // 1. Demo post with valid routePoints still must NOT be GPS verified + final demoPost = SocialFeedItem( + id: 'demo_1', + authorUid: 'uid_1', + authorDisplayName: 'Demo Rider', + authorHandle: 'demo', + locationName: 'Skyline', + timestamp: DateTime(2026, 10, 12), + rideTitle: 'Demo Ride', + bikeModel: 'R1', + distanceMiles: 40.0, + durationSeconds: 2400, + maxLeanAngleDeg: 45.0, + isDemo: true, + isGpsVerified: true, + routePoints: const [ + AppLatLng(37.3861, -122.2641), + AppLatLng(37.2500, -122.1800), + ], + ); + expect(demoPost.hasVerifiedGps, isFalse); + + // 2. Non-demo post with < 2 points or zero displacement (< 100m) must NOT be GPS verified + final stationaryPost = SocialFeedItem( + id: 'stat_1', + authorUid: 'uid_1', + authorDisplayName: 'Rider', + authorHandle: 'rider', + locationName: 'Garage', + timestamp: DateTime(2026, 10, 12), + rideTitle: 'Garage Idle', + bikeModel: 'R1', + distanceMiles: 5.0, + durationSeconds: 600, + maxLeanAngleDeg: 10.0, + isDemo: false, + isGpsVerified: true, + routePoints: const [ + AppLatLng(37.38610, -122.26410), + AppLatLng(37.38611, -122.26411), + ], + ); + expect(stationaryPost.hasVerifiedGps, isFalse); + + // 3. Untrusted Firestore JSON claiming isGpsVerified: true with empty routePoints is rejected + final forgedFromJson = SocialFeedItem.fromJson({ + 'id': 'forged_1', + 'authorUid': 'uid_forger', + 'authorDisplayName': 'Forger', + 'authorHandle': 'forger', + 'locationName': 'Nowhere', + 'timestamp': '2026-10-12T10:00:00.000Z', + 'rideTitle': 'Fake Verified Run', + 'bikeModel': 'H2R', + 'distanceMiles': 100.0, + 'durationSeconds': 3600, + 'maxLeanAngleDeg': 60.0, + 'isDemo': false, + 'isGpsVerified': true, + 'routePoints': >[], + }); + expect(forgedFromJson.isGpsVerified, isFalse); + expect(forgedFromJson.hasVerifiedGps, isFalse); + + // 4. Real non-demo ride with >= 2 distinct GPS coordinates (>= 80.47m), telemetry samples, positive distance & duration is verified + final validPost = SocialFeedItem( + id: 'valid_1', + authorUid: 'uid_real', + authorDisplayName: 'Real Rider', + authorHandle: 'realrider', + locationName: 'Highway 9', + timestamp: DateTime(2026, 10, 12), + rideTitle: 'Real Canyon Run', + bikeModel: 'Street Triple RS', + distanceMiles: 24.5, + durationSeconds: 1800, + maxLeanAngleDeg: 44.0, + isDemo: false, + isGpsVerified: true, + routePoints: const [ + AppLatLng(37.2500, -122.1200), + AppLatLng(37.2900, -122.1600), + ], + telemetrySamples: [ + FeedTelemetrySample( + progress: 0.0, + speedMph: 35.0, + leanAngleDeg: 18.0, + latitude: 37.2500, + longitude: -122.1200, + timestamp: DateTime(2026, 10, 12, 8, 0), + ), + FeedTelemetrySample( + progress: 1.0, + speedMph: 48.0, + leanAngleDeg: 44.0, + latitude: 37.2900, + longitude: -122.1600, + timestamp: DateTime(2026, 10, 12, 8, 30), + ), + ], + ); + expect(validPost.hasVerifiedGps, isTrue); + }, + ); + + testWidgets( + 'Unverified/demo feed posts hide GPS VERIFIED badge and show explicit empty telemetry state in inspector', + (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 1000)); + + final service = SocialFeedService(); + addTearDown(service.dispose); + + await tester.pumpWidget( + _createTestApp( + overrides: [ + socialFeedServiceProvider.overrideWithValue(service), + ], + child: const SocialFeedScreen(), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // Default feed items are demo items (isDemo: true), so GPS VERIFIED badge must NOT appear + expect(find.text('GPS VERIFIED'), findsNothing); + expect(find.text('NO GPS TRACK RECORDED'), findsWidgets); + + // Open telemetry inspector on unverified/demo post + await tester.drag(find.byType(ListView), const Offset(0, -100)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('INSPECT TELEMETRY GRAPH').first); + await tester.pumpAndSettle(); + + // Must show explicit no-telemetry-samples state instead of synthetic sine wave + expect( + find.byKey(const ValueKey('inspector_no_telemetry_state')), + findsOneWidget, + ); + expect(find.text('NO RECORDED TELEMETRY SAMPLES'), findsOneWidget); + expect(find.text('TELEMETRY TRACE UNAVAILABLE'), findsOneWidget); + }, + ); + + testWidgets( + 'Verified ride with recorded telemetrySamples renders GPS VERIFIED badge and real interpolated scrubber values', + (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 1000)); + + final verifiedItem = SocialFeedItem( + id: 'verified_post_99', + rideId: 99, + authorUid: 'uid_verified', + authorDisplayName: 'Elena Rostova', + authorHandle: 'ElenaApex', + locationName: 'Angeles Crest Hwy', + timestamp: DateTime.now(), + rideTitle: 'Verified Morning Telemetry Run', + bikeModel: '2024 Aprilia Tuono V4', + distanceMiles: 20.0, + durationSeconds: 1500, + maxLeanAngleDeg: 46.0, + maxLeanSide: 'LEFT', + elevationFt: 1800, + isDemo: false, + isGpsVerified: true, + routePoints: const [ + AppLatLng(34.2000, -118.1500), + AppLatLng(34.2400, -118.1100), + AppLatLng(34.2800, -118.0700), + ], + telemetrySamples: const [ + FeedTelemetrySample( + progress: 0.0, + speedMph: 35.0, + leanAngleDeg: 10.0, + lateralG: 0.18, + altitudeFt: 1200.0, + latitude: 34.2000, + longitude: -118.1500, + ), + FeedTelemetrySample( + progress: 0.5, + speedMph: 63.0, + leanAngleDeg: -46.0, + lateralG: -0.84, + altitudeFt: 2400.0, + latitude: 34.2400, + longitude: -118.1100, + ), + FeedTelemetrySample( + progress: 1.0, + speedMph: 42.0, + leanAngleDeg: 15.0, + lateralG: 0.25, + altitudeFt: 3000.0, + latitude: 34.2800, + longitude: -118.0700, + ), + ], + ); + + await tester.pumpWidget( + _createTestApp( + child: Builder( + builder: (context) => Column( + children: [ + if (verifiedItem.hasVerifiedGps) + const Text('GPS VERIFIED'), + ElevatedButton( + onPressed: () => + FeedTelemetryInspectorModal.show(context, verifiedItem), + child: const Text('Inspect Verified Ride'), + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(verifiedItem.hasVerifiedGps, isTrue); + expect(find.text('GPS VERIFIED'), findsOneWidget); + + await tester.tap(find.text('Inspect Verified Ride')); + await tester.pumpAndSettle(); + + // No empty-state overlay when real telemetry samples exist + expect( + find.byKey(const ValueKey('inspector_no_telemetry_state')), + findsNothing, + ); + + // At default _scrubFraction = 0.5, scrubber readout matches sample[1] (63 MPH, 46°, 0.84G) + expect(find.text('POINT: 10.0 MI (50%)'), findsOneWidget); + expect(find.text('63 MPH'), findsOneWidget); + expect(find.text('46°'), findsWidgets); + expect(find.text('0.84G'), findsOneWidget); + }, + ); + }); + + group('BUG-002: Social Feed Typography & Theme Contrast Tests', () { + double contrastRatio(Color fg, Color bg) { + final l1 = fg.computeLuminance(); + final l2 = bg.computeLuminance(); + final brightest = l1 > l2 ? l1 : l2; + final darkest = l1 > l2 ? l2 : l1; + return (brightest + 0.05) / (darkest + 0.05); + } + + for (final isLight in [false, true]) { + testWidgets( + 'SocialFeedScreen and FeedCommentsBottomSheet maintain WCAG AA (>= 4.5:1) contrast in ${isLight ? "Light" : "Dark"} mode', + (tester) async { + await tester.binding.setSurfaceSize(const Size(412, 915)); + + final themeColors = isLight + ? MotorSignalThemeColors.light + : MotorSignalThemeColors.dark; + final baseTheme = isLight ? ThemeData.light() : ThemeData.dark(); + + final service = SocialFeedService(); + addTearDown(service.dispose); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + socialFeedServiceProvider.overrideWithValue(service), + ], + child: MediaQuery( + data: const MediaQueryData( + size: Size(412, 915), + textScaler: TextScaler.linear(1.2), + ), + child: MaterialApp( + theme: baseTheme.copyWith( + extensions: [themeColors], + ), + home: const SocialFeedScreen(), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(tester.takeException(), isNull); + + // 1. AppBar title contrast against canvas surface + final appBarTitle = tester.widget(find.text('Community Feed')); + expect( + contrastRatio(appBarTitle.style!.color!, themeColors.surfaceCanvas), + greaterThanOrEqualTo(4.5), + ); + + // 2. Card primary text (@authorHandle and rideTitle) against card primary surface + final handleText = tester.widget(find.text('@ApexMarcus')); + expect( + contrastRatio( + handleText.style!.color!, + themeColors.surfaceCardPrimary, + ), + greaterThanOrEqualTo(4.5), + ); + + final rideTitleText = tester.widget( + find.text('Sunday Skyline & Pescadero Carve'), + ); + expect( + contrastRatio( + rideTitleText.style!.color!, + themeColors.surfaceCardPrimary, + ), + greaterThanOrEqualTo(4.5), + ); + + // 3. Card secondary text (clubName) against card primary surface + final clubText = tester.widget( + find.text('Bay Area Twisties Club'), + ); + expect( + contrastRatio( + clubText.style!.color!, + themeColors.surfaceCardPrimary, + ), + greaterThanOrEqualTo(4.5), + ); + + // 4. Filter tabs (selected vs unselected) contrast + final selectedFilterText = tester.widget( + find.text('All Activity'), + ); + final selectedFilterBg = isLight + ? const Color(0xFF0047B3) + : const Color(0xFF00E5FF); + expect( + contrastRatio(selectedFilterText.style!.color!, selectedFilterBg), + greaterThanOrEqualTo(4.5), + ); + + final unselectedFilterText = tester.widget( + find.text('Club Only'), + ); + expect( + contrastRatio( + unselectedFilterText.style!.color!, + themeColors.surfaceCardSecondary, + ), + greaterThanOrEqualTo(4.5), + ); + + // 5. Telemetry metric labels and values contrast against metric cell surface + final distLabel = tester.widget(find.text('DISTANCE').first); + expect( + contrastRatio( + distLabel.style!.color!, + themeColors.surfaceCardPrimary, + ), + greaterThanOrEqualTo(4.5), + ); + + final leanValue = tester.widget(find.text('51°').first); + expect( + contrastRatio( + leanValue.style!.color!, + themeColors.surfaceCardPrimary, + ), + greaterThanOrEqualTo(4.5), + ); + + // 6. ShareSessionModal contrast in both Light and Dark modes + await tester.tap( + find.byKey(const ValueKey('feed_share_session_button')), + ); + await tester.pumpAndSettle(); + + final shareModalTitle = tester.widget( + find.text('Share Recent Session'), + ); + expect( + contrastRatio( + shareModalTitle.style!.color!, + themeColors.surfaceCardPrimary, + ), + greaterThanOrEqualTo(4.5), + ); + + // Publish button is disabled when no recorded ride is selected + final publishBtn = tester.widget( + find.widgetWithText(ElevatedButton, 'PUBLISH TO COMMUNITY FEED'), + ); + expect(publishBtn.onPressed, isNull); + }, + ); + } + }); + + group('BUG-005: GPX Export Snackbar Horizontal Overflow Tests', () { + for (final viewport in const [ + Size(320, 640), + Size(360, 800), + Size(800, 480), + ]) { + testWidgets( + 'GPX export snackbar renders long rideTitle without RenderFlex overflow at ${viewport.width.toInt()}x${viewport.height.toInt()} (1.5x text scale)', + (tester) async { + await tester.binding.setSurfaceSize(viewport); + + final service = SocialFeedService(); + addTearDown(service.dispose); + + const longRideTitle = + 'Ultra Long Sunday Skyline Boulevard & Pescadero Creek Canyon Telemetry Endurance Carve Session With Full Squad Pack'; + + final post = await service.shareRecentRide( + authorUid: 'uid_overflow_test', + authorDisplayName: 'Marcus Vance', + authorHandle: 'ApexMarcus', + locationName: 'Skyline Blvd', + rideTitle: longRideTitle, + bikeModel: '2024 Ducati Streetfighter V4', + distanceMiles: 68.4, + durationSeconds: 4440, + maxLeanAngleDeg: 51.0, + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + socialFeedServiceProvider.overrideWithValue(service), + ], + child: MediaQuery( + data: MediaQueryData( + size: viewport, + textScaler: const TextScaler.linear(1.5), + ), + child: MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: const [MotorSignalThemeColors.dark], + ), + home: const SocialFeedScreen(), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + final gpxButton = find.byKey( + ValueKey('gpx_export_button_${post.id}'), + ); + await tester.scrollUntilVisible( + gpxButton, + 120.0, + scrollable: find.descendant( + of: find.byKey(const ValueKey('social_feed_list_view')), + matching: find.byType(Scrollable), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(gpxButton); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // Must not throw any RenderFlex overflow exception + expect(tester.takeException(), isNull); + expect( + find.text('GPX track exported for $longRideTitle'), + findsOneWidget, + ); + }, + ); + } + }); + + group('Sprint 1 Remediation: BUG-005 / Issue #52 Landscape Overflow', () { + for (final viewport in const [ + Size(800, 360), + Size(780, 360), + Size(840, 390), + ]) { + testWidgets( + 'Empty feed state renders without 90px bottom overflow in landscape ${viewport.width.toInt()}x${viewport.height.toInt()}', + (tester) async { + await tester.binding.setSurfaceSize(viewport); + + final service = SocialFeedService(); + addTearDown(service.dispose); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + socialFeedServiceProvider.overrideWithValue(service), + ], + child: MediaQuery( + data: MediaQueryData(size: viewport), + child: MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: const [MotorSignalThemeColors.dark], + ), + home: const SocialFeedScreen(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Must not throw RenderFlex bottom overflow + expect(tester.takeException(), isNull); + expect(find.byType(SingleChildScrollView), findsWidgets); + expect(find.byKey(const ValueKey('feed_share_session_button')), findsOneWidget); + }, + ); + } + }); + + group('Sprint 1 Remediation: BUG-003 / Sub-Issue #81 Starting Location & Canyon Removal', () { + testWidgets('ShareSessionModal displays STARTING LOCATION, removes Canyon label, and defaults to Morning Ride', (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 800)); + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: const [MotorSignalThemeColors.dark], + ), + home: const Scaffold( + body: ShareSessionModal(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Check STARTING LOCATION label exists and LOCATION / CANYON is gone + expect(find.text('STARTING LOCATION'), findsOneWidget); + expect(find.text('LOCATION / CANYON'), findsNothing); + + // Check title defaults to Morning Ride (not Morning Canyon Carve) + expect(find.text('Morning Ride'), findsOneWidget); + expect(find.text('Morning Canyon Carve'), findsNothing); + + // Check starting location initial fallback is Location unavailable (privacy-safe) + expect(find.text('Location unavailable'), findsOneWidget); + expect(find.text('Skyline Blvd & Highway 9'), findsNothing); + }); + }); + + group('PR #78 Review Threads Regression Suite', () { + test( + 'Thread 1: verifyGpsCriteria and shareRecentRide reject empty or zero-span telemetrySamples', + () async { + final service = SocialFeedService(); + addTearDown(service.dispose); + + final recordedRide = Ride( + id: 101, + startTime: DateTime(2026, 10, 15, 9, 0), + endTime: DateTime(2026, 10, 15, 9, 30), + distance: 18.4, + distanceMiles: 18.4, + movingTime: 1800, + stoppedTime: 0, + topSpeed: 64.0, + topSpeedMph: 64.0, + averageSpeed: 36.8, + maxLeanLeft: 38.0, + maxLeanRight: 35.0, + maxAccelG: 0.4, + maxBrakingG: 0.5, + elevationGain: 210.0, + elevationLoss: 90.0, + ); + + // Reachable via shareRecentRide when telemetrySamples defaults to empty + final publishedEmptySamples = await service.shareRecentRide( + authorUid: 'uid_t1', + authorDisplayName: 'Rider One', + authorHandle: 'rider_one', + locationName: 'Saratoga, CA', + rideTitle: 'Morning Ride', + bikeModel: 'Yamaha MT-09', + authoritativeRide: recordedRide, + routePoints: const [ + AppLatLng(37.2500, -122.1200), + AppLatLng(37.2900, -122.1600), + ], + telemetrySamples: const [], + ); + + expect(publishedEmptySamples.isGpsVerified, isFalse); + expect(publishedEmptySamples.hasVerifiedGps, isFalse); + + // Direct model construction with empty telemetrySamples + final itemEmptySamples = SocialFeedItem( + id: 't1_empty', + authorUid: 'uid_t1', + authorDisplayName: 'Rider One', + authorHandle: 'rider_one', + locationName: 'Saratoga, CA', + timestamp: DateTime(2026, 10, 15, 9, 30), + rideTitle: 'Morning Ride', + bikeModel: 'Yamaha MT-09', + distanceMiles: 18.4, + durationSeconds: 1800, + maxLeanAngleDeg: 38.0, + isDemo: false, + isGpsVerified: true, + routePoints: const [ + AppLatLng(37.2500, -122.1200), + AppLatLng(37.2900, -122.1600), + ], + telemetrySamples: const [], + ); + expect(itemEmptySamples.hasVerifiedGps, isFalse); + + // Zero recorded progress span across samples must also be rejected + final itemZeroSpan = itemEmptySamples.copyWith( + id: 't1_zero_span', + telemetrySamples: const [ + FeedTelemetrySample( + progress: 0.0, + speedMph: 35.0, + leanAngleDeg: 12.0, + latitude: 37.2500, + longitude: -122.1200, + ), + FeedTelemetrySample( + progress: 0.0, + speedMph: 40.0, + leanAngleDeg: 18.0, + latitude: 37.2900, + longitude: -122.1600, + ), + ], + ); + expect(itemZeroSpan.hasVerifiedGps, isFalse); + + // Zero recorded timestamp span across samples (identical timestamps) must also be rejected + final sameInstant = DateTime(2026, 10, 15, 9, 0, 0); + final itemZeroTimestampSpan = itemEmptySamples.copyWith( + id: 't1_zero_ts_span', + telemetrySamples: [ + FeedTelemetrySample( + progress: 0.0, + speedMph: 35.0, + leanAngleDeg: 12.0, + latitude: 37.2500, + longitude: -122.1200, + timestamp: sameInstant, + ), + FeedTelemetrySample( + progress: 1.0, + speedMph: 40.0, + leanAngleDeg: 18.0, + latitude: 37.2900, + longitude: -122.1600, + timestamp: sameInstant, + ), + ], + ); + expect(itemZeroTimestampSpan.hasVerifiedGps, isFalse); + }, + ); + + test( + 'Thread 2: verifyGpsCriteria measures 0.05-mile (~80.47m) displacement geodesically including longitude-at-latitude', + () { + // Case A: 0.0003 deg on each axis at equator (~47.2m < 80.47m) must fail + final equatorialSubThreshold = SocialFeedItem.verifyGpsCriteria( + isDemo: false, + distanceMiles: 1.0, + durationSeconds: 180, + routePoints: const [ + AppLatLng(0.0, 0.0), + AppLatLng(0.0003, 0.0003), + ], + telemetrySamples: const [ + FeedTelemetrySample( + progress: 0.0, + speedMph: 20.0, + leanAngleDeg: 5.0, + latitude: 0.0, + longitude: 0.0, + ), + FeedTelemetrySample( + progress: 1.0, + speedMph: 25.0, + leanAngleDeg: 8.0, + latitude: 0.0003, + longitude: 0.0003, + ), + ], + ); + expect(equatorialSubThreshold, isFalse); + + // Case B: 0.0010 deg longitude delta at 60°N (~55.6m < 80.47m) must fail + final highLatLongitudeSubThreshold = SocialFeedItem.verifyGpsCriteria( + isDemo: false, + distanceMiles: 1.0, + durationSeconds: 180, + routePoints: const [ + AppLatLng(60.0, -149.0000), + AppLatLng(60.0, -149.0010), + ], + telemetrySamples: const [ + FeedTelemetrySample( + progress: 0.0, + speedMph: 20.0, + leanAngleDeg: 5.0, + latitude: 60.0, + longitude: -149.0000, + ), + FeedTelemetrySample( + progress: 1.0, + speedMph: 25.0, + leanAngleDeg: 8.0, + latitude: 60.0, + longitude: -149.0010, + ), + ], + ); + expect(highLatLongitudeSubThreshold, isFalse); + + // Case C: 0.0020 deg longitude delta at 60°N (~111.2m >= 80.47m) passes + final highLatAboveThreshold = SocialFeedItem.verifyGpsCriteria( + isDemo: false, + distanceMiles: 1.0, + durationSeconds: 180, + routePoints: const [ + AppLatLng(60.0, -149.0000), + AppLatLng(60.0, -149.0020), + ], + telemetrySamples: const [ + FeedTelemetrySample( + progress: 0.0, + speedMph: 20.0, + leanAngleDeg: 5.0, + latitude: 60.0, + longitude: -149.0000, + ), + FeedTelemetrySample( + progress: 1.0, + speedMph: 25.0, + leanAngleDeg: 8.0, + latitude: 60.0, + longitude: -149.0020, + ), + ], + ); + expect(highLatAboveThreshold, isTrue); + }, + ); + + testWidgets( + 'Thread 3: ShareSessionModal excludes stopped-frame lean (< 3.0 MPH gate) from published telemetrySamples', + (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 900)); + + final service = SocialFeedService(); + addTearDown(service.dispose); + + final ride = Ride( + id: 303, + startTime: DateTime(2026, 10, 16, 10, 0), + endTime: DateTime(2026, 10, 16, 10, 20), + distance: 12.5, + distanceMiles: 12.5, + movingTime: 1200, + stoppedTime: 60, + topSpeed: 58.0, + topSpeedMph: 58.0, + averageSpeed: 37.5, + maxLeanLeft: 32.0, + maxLeanRight: 28.0, + maxAccelG: 0.35, + maxBrakingG: 0.42, + elevationGain: 180.0, + elevationLoss: 60.0, + ); + + final rawSamples = [ + // Stopped kickstand frame (0.0 MPH, -54° lean) + TelemetrySample( + id: 1, + rideId: 303, + timestamp: DateTime(2026, 10, 16, 10, 0, 0), + latitude: 37.2500, + longitude: -122.1200, + altitude: 100.0, + speed: 0.0, + heading: 90.0, + gpsAccuracy: 4.0, + leanAngle: -54.0, + pitchAngle: 0.0, + accelX: 0.0, + accelY: 0.0, + accelZ: 1.0, + longitudinalG: 0.0, + lateralG: 0.0, + ), + // Moving corner apex (48.0 MPH, -32° lean) + TelemetrySample( + id: 2, + rideId: 303, + timestamp: DateTime(2026, 10, 16, 10, 10, 0), + latitude: 37.2700, + longitude: -122.1400, + altitude: 150.0, + speed: 48.0, + heading: 110.0, + gpsAccuracy: 4.0, + leanAngle: -32.0, + pitchAngle: 1.0, + accelX: 0.1, + accelY: -0.6, + accelZ: 1.0, + longitudinalG: 0.1, + lateralG: -0.62, + ), + // Moving corner exit (42.0 MPH, +28° lean) + TelemetrySample( + id: 3, + rideId: 303, + timestamp: DateTime(2026, 10, 16, 10, 15, 0), + latitude: 37.2850, + longitude: -122.1550, + altitude: 170.0, + speed: 42.0, + heading: 130.0, + gpsAccuracy: 4.0, + leanAngle: 28.0, + pitchAngle: 0.5, + accelX: 0.05, + accelY: 0.5, + accelZ: 1.0, + longitudinalG: 0.05, + lateralG: 0.52, + ), + // Sub-3.0 MPH dismount artifact (1.8 MPH, +58° lean) + TelemetrySample( + id: 4, + rideId: 303, + timestamp: DateTime(2026, 10, 16, 10, 20, 0), + latitude: 37.2900, + longitude: -122.1600, + altitude: 180.0, + speed: 1.8, + heading: 135.0, + gpsAccuracy: 4.0, + leanAngle: 58.0, + pitchAngle: 0.0, + accelX: 0.0, + accelY: 0.0, + accelZ: 1.0, + longitudinalG: 0.0, + lateralG: 0.0, + ), + ]; + + await tester.pumpWidget( + _createTestApp( + overrides: [ + socialFeedServiceProvider.overrideWithValue(service), + ], + child: Builder( + builder: (context) => ElevatedButton( + onPressed: () => ShareSessionModal.show( + context, + initialRide: ride, + initialTelemetrySamples: rawSamples, + ), + child: const Text('Open Share Modal'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Share Modal')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('PUBLISH TO COMMUNITY FEED')); + await tester.pumpAndSettle(); + + final feedItems = await service.getFeedStream().first; + final published = feedItems.first; + + expect(published.maxLeanAngleDeg, closeTo(32.0, 0.001)); + expect(published.telemetrySamples, hasLength(4)); + + // Stopped frames (< 3.0 MPH) must have lean gated to 0.0° + expect(published.telemetrySamples[0].leanAngleDeg, 0.0); + expect(published.telemetrySamples[3].leanAngleDeg, 0.0); + + // Moving frames (>= 3.0 MPH) preserve their recorded lean angles + expect(published.telemetrySamples[1].leanAngleDeg, closeTo(-32.0, 0.001)); + expect(published.telemetrySamples[2].leanAngleDeg, closeTo(28.0, 0.001)); + + // Max apex across the published trace matches the authoritative 32° max + final maxTraceLean = published.telemetrySamples + .map((s) => s.leanAngleDeg.abs()) + .reduce((a, b) => a > b ? a : b); + expect(maxTraceLean, closeTo(32.0, 0.001)); + }, + ); + }); } diff --git a/test/map_smoothness_and_orientation_test.dart b/test/map_smoothness_and_orientation_test.dart new file mode 100644 index 0000000..de8d28f --- /dev/null +++ b/test/map_smoothness_and_orientation_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:motorcycle_dash/services/map/map_providers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Map Smoothness & Orientation Angle Normalization (#79 & #80)', () { + test('shortestAngleDiff correctly calculates minimal circular angular distance', () { + // Direct small adjustments + expect(shortestAngleDiff(10.0, 20.0), closeTo(10.0, 0.001)); + expect(shortestAngleDiff(20.0, 10.0), closeTo(-10.0, 0.001)); + + // Circular transition crossing 0° / 360° + // Moving from 359° to 1° should be +2° clockwise, NOT -358° counter-clockwise + expect(shortestAngleDiff(359.0, 1.0), closeTo(2.0, 0.001)); + + // Moving from 1° to 359° should be -2° counter-clockwise, NOT +358° clockwise + expect(shortestAngleDiff(1.0, 359.0), closeTo(-2.0, 0.001)); + + // 350° to 10° should be +20° + expect(shortestAngleDiff(350.0, 10.0), closeTo(20.0, 0.001)); + + // 10° to 350° should be -20° + expect(shortestAngleDiff(10.0, 350.0), closeTo(-20.0, 0.001)); + + // Half-circle + expect(shortestAngleDiff(0.0, 180.0).abs(), closeTo(180.0, 0.001)); + }); + + test('normalizeAngle correctly constrains values to [0.0, 360.0)', () { + expect(normalizeAngle(0.0), closeTo(0.0, 0.001)); + expect(normalizeAngle(360.0), closeTo(0.0, 0.001)); + expect(normalizeAngle(365.0), closeTo(5.0, 0.001)); + expect(normalizeAngle(-10.0), closeTo(350.0, 0.001)); + expect(normalizeAngle(-370.0), closeTo(350.0, 0.001)); + expect(normalizeAngle(720.0), closeTo(0.0, 0.001)); + }); + }); + + group('MapOrientation State & Persistence (#80)', () { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('MapOrientationNotifier toggles and persists orientation', () async { + final notifier = MapOrientationNotifier(); + expect(notifier.state, MapOrientation.northUp); + + await notifier.toggle(); + expect(notifier.state, MapOrientation.headingUp); + + await notifier.toggle(); + expect(notifier.state, MapOrientation.northUp); + + await notifier.setOrientation(MapOrientation.headingUp); + expect(notifier.state, MapOrientation.headingUp); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('selected_map_orientation'), 'headingUp'); + }); + }); +} diff --git a/test/pro_upgrade_sheet_landscape_test.dart b/test/pro_upgrade_sheet_landscape_test.dart new file mode 100644 index 0000000..7c5296c --- /dev/null +++ b/test/pro_upgrade_sheet_landscape_test.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/features/entitlements/widgets/pro_lock_badge.dart'; +import 'package:motorcycle_dash/features/rides/ride_state_machine.dart'; +import 'package:motorcycle_dash/services/entitlements/feature_access_service.dart'; + +/// Regression for Issue #52 / BUG-005 follow-up: the "Forensic CSV & PDF Export" Pro +/// upsell sheet showed "BOTTOM OVERFLOWED BY 90 PIXELS" in landscape on a Pixel 8a +/// (~914x411 dp) because showProUpgradeSheet built a non-scrolling Column inside a default +/// modal bottom sheet, which Flutter caps at 9/16 of the screen height. +class _FakeRideStateMachine extends StateNotifier + implements RideStateMachine { + _FakeRideStateMachine(super.initialState); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +Future _pumpHost( + WidgetTester tester, { + required Size viewport, + double textScale = 1.0, +}) async { + await tester.binding.setSurfaceSize(viewport); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + rideStateMachineProvider.overrideWith( + (ref) => _FakeRideStateMachine(RideSession(state: RideState.idle)), + ), + ], + child: MediaQuery( + data: MediaQueryData( + size: viewport, + textScaler: TextScaler.linear(textScale), + ), + child: MaterialApp( + home: Scaffold( + body: Consumer( + builder: (context, ref, _) => ElevatedButton( + onPressed: () => showProUpgradeSheet( + context, + ref, + feature: MotorSignalFeature.csvExport, + ), + child: const Text('Open upsell'), + ), + ), + ), + ), + ), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + for (final viewport in const [ + Size(914, 411), // Pixel 8a landscape + Size(800, 360), + Size(780, 360), + ]) { + for (final scale in const [1.0, 1.3]) { + testWidgets( + 'Pro upgrade sheet has no bottom overflow in landscape ' + '${viewport.width.toInt()}x${viewport.height.toInt()} (text scale $scale)', + (tester) async { + await _pumpHost(tester, viewport: viewport, textScale: scale); + + await tester.tap(find.text('Open upsell')); + await tester.pumpAndSettle(); + + expect(find.text('Forensic CSV & PDF Export'), findsOneWidget); + expect(find.text('VIEW MOTORSIGNAL PRO'), findsOneWidget); + expect(find.text('NOT NOW'), findsOneWidget); + // No RenderFlex overflow exception. + expect(tester.takeException(), isNull); + }, + ); + } + } + + testWidgets('NOT NOW is reachable and dismisses the sheet in landscape', ( + tester, + ) async { + await _pumpHost(tester, viewport: const Size(800, 360), textScale: 1.3); + + await tester.tap(find.text('Open upsell')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('NOT NOW')); + await tester.tap(find.text('NOT NOW')); + await tester.pumpAndSettle(); + + expect(find.text('VIEW MOTORSIGNAL PRO'), findsNothing); + expect(tester.takeException(), isNull); + }); +}