diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index d8c765c..279632f 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -4,7 +4,7 @@ on: push: branches: [ master ] pull_request: - branches: [ master ] + branches: [ master, 'feature/**', 'fix/**', 'stabilization/**' ] workflow_dispatch: permissions: @@ -166,6 +166,13 @@ jobs: test -s "$APK" file "$APK" sha256sum "$APK" | tee build/app/outputs/flutter-apk/app-debug.apk.sha256 + if [ -n "${ANDROID_SDK_ROOT:-}" ]; then + AAPT="$(find "$ANDROID_SDK_ROOT/build-tools" -type f -name aapt -print -quit 2>/dev/null || true)" + if [ -x "$AAPT" ]; then + "$AAPT" dump badging "$APK" | grep "package: name='app.motorsignal.motorcycle'" + echo "Package identity verified: app.motorsignal.motorcycle" + fi + fi - name: Upload debug APK uses: actions/upload-artifact@v5 with: @@ -180,7 +187,7 @@ jobs: name: Build signed release APK and AAB runs-on: ubuntu-latest needs: [detect, analyze-and-build] - if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.detect.outputs.app_changed == 'true') + if: (github.ref == 'refs/heads/master') && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && needs.detect.outputs.app_changed == 'true')) permissions: contents: read steps: diff --git a/android/app/google-services.json b/android/app/google-services.json index aee7617..ced8da4 100644 --- a/android/app/google-services.json +++ b/android/app/google-services.json @@ -13,6 +13,14 @@ } }, "oauth_client": [ + { + "client_id": "809431485362-0ev25bbjbjjm5tj6nitinefhg4fi7vvj.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "app.motorsignal.motorcycle", + "certificate_hash": "4ab4d3266a5b50ef31e46a5dccb8d42f295bc635" + } + }, { "client_id": "809431485362-c2qfdas53jtoft3udu6jokciv9pruehq.apps.googleusercontent.com", "client_type": 1, diff --git a/lib/core/widgets/hybrid_riding_action_bar.dart b/lib/core/widgets/hybrid_riding_action_bar.dart index f7735d2..86ac42f 100644 --- a/lib/core/widgets/hybrid_riding_action_bar.dart +++ b/lib/core/widgets/hybrid_riding_action_bar.dart @@ -4,24 +4,94 @@ import '../theme/app_spacing.dart'; import '../theme/app_typography.dart'; import '../theme/theme_extensions.dart'; import '../../features/rides/ride_state_machine.dart'; +import '../../services/alerts/widgets/quick_swarm_report_modal.dart'; /// Pinned riding action controls for MotorSignal Hybrid v1. /// /// Provides 56dp minimum height buttons for glove-friendly cockpit control. -/// In active ride state, provides high-contrast Pause Ride (Amber) and -/// Finish Ride (Dark/Slate with Cyan/Green accent) action controls. +/// In active ride state, provides high-contrast Pause Ride (Amber), +/// Report Hazard (Caution/Warning), and Finish Ride controls. class HybridRidingActionBar extends ConsumerWidget { final bool compact; final String? finishLabel; final String? logTag; + final bool requireConfirmation; const HybridRidingActionBar({ super.key, this.compact = false, this.finishLabel, this.logTag, + this.requireConfirmation = true, }); + Future _handleFinish( + BuildContext context, + RideStateMachine notifier, + String effectiveTag, + ) async { + if (!requireConfirmation) { + notifier.finishRide().catchError((Object error) { + debugPrint('[$effectiveTag] finish failed class=${error.runtimeType}'); + }); + return; + } + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + key: const ValueKey('confirm_finish_ride_dialog'), + backgroundColor: const Color(0xFF161B22), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide(color: Color(0xFF30363D)), + ), + title: const Text( + 'End Ride?', + style: TextStyle( + color: Colors.white, + fontFamily: AppTypography.fontHeadline, + fontWeight: FontWeight.w700, + fontSize: 18, + ), + ), + content: const Text( + 'Are you sure you want to end this ride session? Telemetry recording will stop and finalize.', + style: TextStyle(color: Color(0xFF8B949E), fontSize: 14), + ), + actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + actions: [ + TextButton( + key: const ValueKey('confirm_finish_ride_cancel_button'), + onPressed: () => Navigator.of(ctx).pop(false), + style: TextButton.styleFrom( + minimumSize: const Size(80, 48), + foregroundColor: const Color(0xFF8B949E), + ), + child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.w600)), + ), + ElevatedButton( + key: const ValueKey('confirm_finish_ride_confirm_button'), + style: ElevatedButton.styleFrom( + minimumSize: const Size(100, 48), + backgroundColor: const Color(0xFFFF334B), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('End Ride', style: TextStyle(fontWeight: FontWeight.w700)), + ), + ], + ), + ); + + if (confirmed == true && context.mounted) { + notifier.finishRide().catchError((Object error) { + debugPrint('[$effectiveTag] finish failed class=${error.runtimeType}'); + }); + } + } + @override Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch( @@ -37,7 +107,7 @@ class HybridRidingActionBar extends ConsumerWidget { if (isRiding || isPaused) { return Row( children: [ - // Pause / Resume button + // 1. Pause / Resume button Expanded( flex: 1, child: SizedBox( @@ -52,7 +122,7 @@ class HybridRidingActionBar extends ConsumerWidget { borderRadius: BorderRadius.circular(14), ), elevation: 0, - padding: const EdgeInsets.symmetric(horizontal: 8), + padding: const EdgeInsets.symmetric(horizontal: 6), ), onPressed: () { if (isPaused) { @@ -63,7 +133,7 @@ class HybridRidingActionBar extends ConsumerWidget { }, icon: Icon( isPaused ? Icons.play_arrow_rounded : Icons.pause_rounded, - size: 24, + size: 22, color: colors.cautionAmber, ), label: FittedBox( @@ -72,17 +142,58 @@ class HybridRidingActionBar extends ConsumerWidget { isPaused ? 'Resume' : 'Pause Ride', style: const TextStyle( fontFamily: AppTypography.fontHeadline, - fontSize: 16, + fontSize: 15, fontWeight: FontWeight.w700, - letterSpacing: 0.3, + letterSpacing: 0.2, ), ), ), ), ), ), - const SizedBox(width: 10), - // Finish Ride button + const SizedBox(width: 8), + + // 2. Report Hazard button (Split from pause bar for quick emergency access) + Expanded( + flex: 1, + child: SizedBox( + height: buttonHeight, + child: ElevatedButton.icon( + key: const ValueKey('hybrid_report_hazard_button'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFFF9800).withValues(alpha: 0.16), + foregroundColor: const Color(0xFFFF9800), + side: const BorderSide(color: Color(0xFFFF9800), width: 1.2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 6), + ), + onPressed: () => QuickSwarmReportModal.show(context), + icon: const Icon( + Icons.warning_amber_rounded, + size: 22, + color: Color(0xFFFF9800), + ), + label: const FittedBox( + fit: BoxFit.scaleDown, + child: Text( + 'Hazard', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 15, + fontWeight: FontWeight.w700, + letterSpacing: 0.2, + ), + ), + ), + ), + ), + ), + const SizedBox(width: 8), + + // 3. Finish Ride / Stop Session button (Guarded with confirmation dialog) Expanded( flex: 1, child: SizedBox( @@ -97,17 +208,15 @@ class HybridRidingActionBar extends ConsumerWidget { borderRadius: BorderRadius.circular(14), ), elevation: 0, - padding: const EdgeInsets.symmetric(horizontal: 8), + padding: const EdgeInsets.symmetric(horizontal: 6), ), onPressed: () { final effectiveTag = logTag ?? 'HybridRidingActionBar'; - notifier.finishRide().catchError((Object error) { - debugPrint('[$effectiveTag] finish failed class=${error.runtimeType}'); - }); + _handleFinish(context, notifier, effectiveTag); }, icon: Icon( Icons.stop_rounded, - size: 24, + size: 22, color: colors.brandCyan, ), label: FittedBox( @@ -116,9 +225,9 @@ class HybridRidingActionBar extends ConsumerWidget { finishLabel ?? 'Finish Ride', style: const TextStyle( fontFamily: AppTypography.fontHeadline, - fontSize: 16, + fontSize: 15, fontWeight: FontWeight.w700, - letterSpacing: 0.3, + letterSpacing: 0.2, ), ), ), @@ -129,6 +238,7 @@ class HybridRidingActionBar extends ConsumerWidget { ); } + // Ready / Standby state: Single prominent start button return SizedBox( width: double.infinity, diff --git a/lib/features/dashboard/widgets/dashboard_alert_region.dart b/lib/features/dashboard/widgets/dashboard_alert_region.dart index 43ff963..2e1fbdb 100644 --- a/lib/features/dashboard/widgets/dashboard_alert_region.dart +++ b/lib/features/dashboard/widgets/dashboard_alert_region.dart @@ -82,13 +82,15 @@ class DashboardAlertRegion extends ConsumerWidget { } Widget _buildRoadAlertBanner(BuildContext context, RoadAlert alert, WidgetRef ref) { - final isSwarm = alert.providerId == 'swarm_alerts'; + final isDark = Theme.of(context).brightness == Brightness.dark; + final isSwarm = alert.providerId == 'swarm_alerts' || alert.providerId == 'swarm'; + final subtitleColor = isDark ? Colors.white70 : Theme.of(context).colorScheme.onSurfaceVariant; return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( - color: alert.category.color.withValues(alpha: 0.2), + color: alert.category.color.withValues(alpha: isDark ? 0.2 : 0.12), border: Border(bottom: BorderSide(color: alert.category.color, width: 2)), ), child: Row( @@ -101,15 +103,18 @@ class DashboardAlertRegion extends ConsumerWidget { children: [ Row( children: [ - Text( - alert.category.displayName.toUpperCase(), - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontFamily: AppTypography.fontHeadline, - color: alert.category.color, - fontWeight: FontWeight.bold, - fontSize: 13, - letterSpacing: 0.8, + Flexible( + child: Text( + alert.category.displayName.toUpperCase(), + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + color: alert.category.color, + fontWeight: FontWeight.bold, + fontSize: 13, + letterSpacing: 0.8, + ), ), ), if (isSwarm) ...[ @@ -135,11 +140,12 @@ class DashboardAlertRegion extends ConsumerWidget { ], ), Text( - '${alert.distanceFromUser?.toStringAsFixed(0) ?? '0'}m Ahead • ${alert.roadName ?? alert.description}', + _formatAlertSubtitle(alert), overflow: TextOverflow.ellipsis, + maxLines: 1, style: TextStyle( fontFamily: AppTypography.fontBody, - color: Colors.white, + color: subtitleColor, fontSize: 12, fontWeight: FontWeight.w600, ), @@ -155,13 +161,15 @@ class DashboardAlertRegion extends ConsumerWidget { constraints: const BoxConstraints(minWidth: 32, minHeight: 32), tooltip: 'Confirm (Still there)', onPressed: () { - ref.read(swarmRoadAlertServiceProvider).confirmAlert(alert.id); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Thanks! Swarm alert confirmed.'), - duration: Duration(seconds: 2), - ), - ); + if (alert.id.isNotEmpty) { + ref.read(swarmRoadAlertServiceProvider).confirmAlert(alert.id); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Thanks! Swarm alert confirmed.'), + duration: Duration(seconds: 2), + ), + ); + } }, ), IconButton( @@ -170,19 +178,21 @@ class DashboardAlertRegion extends ConsumerWidget { constraints: const BoxConstraints(minWidth: 32, minHeight: 32), tooltip: 'Clear (No longer there)', onPressed: () { - ref.read(swarmRoadAlertServiceProvider).clearAlert(alert.id); - ref.read(roadAlertServiceProvider).dismissAlert(alert.id); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Reported clear to Rider Swarm.'), - duration: Duration(seconds: 2), - ), - ); + if (alert.id.isNotEmpty) { + ref.read(swarmRoadAlertServiceProvider).clearAlert(alert.id); + ref.read(roadAlertServiceProvider).dismissAlert(alert.id); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Reported clear to Rider Swarm.'), + duration: Duration(seconds: 2), + ), + ); + } }, ), ], IconButton( - icon: const Icon(Icons.close, color: Colors.white70, size: 18), + icon: Icon(Icons.close, color: subtitleColor, size: 18), padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 32, minHeight: 32), tooltip: 'Dismiss alert', @@ -256,4 +266,30 @@ class DashboardAlertRegion extends ConsumerWidget { ), ); } + + String _formatAlertSubtitle(RoadAlert alert) { + final distText = _formatAlertDistance(alert.distanceFromUser); + final detail = alert.roadName ?? alert.description; + if (distText.isEmpty) { + return detail.isNotEmpty ? detail : 'Road Alert'; + } + if (detail.isEmpty) { + return distText; + } + return '$distText • $detail'; + } + + String _formatAlertDistance(double? meters) { + if (meters == null) return ''; + if (meters < 15.0) { + return 'Nearby'; + } + final miles = meters / 1609.344; + if (miles < 0.1) { + final feet = ((meters * 3.28084) / 10).round() * 10; + return '${feet > 0 ? feet : 50} ft Ahead'; + } else { + return '${miles.toStringAsFixed(1)} mi Ahead'; + } + } } diff --git a/lib/features/dashboard/widgets/dashboard_map_panel.dart b/lib/features/dashboard/widgets/dashboard_map_panel.dart index 5eb0123..74af053 100644 --- a/lib/features/dashboard/widgets/dashboard_map_panel.dart +++ b/lib/features/dashboard/widgets/dashboard_map_panel.dart @@ -6,6 +6,7 @@ import '../../../services/alerts/road_alert_models.dart'; import '../../../services/alerts/road_alert_service.dart'; import '../../../services/map/map_provider_interface.dart'; import '../../../services/map/unified_map_view.dart'; +import '../../../services/navigation/navigation_session_service.dart'; import '../../../services/telemetry_service.dart'; /// Embedded tactical map panel for Touring mode and responsive dashboard views. @@ -80,6 +81,20 @@ class DashboardMapPanel extends ConsumerWidget { ); } + final navSession = ref.watch(navigationSessionProvider); + final polylines = []; + if (navSession.isNavigating && + navSession.calculationResult?.geometry.isNotEmpty == true) { + polylines.add( + AppPolyline( + id: 'dash_active_nav_route', + points: navSession.calculationResult!.geometry, + color: const Color(0xFF00E5FF), + width: 5.0, + ), + ); + } + return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(14), @@ -103,7 +118,9 @@ class DashboardMapPanel extends ConsumerWidget { key: const ValueKey('dash_map_x'), center: AppLatLng(centerLat, centerLng), zoom: zoom, + bearing: 0.0, markers: mapMarkers, + polylines: polylines, ), ), if (!isFresh && hasCoordinates) diff --git a/lib/features/dashboard/widgets/evolution_portrait_dashboard.dart b/lib/features/dashboard/widgets/evolution_portrait_dashboard.dart index a6cdb7c..8463752 100644 --- a/lib/features/dashboard/widgets/evolution_portrait_dashboard.dart +++ b/lib/features/dashboard/widgets/evolution_portrait_dashboard.dart @@ -257,10 +257,11 @@ class EvolutionPortraitDashboard extends ConsumerWidget { size: const Size(double.infinity, double.infinity), painter: _GForcePodPainter(accelG: accelG, lateralG: lateralG), ), - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 18), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( '${totalG.toStringAsFixed(2)} G', style: const TextStyle( fontFamily: 'JetBrains Mono', @@ -269,31 +270,37 @@ class EvolutionPortraitDashboard extends ConsumerWidget { color: Colors.white, ), ), - ], + ), ), const Positioned( - top: 8, - child: Text( - 'BRAKING', - style: TextStyle( - fontFamily: 'Inter', - fontSize: 9, - fontWeight: FontWeight.w800, - color: Colors.white38, - letterSpacing: 0.08, + top: 6, + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + 'BRAKING', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 9, + fontWeight: FontWeight.w800, + color: Colors.white38, + letterSpacing: 0.08, + ), ), ), ), const Positioned( - bottom: 8, - child: Text( - 'CORNERING', - style: TextStyle( - fontFamily: 'Inter', - fontSize: 9, - fontWeight: FontWeight.w800, - color: Colors.white38, - letterSpacing: 0.08, + bottom: 6, + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + 'CORNERING', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 9, + fontWeight: FontWeight.w800, + color: Colors.white38, + letterSpacing: 0.08, + ), ), ), ), @@ -317,39 +324,45 @@ class EvolutionPortraitDashboard extends ConsumerWidget { maxLeft: session.maxLeanLeft, maxRight: session.maxLeanRight, ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '${leanAngle.abs().toStringAsFixed(0)}°', - style: const TextStyle( - fontFamily: 'JetBrains Mono', - fontSize: 20, - fontWeight: FontWeight.w900, - color: Colors.white, - ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${leanAngle.abs().toStringAsFixed(0)}°', + style: const TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 20, + fontWeight: FontWeight.w900, + color: Colors.white, + ), + ), + const Text( + 'LEAN ANGLE', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 9, + fontWeight: FontWeight.w800, + color: Colors.white54, + letterSpacing: 0.08, + ), + ), + const SizedBox(height: 2), + Text( + 'MAX L: ${session.maxLeanLeft.toStringAsFixed(0)}° R: ${session.maxLeanRight.toStringAsFixed(0)}°', + style: const TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 9, + fontWeight: FontWeight.w700, + color: Color(0xFF00E5FF), + ), + ), + ], ), - const Text( - 'LEAN ANGLE', - style: TextStyle( - fontFamily: 'Inter', - fontSize: 9, - fontWeight: FontWeight.w800, - color: Colors.white54, - letterSpacing: 0.08, - ), - ), - const SizedBox(height: 2), - Text( - 'MAX L: ${session.maxLeanLeft.toStringAsFixed(0)}° R: ${session.maxLeanRight.toStringAsFixed(0)}°', - style: const TextStyle( - fontFamily: 'JetBrains Mono', - fontSize: 9, - fontWeight: FontWeight.w700, - color: Color(0xFF00E5FF), - ), - ), - ], + ), ), ), ), @@ -393,8 +406,57 @@ class EvolutionPortraitDashboard extends ConsumerWidget { width: double.infinity, height: 52, child: ElevatedButton( - onPressed: () { + onPressed: () async { if (session.state == RideState.riding) { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + key: const ValueKey('confirm_stop_session_dialog'), + backgroundColor: const Color(0xFF161B22), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide(color: Color(0xFF30363D)), + ), + title: const Text( + 'End Ride?', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 18, + ), + ), + content: const Text( + 'Are you sure you want to end this ride session? Telemetry recording will stop and finalize.', + style: TextStyle(color: Color(0xFF8B949E), fontSize: 14), + ), + actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + actions: [ + TextButton( + key: const ValueKey('confirm_stop_session_cancel_button'), + onPressed: () => Navigator.of(ctx).pop(false), + style: TextButton.styleFrom( + minimumSize: const Size(80, 48), + foregroundColor: const Color(0xFF8B949E), + ), + child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.w600)), + ), + ElevatedButton( + key: const ValueKey('confirm_stop_session_confirm_button'), + style: ElevatedButton.styleFrom( + minimumSize: const Size(100, 48), + backgroundColor: const Color(0xFFFF334B), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('End Ride', style: TextStyle(fontWeight: FontWeight.w700)), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted) return; + // RID-2: contain the fire-and-forget Stop action at the UI // boundary. finishRide() keeps its Future error semantics // for awaiting callers; only the exception class is logged, diff --git a/lib/features/dashboard/widgets/lean_angle_indicator.dart b/lib/features/dashboard/widgets/lean_angle_indicator.dart index fc19838..4901ffd 100644 --- a/lib/features/dashboard/widgets/lean_angle_indicator.dart +++ b/lib/features/dashboard/widgets/lean_angle_indicator.dart @@ -52,6 +52,18 @@ class LeanAngleIndicator extends ConsumerWidget { final String angleText = isAvailable ? '${displayLean.abs().toStringAsFixed(0)}°' : '--°'; + void showDiagnosticsSheet(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: const Color(0xFF14171A), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) => const _SensorDiagnosticsSheet(), + ); + } + void handleTapToCalibrate() { final currentSpeed = sample?.speed ?? 0.0; final currentLean = displayLean.abs(); @@ -148,6 +160,15 @@ class LeanAngleIndicator extends ConsumerWidget { ), ), actions: [ + TextButton.icon( + key: const ValueKey('lean_calibrate_diagnostics_button'), + icon: const Icon(Icons.analytics_outlined, size: 16), + label: const Text('Diagnostics'), + onPressed: () { + Navigator.pop(ctx); + showDiagnosticsSheet(context); + }, + ), TextButton( onPressed: () => Navigator.pop(ctx), child: const Text('Cancel'), @@ -193,14 +214,15 @@ class LeanAngleIndicator extends ConsumerWidget { builder: (context, constraints) { final availableWidth = constraints.maxWidth; final double gaugeWidth = math.min(availableWidth - 8, compact ? 110.0 : 130.0).clamp(60.0, 150.0); - final double gaugeHeight = compact ? 52.0 : 68.0; + final double gaugeHeight = compact ? 46.0 : 60.0; return Tooltip( - message: 'Tap to Calibrate Upright (Stationary)', + message: 'Tap to Calibrate | Long press for Diagnostics', child: InkWell( key: const ValueKey('lean_angle_indicator_tap'), borderRadius: BorderRadius.circular(12), onTap: handleTapToCalibrate, + onLongPress: () => showDiagnosticsSheet(context), child: Semantics( label: isAvailable ? 'Lean angle: ${displayLean.abs().toStringAsFixed(0)} degrees $direction. Max left: ${maxLeft.toStringAsFixed(0)} degrees. Max right: ${maxRight.toStringAsFixed(0)} degrees.' @@ -302,21 +324,30 @@ class LeanAngleIndicator extends ConsumerWidget { ), ), - // Preserves backward-compatible telemetry text matchers for existing tests - SizedBox( - width: 0, - height: 0, - child: OverflowBox( - minWidth: 0, - maxWidth: 300, - minHeight: 0, - maxHeight: 50, - child: Row( - children: [ - Text('L: ${currentLeft.toStringAsFixed(0)}°'), - Text('R: ${currentRight.toStringAsFixed(0)}°'), - ], - ), + // Instant Live Lean Angle telemetry + FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'L: ${currentLeft.toStringAsFixed(0)}°', + style: TextStyle( + fontSize: compact ? 8 : 9.5, + fontWeight: FontWeight.w600, + color: colors.textMuted, + ), + ), + SizedBox(width: compact ? 8 : 12), + Text( + 'R: ${currentRight.toStringAsFixed(0)}°', + style: TextStyle( + fontSize: compact ? 8 : 9.5, + fontWeight: FontWeight.w600, + color: colors.textMuted, + ), + ), + ], ), ), ], @@ -492,3 +523,131 @@ class _MotorcycleLeanPainter extends CustomPainter { oldDelegate.isDark != isDark; } } + +class _SensorDiagnosticsSheet extends ConsumerWidget { + const _SensorDiagnosticsSheet(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final diagAsync = ref.watch(sensorDiagnosticsStreamProvider); + final fallbackDiag = ref.read(sensorFusionServiceProvider).latestDiagnostics; + final diag = diagAsync.value ?? fallbackDiag; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + decoration: const BoxDecoration( + color: Color(0xFF14171A), + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Row( + children: [ + Icon(Icons.sensors, color: Color(0xFF00E5FF), size: 20), + SizedBox(width: 8), + Text( + 'IMU Sensor Diagnostics', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: (diag?.isCalibrated ?? false) + ? const Color(0xFF10B981).withValues(alpha: 0.2) + : const Color(0xFFEF4444).withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: (diag?.isCalibrated ?? false) + ? const Color(0xFF10B981) + : const Color(0xFFEF4444), + width: 1, + ), + ), + child: Text( + (diag?.isCalibrated ?? false) ? 'CALIBRATED' : 'UNCALIBRATED', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 11, + fontWeight: FontWeight.w700, + color: (diag?.isCalibrated ?? false) + ? const Color(0xFF10B981) + : const Color(0xFFEF4444), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + if (diag == null) + const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text( + 'Awaiting IMU sensor data...', + style: TextStyle(color: Colors.white54), + ), + ), + ) + else ...[ + _buildDiagRow('Event Rate', '${diag.eventRateHz.toStringAsFixed(1)} Hz (target 50 Hz)'), + _buildDiagRow('Mount Mode', diag.mountOrientation), + _buildDiagRow('Fused Roll (Lean)', '${diag.finalLeanAngle.toStringAsFixed(1)}° (${diag.dashboardDirection})'), + _buildDiagRow('Fused Pitch', '${diag.transformedPitch.toStringAsFixed(1)}° (Offset: ${diag.pitchOffset.toStringAsFixed(1)}°)'), + _buildDiagRow('Longitudinal G', '${diag.longitudinalG >= 0 ? "+" : ""}${diag.longitudinalG.toStringAsFixed(2)} G'), + _buildDiagRow('Lateral G', '${diag.lateralG >= 0 ? "+" : ""}${diag.lateralG.toStringAsFixed(2)} G'), + _buildDiagRow('Raw Accel [X, Y, Z]', '${diag.rawAccelX.toStringAsFixed(2)}, ${diag.rawAccelY.toStringAsFixed(2)}, ${diag.rawAccelZ.toStringAsFixed(2)} m/s²'), + _buildDiagRow('Raw Gyro [X, Y, Z]', '${diag.rawGyroX.toStringAsFixed(3)}, ${diag.rawGyroY.toStringAsFixed(3)}, ${diag.rawGyroZ.toStringAsFixed(3)} rad/s'), + ], + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton( + key: const ValueKey('close_sensor_diagnostics_button'), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF222831), + ), + onPressed: () => Navigator.pop(context), + child: const Text('Close Diagnostics', style: TextStyle(color: Colors.white)), + ), + ), + ], + ), + ), + ); + } + + Widget _buildDiagRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: const TextStyle(fontFamily: 'Inter', fontSize: 13, color: Colors.white70)), + Text( + value, + style: const TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 13, + fontWeight: FontWeight.w600, + color: Color(0xFF00E5FF), + ), + ), + ], + ), + ); + } +} + diff --git a/lib/features/dashboard/widgets/navigation_maneuver_card.dart b/lib/features/dashboard/widgets/navigation_maneuver_card.dart index 07a675b..a1b2c9c 100644 --- a/lib/features/dashboard/widgets/navigation_maneuver_card.dart +++ b/lib/features/dashboard/widgets/navigation_maneuver_card.dart @@ -22,7 +22,6 @@ class NavigationManeuverCard extends ConsumerWidget { final currentManeuver = navState.currentManeuver!; final nextManeuver = navState.nextManeuver; final colors = context.motorColors; - final theme = Theme.of(context); return Semantics( label: 'Navigation: ${currentManeuver.instruction}. ${navState.formattedDistanceToNext} to turn. Remaining: ${navState.formattedRemainingDistance}, ETA ${navState.formattedEta}.', @@ -81,53 +80,57 @@ class NavigationManeuverCard extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Wrap( - spacing: 8, - runSpacing: 2, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text( - navState.formattedDistanceToNext, - style: const TextStyle( - fontFamily: 'JetBrains Mono', - fontSize: 22, - fontWeight: FontWeight.w900, - color: Colors.white, - letterSpacing: -0.5, - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: colors.cyberCyan.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - currentManeuver.type.directionShort, - style: TextStyle( + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + navState.formattedDistanceToNext, + style: const TextStyle( fontFamily: 'JetBrains Mono', - fontSize: 10, - fontWeight: FontWeight.w800, - color: colors.cyberCyan, - letterSpacing: 0.8, + fontSize: 22, + fontWeight: FontWeight.w900, + color: Colors.white, + letterSpacing: -0.5, ), ), - ), - ], + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: colors.cyberCyan.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + currentManeuver.type.directionShort, + style: TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 10, + fontWeight: FontWeight.w800, + color: colors.cyberCyan, + letterSpacing: 0.8, + ), + ), + ), + ], + ), ), const SizedBox(height: 2), Text( currentManeuver.streetName ?? currentManeuver.instruction, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle( + style: const TextStyle( fontFamily: 'Inter', fontSize: 15, fontWeight: FontWeight.bold, - color: theme.colorScheme.onSurface, + color: Colors.white, ), ), ], @@ -137,6 +140,8 @@ class NavigationManeuverCard extends ConsumerWidget { // Quick Audio Mute & End Navigation Buttons IconButton( key: const ValueKey('nav_toggle_tts_button'), + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + padding: EdgeInsets.zero, icon: Icon( navState.ttsEnabled ? Icons.volume_up : Icons.volume_off, color: navState.ttsEnabled ? colors.cyberCyan : Colors.white54, @@ -149,6 +154,8 @@ class NavigationManeuverCard extends ConsumerWidget { ), IconButton( key: const ValueKey('nav_stop_button'), + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + padding: EdgeInsets.zero, icon: const Icon(Icons.close, color: Colors.white70, size: 22), tooltip: 'Exit Navigation', onPressed: () { diff --git a/lib/features/dashboard/widgets/responsive_dashboard_layout.dart b/lib/features/dashboard/widgets/responsive_dashboard_layout.dart index c5d7e9b..5454e06 100644 --- a/lib/features/dashboard/widgets/responsive_dashboard_layout.dart +++ b/lib/features/dashboard/widgets/responsive_dashboard_layout.dart @@ -62,7 +62,7 @@ class ResponsiveDashboardLayout extends ConsumerWidget { return LayoutBuilder( builder: (context, constraints) { // Group-ride alerts reduce the usable height above this layout. - final isShortPortrait = constraints.maxHeight < 760; + final isShortPortrait = constraints.maxHeight < 880; final isUltraShortPortrait = constraints.maxHeight < 500; return Column( @@ -200,8 +200,8 @@ class ResponsiveDashboardLayout extends ConsumerWidget { child: Center( child: MetricTile( label: 'ELEV', - value: session.elevationGain.toStringAsFixed(0), - unit: 'M', + value: (session.elevationGain * 3.28084).toStringAsFixed(0), + unit: 'FT', compact: true, ), ), @@ -229,11 +229,8 @@ class ResponsiveDashboardLayout extends ConsumerWidget { if (!isShortPortrait) const Spacer(flex: 1), Flexible( flex: isShortPortrait ? 3 : 4, - child: Center( - child: Transform.translate( - offset: Offset(0, -12), - child: SpeedReadout(size: SpeedReadoutSize.hero), - ), + child: const Center( + child: SpeedReadout(size: SpeedReadoutSize.hero), ), ), if (!isShortPortrait) const Spacer(flex: 1), @@ -351,8 +348,8 @@ class ResponsiveDashboardLayout extends ConsumerWidget { ), MetricTile( label: 'ELEV', - value: session.elevationGain.toStringAsFixed(0), - unit: 'M', + value: (session.elevationGain * 3.28084).toStringAsFixed(0), + unit: 'FT', compact: true, ), ], @@ -559,8 +556,8 @@ class ResponsiveDashboardLayout extends ConsumerWidget { ), MetricTile( label: 'ELEV GAIN', - value: session.elevationGain.toStringAsFixed(0), - unit: 'M', + value: (session.elevationGain * 3.28084).toStringAsFixed(0), + unit: 'FT', ), ], ), diff --git a/lib/features/dashboard/widgets/speed_readout.dart b/lib/features/dashboard/widgets/speed_readout.dart index a6ff310..1eb577c 100644 --- a/lib/features/dashboard/widgets/speed_readout.dart +++ b/lib/features/dashboard/widgets/speed_readout.dart @@ -53,12 +53,13 @@ class SpeedReadout extends ConsumerWidget { final sample = s.value; if (sample == null) { // Pre-telemetry initial state (before first tick) - return (speed: 0.0, isFresh: true, isInitial: true); + return (speed: 0.0, isFresh: true, isInitial: true, rawSpeed: null); } return ( speed: sample.freshSpeed, isFresh: sample.isSpeedFresh, isInitial: false, + rawSpeed: sample.speed, ); }), ); @@ -70,12 +71,12 @@ class SpeedReadout extends ConsumerWidget { final Color speedColor; final Color unitColor; - if (speedState.isInitial) { + if (speedState.isInitial || (speedState.rawSpeed == null && !speedState.isFresh)) { speedText = '0'; speedColor = isDark ? Colors.white : AppColors.lightOnSurface; unitColor = baseUnitColor; } else if (!speedState.isFresh) { - // Stale or unavailable GNSS fix: display '--' to reflect truthful uncertainty + // Stale or unavailable GNSS fix during active motion: display '--' to reflect truthful uncertainty speedText = '--'; speedColor = isDark ? Colors.white38 : AppColors.lightOnSurface.withValues(alpha: 0.38); unitColor = baseUnitColor.withValues(alpha: 0.5); diff --git a/lib/features/entitlements/pro_screen.dart b/lib/features/entitlements/pro_screen.dart index 0c7cc00..b49131e 100644 --- a/lib/features/entitlements/pro_screen.dart +++ b/lib/features/entitlements/pro_screen.dart @@ -137,8 +137,9 @@ class _ProScreenState extends ConsumerState { .where((p) => p.id == ProductCatalog.proLifetime) .firstOrNull; + final isDark = Theme.of(context).brightness == Brightness.dark; return Scaffold( - backgroundColor: Colors.black, + backgroundColor: isDark ? const Color(0xFF0B0D0F) : Theme.of(context).colorScheme.surface, appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, @@ -359,9 +360,9 @@ class _ProScreenState extends ConsumerState { : (entitlement.expiresAt != null ? 'Subscription active. Renews ${entitlement.expiresAt!.toLocal().toString().split(' ')[0]}.' : 'Subscription active.'), - style: const TextStyle( + style: TextStyle( fontSize: 11, - color: Colors.white70, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], @@ -373,7 +374,8 @@ class _ProScreenState extends ConsumerState { } Widget _buildPitchHeader() { - return const Column( + final theme = Theme.of(context); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( @@ -382,17 +384,17 @@ class _ProScreenState extends ConsumerState { fontFamily: 'Sora', fontSize: 22, fontWeight: FontWeight.w900, - color: Colors.white, + color: theme.colorScheme.onSurface, letterSpacing: 0.2, height: 1.2, ), ), - SizedBox(height: 8), + const SizedBox(height: 8), Text( 'Unlock 20Hz deep telemetry analysis, multiple motorcycle profiles, unlimited cloud sync, and forensic data exports.', style: TextStyle( fontSize: 13, - color: Colors.white70, + color: theme.colorScheme.onSurfaceVariant, height: 1.4, ), ), @@ -426,7 +428,7 @@ class _ProScreenState extends ConsumerState { title: 'PRO LIFETIME', subtitle: 'One-time purchase • Permanent access', priceDisplay: lifetimeProd?.price ?? 'Unavailable', - badgeText: 'BEST VALUE', + badgeText: lifetimeProd != null ? 'BEST VALUE' : null, isHighlighted: true, isLoading: _currentStep == ProPurchaseStep.purchasing, isCurrentPlan: @@ -528,12 +530,12 @@ class _ProScreenState extends ConsumerState { } Widget _buildDisclaimer() { - return const Text( + return Text( 'Basic ride recording, cockpit instruments, offline history, local SQLite storage, and crash detection remain 100% free forever. MotorSignal Pro subscriptions can be managed or canceled anytime in Google Play Store settings.', textAlign: TextAlign.center, style: TextStyle( fontSize: 11, - color: Colors.white38, + color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.6), height: 1.4, ), ); @@ -557,10 +559,10 @@ class _ProScreenState extends ConsumerState { style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), ), const SizedBox(height: 6), - const Text( + Text( 'Cannot connect to Google Play. Please check your internet connection or Google Play Store service status.', textAlign: TextAlign.center, - style: TextStyle(fontSize: 12, color: Colors.white60), + style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant), ), const SizedBox(height: 14), OutlinedButton( @@ -619,11 +621,11 @@ class _ProScreenState extends ConsumerState { children: [ Text( title, - style: const TextStyle( + style: TextStyle( fontFamily: 'Sora', fontSize: 15, fontWeight: FontWeight.w900, - color: Colors.white, + color: Theme.of(context).colorScheme.onSurface, letterSpacing: 0.5, ), ), @@ -675,9 +677,9 @@ class _ProScreenState extends ConsumerState { const SizedBox(height: 4), Text( subtitle, - style: const TextStyle( + style: TextStyle( fontSize: 11, - color: Colors.white60, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], @@ -700,18 +702,22 @@ class _ProScreenState extends ConsumerState { fontWeight: FontWeight.w900, color: isHighlighted ? colors.cyberCyan - : Colors.white, + : Theme.of(context).colorScheme.onSurface, ), ), ), const SizedBox(height: 2), Text( - isCurrentPlan ? 'Active' : 'Select', + isCurrentPlan + ? 'Active' + : (onTap == null ? 'Unavailable' : 'Select'), style: TextStyle( fontSize: 11, color: isHighlighted ? colors.cyberCyan - : Colors.white54, + : (onTap == null + ? Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5) + : Theme.of(context).colorScheme.onSurfaceVariant), fontWeight: FontWeight.bold, ), ), @@ -751,18 +757,18 @@ class _ProScreenState extends ConsumerState { children: [ Text( title, - style: const TextStyle( + style: TextStyle( fontSize: 13, fontWeight: FontWeight.bold, - color: Colors.white, + color: Theme.of(context).colorScheme.onSurface, ), ), const SizedBox(height: 2), Text( desc, - style: const TextStyle( + style: TextStyle( fontSize: 11, - color: Colors.white60, + color: Theme.of(context).colorScheme.onSurfaceVariant, height: 1.3, ), ), diff --git a/lib/features/map/map_screen.dart b/lib/features/map/map_screen.dart index 7277e7f..b59e1c2 100644 --- a/lib/features/map/map_screen.dart +++ b/lib/features/map/map_screen.dart @@ -56,7 +56,9 @@ class _MapScreenState extends ConsumerState { List _searchResults = []; ResolvedPlace? _selectedDestination; RouteCalculationResult? _previewRouteResult; + bool _isAddressExpanded = false; bool _isCalculatingRoute = false; + int _routeCalculationRequestId = 0; RoutingOptions _routingOptions = const RoutingOptions(); static const double _hardAccelThresholdG = 0.35; @@ -119,8 +121,10 @@ class _MapScreenState extends ConsumerState { AppLatLng currentPos, ) async { _searchDebounce?.cancel(); + FocusScope.of(context).unfocus(); setState(() { _searchResults = []; + _isAddressExpanded = false; _searchController.text = place.primaryText; _destinationQuery = place.primaryText; }); @@ -148,6 +152,7 @@ class _MapScreenState extends ConsumerState { ResolvedPlace dest, AppLatLng currentPos, ) async { + final reqId = ++_routeCalculationRequestId; setState(() => _isCalculatingRoute = true); try { @@ -170,14 +175,14 @@ class _MapScreenState extends ConsumerState { .read(activeRouteCalculationProvider) .calculateRoute([origin, destinationStop], options: _routingOptions); - if (mounted) { + if (mounted && reqId == _routeCalculationRequestId) { setState(() { _previewRouteResult = result; _isCalculatingRoute = false; }); } } catch (e) { - if (mounted) { + if (mounted && reqId == _routeCalculationRequestId) { setState(() => _isCalculatingRoute = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Route calculation error: $e')), @@ -194,19 +199,25 @@ class _MapScreenState extends ConsumerState { _searchResults = []; _selectedDestination = null; _previewRouteResult = null; + _isAddressExpanded = false; }); } void _startTurnByTurnNavigation() { if (_previewRouteResult == null || _selectedDestination == null) return; + final preview = _previewRouteResult!; + final dest = _selectedDestination!; + setState(() { + _previewRouteResult = null; + }); ref.read(navigationSessionProvider.notifier).startNavigation( - calculationResult: _previewRouteResult!, - destination: _selectedDestination, + calculationResult: preview, + destination: dest, options: _routingOptions, ); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Navigation started to ${_selectedDestination!.displayName}'), + content: Text('Navigation started to ${dest.displayName}'), backgroundColor: const Color(0xFF00E5FF), ), ); @@ -880,6 +891,15 @@ class _MapScreenState extends ConsumerState { if (_isLoadingHistory) { return const Scaffold(body: Center(child: CircularProgressIndicator())); } + ref.listen(navigationSessionProvider, (prev, next) { + if (prev?.isNavigating == true && !next.isNavigating && mounted) { + setState(() { + _previewRouteResult = null; + _selectedDestination = null; + }); + } + }); + final isFollowMode = ref.watch(mapFollowStateProvider); final layerSettings = ref.watch(mapLayerSettingsProvider); final colors = context.motorColors; @@ -1158,6 +1178,7 @@ class _MapScreenState extends ConsumerState { key: const ValueKey('live_map_view'), center: currentPos, zoom: _currentZoom, + bearing: _isNorthUp ? 0.0 : currentHeading, markers: mapMarkers, polylines: livePolylines, ), @@ -1288,10 +1309,10 @@ class _MapScreenState extends ConsumerState { ), ), - // Bottom-left Quick Swarm Report Button - if (_previewRouteResult == null && !navSession.isNavigating) + // Bottom-left Quick Swarm Report Button (Repositioned to prevent card collision and hidden during search/typing) + if (MediaQuery.of(context).viewInsets.bottom == 0 && _searchResults.isEmpty) Positioned( - bottom: 24, + bottom: _previewRouteResult != null ? 295 : 24, left: 20, child: FloatingActionButton.extended( key: const ValueKey('quick_swarm_report_button'), @@ -1502,7 +1523,8 @@ class _MapScreenState extends ConsumerState { child: Row( children: [ _buildQuickDestinationChip( - label: 'Gas & Fuel', + label: 'Gas Stations', + customKey: 'chip_gas_and_fuel', icon: Icons.local_gas_station, colors: colors, currentPos: currentPos, @@ -1542,10 +1564,13 @@ class _MapScreenState extends ConsumerState { required IconData icon, required MotorSignalThemeColors colors, required AppLatLng currentPos, + String? customKey, }) { final isSelected = _destinationQuery.toLowerCase() == label.toLowerCase(); + final keyString = customKey ?? + 'chip_${label.toLowerCase().replaceAll(' ', '_').replaceAll('/', '_').replaceAll('&', 'and')}'; return InkWell( - key: ValueKey('chip_${label.toLowerCase().replaceAll(' ', '_').replaceAll('/', '_').replaceAll('&', 'and')}'), + key: ValueKey(keyString), borderRadius: BorderRadius.circular(20), onTap: () { if (isSelected) { @@ -1575,7 +1600,7 @@ class _MapScreenState extends ConsumerState { Icon( icon, size: 13, - color: isSelected ? colors.cyberCyan : Colors.white70, + color: isSelected ? colors.cyberCyan : Theme.of(context).colorScheme.onSurfaceVariant, ), const SizedBox(width: 4), Text( @@ -1584,7 +1609,7 @@ class _MapScreenState extends ConsumerState { fontFamily: 'Inter', fontSize: 11, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - color: isSelected ? colors.cyberCyan : Colors.white70, + color: isSelected ? colors.cyberCyan : Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], @@ -1597,15 +1622,17 @@ class _MapScreenState extends ConsumerState { MotorSignalThemeColors colors, AppLatLng currentPos, ) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; return Card( key: const ValueKey('search_results_overlay'), elevation: 8, shadowColor: Colors.black87, - color: colors.surfaceContainer.withValues(alpha: 0.98), + color: theme.colorScheme.surfaceContainerHigh, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), side: BorderSide( - color: colors.cyberCyan.withValues(alpha: 0.4), + color: colors.cyberCyan.withValues(alpha: isDark ? 0.4 : 0.6), width: 1.0, ), ), @@ -1617,7 +1644,7 @@ class _MapScreenState extends ConsumerState { itemCount: _searchResults.length, separatorBuilder: (_, _) => Divider( height: 1, - color: colors.outlineVariant.withValues(alpha: 0.3), + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.35), ), itemBuilder: (context, index) { final item = _searchResults[index]; @@ -1637,11 +1664,11 @@ class _MapScreenState extends ConsumerState { ), title: Text( item.primaryText, - style: const TextStyle( + style: TextStyle( fontFamily: 'Inter', fontSize: 13, fontWeight: FontWeight.bold, - color: Colors.white, + color: theme.colorScheme.onSurface, ), ), subtitle: item.secondaryText.isNotEmpty @@ -1649,13 +1676,15 @@ class _MapScreenState extends ConsumerState { item.secondaryText, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( fontFamily: 'Inter', fontSize: 11, - color: Colors.white70, + color: theme.colorScheme.onSurfaceVariant, ), ) : null, + selectedTileColor: colors.cyberCyan.withValues(alpha: 0.12), + hoverColor: colors.cyberCyan.withValues(alpha: 0.08), onTap: () => _onSelectPlace(item, currentPos), ); }, @@ -1670,6 +1699,7 @@ class _MapScreenState extends ConsumerState { ) { final preview = _previewRouteResult!; final dest = _selectedDestination; + final confirmedPref = preview.options?.preference ?? _routingOptions.preference; return Card( key: const ValueKey('route_preview_card'), @@ -1694,18 +1724,36 @@ class _MapScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - dest?.displayName ?? 'Destination Route', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontFamily: 'Inter', - fontSize: 15, - fontWeight: FontWeight.bold, - color: Colors.white, + InkWell( + key: const ValueKey('destination_address_expand_toggle'), + borderRadius: BorderRadius.circular(6), + onTap: () => setState(() => _isAddressExpanded = !_isAddressExpanded), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + dest?.displayName ?? 'Destination Route', + maxLines: _isAddressExpanded ? 4 : 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + const SizedBox(width: 4), + Icon( + _isAddressExpanded ? Icons.expand_less : Icons.expand_more, + size: 18, + color: colors.cyberCyan, + ), + ], ), ), - const SizedBox(height: 2), + const SizedBox(height: 4), Wrap( spacing: 8, runSpacing: 4, @@ -1726,23 +1774,87 @@ class _MapScreenState extends ConsumerState { vertical: 2, ), decoration: BoxDecoration( - color: const Color(0xFF10B981).withValues(alpha: 0.2), + color: colors.cyberCyan.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(4), border: Border.all( - color: const Color(0xFF10B981), + color: colors.cyberCyan, width: 0.8, ), ), - child: Text( - preview.twistinessRating, - style: const TextStyle( - fontFamily: 'JetBrains Mono', - fontSize: 9.5, - fontWeight: FontWeight.bold, - color: Color(0xFF10B981), - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_isCalculatingRoute) ...[ + SizedBox( + width: 8, + height: 8, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: colors.cyberCyan, + ), + ), + const SizedBox(width: 4), + ], + Text( + confirmedPref.name.toUpperCase(), + style: TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 9.5, + fontWeight: FontWeight.bold, + color: colors.cyberCyan, + ), + ), + ], ), ), + if (preview.options?.avoidHighways == true) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: const Color(0xFFF59E0B).withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: const Color(0xFFF59E0B), + width: 0.8, + ), + ), + child: const Text( + 'NO HIGHWAYS', + style: TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 9.5, + fontWeight: FontWeight.bold, + color: Color(0xFFF59E0B), + ), + ), + ), + if (preview.curvatureMetric != null && preview.curvatureMetric! >= 25.0) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: const Color(0xFF10B981).withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: const Color(0xFF10B981), + width: 0.8, + ), + ), + child: Text( + 'CURVES: ${preview.twistinessRating}', + style: const TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 9.5, + fontWeight: FontWeight.bold, + color: Color(0xFF10B981), + ), + ), + ), ], ), ], @@ -1750,7 +1862,7 @@ class _MapScreenState extends ConsumerState { ), IconButton( key: const ValueKey('clear_route_button'), - icon: const Icon(Icons.close, color: Colors.white70), + icon: Icon(Icons.close, color: Theme.of(context).colorScheme.onSurfaceVariant), onPressed: _clearDestination, tooltip: 'Cancel Destination', ), @@ -1790,6 +1902,28 @@ class _MapScreenState extends ConsumerState { colors: colors, currentPos: currentPos, ), + const SizedBox(width: 6), + FilterChip( + key: const ValueKey('preference_avoid_highways'), + label: const Text('Avoid Highways'), + selected: _routingOptions.avoidHighways, + labelStyle: TextStyle( + fontFamily: 'Inter', + fontSize: 11, + fontWeight: _routingOptions.avoidHighways ? FontWeight.bold : FontWeight.normal, + color: _routingOptions.avoidHighways ? Colors.black : Theme.of(context).colorScheme.onSurfaceVariant, + ), + selectedColor: colors.cyberCyan, + backgroundColor: colors.surfaceContainerHigh, + onSelected: (selected) { + setState(() { + _routingOptions = _routingOptions.copyWith(avoidHighways: selected); + }); + if (_selectedDestination != null) { + _calculateRouteToDestination(_selectedDestination!, currentPos); + } + }, + ), ], ), ), @@ -1860,14 +1994,16 @@ class _MapScreenState extends ConsumerState { required AppLatLng currentPos, }) { final isSelected = _routingOptions.preference == preference; + final onVariant = Theme.of(context).colorScheme.onSurfaceVariant; return ChoiceChip( + key: ValueKey('preference_${preference.name}'), label: Text(label), selected: isSelected, labelStyle: TextStyle( fontFamily: 'Inter', fontSize: 11, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - color: isSelected ? Colors.black : Colors.white70, + color: isSelected ? Colors.black : onVariant, ), selectedColor: colors.cyberCyan, backgroundColor: colors.surfaceContainerHigh, @@ -1908,8 +2044,10 @@ class _HudIconButton extends StatelessWidget { @override Widget build(BuildContext context) { final colors = context.motorColors; + final isDark = Theme.of(context).brightness == Brightness.dark; + final defaultBg = isDark ? colors.surfaceContainer : const Color(0xFF1E293B); final button = Material( - color: backgroundColor ?? colors.surfaceContainer.withValues(alpha: 0.9), + color: backgroundColor ?? defaultBg.withValues(alpha: 0.9), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), side: BorderSide( diff --git a/lib/features/replay/replay_screen.dart b/lib/features/replay/replay_screen.dart index e6d9cf8..ae3f55a 100644 --- a/lib/features/replay/replay_screen.dart +++ b/lib/features/replay/replay_screen.dart @@ -74,6 +74,8 @@ class _ReplayScreenState extends ConsumerState { double _playbackSpeed = 1.0; int _currentIndex = 0; Timer? _playbackTimer; + double _zoom = 17.0; + bool _isFollowingRider = true; @override void initState() { @@ -325,6 +327,7 @@ class _ReplayScreenState extends ConsumerState { Widget build(BuildContext context) { final colors = context.motorColors; final theme = Theme.of(context); + final hasLeanData = _telemetry.any((t) => t.leanAngle != null); if (_isLoading) return const Scaffold(body: Center(child: CircularProgressIndicator())); if (_telemetry.isEmpty) { @@ -480,10 +483,49 @@ class _ReplayScreenState extends ConsumerState { children: [ UnifiedMapView( center: currentPos, - zoom: 17.0, + zoom: _zoom, markers: allMarkers, polylines: polylines, - isFollowModeOverride: true, + isFollowModeOverride: _isFollowingRider, + onUserInteraction: () { + if (_isFollowingRider) { + setState(() => _isFollowingRider = false); + } + }, + ), + // Map Navigation Controls (Zoom In, Zoom Out, Recenter) + Positioned( + right: 14, + bottom: 14, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!_isFollowingRider) ...[ + _ReplayMapButton( + key: const ValueKey('replay_recenter_button'), + icon: Icons.my_location, + tooltip: 'Recenter on Rider', + color: colors.cyberCyan, + backgroundColor: colors.cyberCyan.withValues(alpha: 0.25), + onTap: () => setState(() => _isFollowingRider = true), + ), + const SizedBox(height: 8), + ], + _ReplayMapButton( + key: const ValueKey('replay_zoom_in_button'), + icon: Icons.add, + tooltip: 'Zoom In', + onTap: () => setState(() => _zoom = (_zoom + 1.0).clamp(3.0, 19.0)), + ), + const SizedBox(height: 8), + _ReplayMapButton( + key: const ValueKey('replay_zoom_out_button'), + icon: Icons.remove, + tooltip: 'Zoom Out', + onTap: () => setState(() => _zoom = (_zoom - 1.0).clamp(3.0, 19.0)), + ), + ], + ), ), // Telemetry Overlay Positioned( @@ -619,7 +661,7 @@ class _ReplayScreenState extends ConsumerState { // Corner Entry / Apex / Exit Breakdown Table Expanded( - flex: 4, + flex: _corners.isEmpty ? 3 : 4, child: Container( color: theme.colorScheme.surfaceContainerLow, child: Column( @@ -646,7 +688,7 @@ class _ReplayScreenState extends ConsumerState { ), const SizedBox(width: 8), Text( - 'Tap row to seek apex', + _corners.isEmpty ? '' : 'Tap row to seek apex', style: TextStyle( fontSize: 10, fontFamily: 'JetBrains Mono', @@ -659,12 +701,52 @@ class _ReplayScreenState extends ConsumerState { if (_corners.isEmpty) Expanded( child: Center( - child: Text( - 'No distinct corners detected over 15° lean threshold.', - style: TextStyle( - fontSize: 12, - color: theme.colorScheme.onSurface.withValues(alpha: 0.6), - fontFamily: 'JetBrains Mono', + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + hasLeanData + ? Icons.straighten_rounded + : Icons.sensors_off_rounded, + size: 26, + color: hasLeanData + ? colors.cyberCyan.withValues(alpha: 0.7) + : colors.warningAmber.withValues(alpha: 0.8), + ), + const SizedBox(height: 6), + Text( + hasLeanData + ? 'No distinct corners detected over 15° lean threshold.' + : 'Sensor Fusion Uncalibrated / No Lean Telemetry', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface.withValues(alpha: 0.85), + fontFamily: 'JetBrains Mono', + ), + ), + const SizedBox(height: 4), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: Text( + hasLeanData + ? 'Upright riding recorded. Lean angle remained below 15° throughout this session.' + : 'Lean angle was not recorded or IMU was uncalibrated during this session.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurface.withValues(alpha: 0.55), + fontFamily: 'Inter', + ), + ), + ), + ], + ), ), ), ), @@ -861,3 +943,48 @@ class _ReplayScreenState extends ConsumerState { ); } } + +class _ReplayMapButton extends StatelessWidget { + final IconData icon; + final String tooltip; + final VoidCallback onTap; + final Color? color; + final Color? backgroundColor; + + const _ReplayMapButton({ + super.key, + required this.icon, + required this.tooltip, + required this.onTap, + this.color, + this.backgroundColor, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: backgroundColor ?? Colors.black87, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: (color ?? Colors.white).withValues(alpha: 0.3)), + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Tooltip( + message: tooltip, + child: SizedBox( + width: 48, + height: 48, + child: Icon( + icon, + color: color ?? Colors.white, + size: 24, + ), + ), + ), + ), + ); + } +} + diff --git a/lib/features/settings/account_screen.dart b/lib/features/settings/account_screen.dart index dcfeef9..ded10ae 100644 --- a/lib/features/settings/account_screen.dart +++ b/lib/features/settings/account_screen.dart @@ -11,6 +11,7 @@ import '../../services/sync/cloud_restore_controller.dart'; import '../../services/sync/sync_providers.dart'; import '../../services/group_rides/group_ride_providers.dart'; import 'widgets/current_plan_card.dart'; +import 'widgets/sync_status_inspector_screen.dart'; class AccountScreen extends ConsumerStatefulWidget { const AccountScreen({super.key}); @@ -1031,32 +1032,71 @@ class _AccountScreenState extends ConsumerState { ), ), ] else ...[ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colors.surfaceContainerHighest.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: colors.outlineVariant), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _buildSyncStatItem( - 'Synced', - '${syncState.syncedCount}', - colors.launchGreen, + InkWell( + key: const ValueKey('open_sync_inspector_button'), + borderRadius: BorderRadius.circular(8), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SyncStatusInspectorScreen(), ), - _buildSyncStatItem( - 'Pending', - '${syncState.pendingCount}', - colors.warningAmber, - ), - _buildSyncStatItem( - 'Failed', - '${syncState.failedCount}', - colors.raceRed, - ), - ], + ); + }, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colors.outlineVariant), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildSyncStatItem( + 'Synced', + '${syncState.syncedCount}', + colors.launchGreen, + ), + _buildSyncStatItem( + 'Pending', + '${syncState.pendingCount}', + colors.warningAmber, + ), + _buildSyncStatItem( + 'Failed', + '${syncState.failedCount}', + colors.raceRed, + ), + ], + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.list_alt_rounded, size: 12, color: colors.cyberCyan), + const SizedBox(width: 4), + Flexible( + child: Text( + 'Inspect Queue', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: colors.cyberCyan, + ), + ), + ), + const SizedBox(width: 2), + Icon(Icons.chevron_right, size: 12, color: colors.cyberCyan), + ], + ), + ], + ), ), ), const SizedBox(height: 12), @@ -1083,7 +1123,9 @@ class _AccountScreenState extends ConsumerState { const SizedBox(width: 8), Expanded( child: Text( - '${syncState.failedCount} ride(s) need attention. Tap Retry to process.', + '${syncState.failedCount} ride(s) need attention.', + maxLines: 1, + overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w600, @@ -1093,6 +1135,31 @@ class _AccountScreenState extends ConsumerState { TextButton( style: TextButton.styleFrom( visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 4), + minimumSize: const Size(40, 28), + foregroundColor: colors.cyberCyan, + ), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SyncStatusInspectorScreen(), + ), + ); + }, + child: const Text( + 'INSPECT', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 11, + ), + ), + ), + TextButton( + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 4), + minimumSize: const Size(40, 28), foregroundColor: colors.raceRed, ), onPressed: () => syncManager.retryAllPending(), diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index c480621..c10ef45 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -1238,13 +1238,23 @@ class _SettingsScreenState extends ConsumerState { setState(() => _isTestingGa511 = false); if (mounted) { + String statusMsg; + switch (status) { + case RoadAlertProviderStatus.connected: + statusMsg = 'Georgia 511 feed connected!'; + break; + case RoadAlertProviderStatus.authError: + statusMsg = 'Georgia 511 gateway: Authentication required (Sign in to MotorSignal)'; + break; + case RoadAlertProviderStatus.networkError: + statusMsg = 'Georgia 511 gateway: Unreachable (Network error)'; + break; + default: + statusMsg = 'Georgia 511 gateway status: ${status.name}'; + } messenger.showSnackBar( SnackBar( - content: Text( - status == RoadAlertProviderStatus.connected - ? 'Georgia 511 feed connected!' - : 'Georgia 511 gateway status: ${status.name}', - ), + content: Text(statusMsg), ), ); } @@ -1357,8 +1367,12 @@ class _SettingsScreenState extends ConsumerState { if (mounted) { messenger.showSnackBar( - const SnackBar( - content: Text('Waze OSINT adapter configured successfully!'), + SnackBar( + content: Text( + status == RoadAlertProviderStatus.connected + ? 'Waze OSINT feed connected!' + : 'Waze OSINT adapter configured locally (live upstream queries disabled by capability gate)', + ), ), ); } diff --git a/lib/features/settings/widgets/sync_status_inspector_screen.dart b/lib/features/settings/widgets/sync_status_inspector_screen.dart new file mode 100644 index 0000000..634a6e0 --- /dev/null +++ b/lib/features/settings/widgets/sync_status_inspector_screen.dart @@ -0,0 +1,500 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/theme/theme_extensions.dart'; +import '../../../database/database.dart'; +import '../../../services/sync/cloud_restore_service.dart'; +import '../../../services/sync/sync_models.dart'; +import '../../../services/sync/sync_outbox.dart'; +import '../../../services/sync/sync_providers.dart'; + +class SyncStatusInspectorScreen extends ConsumerStatefulWidget { + const SyncStatusInspectorScreen({super.key}); + + @override + ConsumerState createState() => + _SyncStatusInspectorScreenState(); +} + +enum _SyncFilter { all, pending, failed, synced } + +class _SyncStatusInspectorScreenState + extends ConsumerState { + _SyncFilter _currentFilter = _SyncFilter.all; + + @override + Widget build(BuildContext context) { + final colors = context.motorColors; + final theme = Theme.of(context); + final outbox = ref.watch(syncOutboxProvider); + final syncManager = ref.watch(syncManagerProvider); + final syncState = ref.watch(syncStateProvider); + final restoreState = ref.watch(cloudRestoreControllerProvider); + + final allItems = outbox.getAllItems().reversed.toList(); + final filteredItems = allItems.where((item) { + switch (_currentFilter) { + case _SyncFilter.all: + return true; + case _SyncFilter.pending: + return item.status.isPending || item.status.isInProgress; + case _SyncFilter.failed: + return item.status == SyncStatus.failed || + item.status == SyncStatus.retryWait || + item.status == SyncStatus.authRequired; + case _SyncFilter.synced: + return item.status == SyncStatus.synced; + } + }).toList(); + + return Scaffold( + appBar: AppBar( + title: const Text( + 'Sync Queue & Diagnostics', + style: TextStyle(fontFamily: 'Sora', fontSize: 16, fontWeight: FontWeight.bold), + ), + actions: [ + IconButton( + key: const ValueKey('refresh_sync_inspector_button'), + tooltip: 'Refresh Outbox', + icon: const Icon(Icons.refresh), + onPressed: () { + outbox.load(); + setState(() {}); + }, + ), + TextButton.icon( + key: const ValueKey('retry_all_sync_inspector_button'), + icon: const Icon(Icons.replay, size: 16), + label: const Text('RETRY ALL', style: TextStyle(fontWeight: FontWeight.bold)), + onPressed: () async { + await syncManager.retryAllPending(); + syncManager.processQueue(); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Re-armed pending outbox items and started sync.'), + duration: Duration(seconds: 2), + ), + ); + }, + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + // Restore reconciliation banner if items need attention + if (restoreState.result != null && restoreState.result!.failures > 0) + _buildRestoreNoticeBanner(restoreState.result!), + + // Status counter overview + _buildSummaryCard(outbox, syncState), + const SizedBox(height: 16), + + // Filter chips + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildFilterChip('All (${allItems.length})', _SyncFilter.all), + const SizedBox(width: 8), + _buildFilterChip( + 'Pending (${allItems.where((i) => i.status.isPending || i.status.isInProgress).length})', + _SyncFilter.pending, + ), + const SizedBox(width: 8), + _buildFilterChip( + 'Needs Attention (${allItems.where((i) => i.status == SyncStatus.failed || i.status == SyncStatus.retryWait || i.status == SyncStatus.authRequired).length})', + _SyncFilter.failed, + ), + const SizedBox(width: 8), + _buildFilterChip( + 'Synced (${allItems.where((i) => i.status == SyncStatus.synced).length})', + _SyncFilter.synced, + ), + ], + ), + ), + const SizedBox(height: 16), + + if (filteredItems.isEmpty) + Container( + padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 24), + alignment: Alignment.center, + child: Column( + children: [ + Icon(Icons.inbox_outlined, size: 48, color: colors.outlineVariant), + const SizedBox(height: 12), + Text( + 'No sync items in this view', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + 'Local rides are queued for backup when completed.', + style: TextStyle(fontSize: 12, color: colors.outlineVariant), + ), + ], + ), + ) + else + ...filteredItems.map((item) => _buildItemCard(item)), + ], + ), + ); + } + + Widget _buildRestoreNoticeBanner(CloudRestoreResult result) { + final colors = context.motorColors; + final reasons = []; + if (result.telemetryFailed > 0) { + reasons.add( + '${result.telemetryFailed} ride(s) have cloud metadata but no telemetry bundle in cloud storage yet (pending local upload).', + ); + } + if (result.telemetryAuthFailures > 0) { + reasons.add('${result.telemetryAuthFailures} ride(s) require re-authentication.'); + } + if (result.metadata.conflicts > 0) { + reasons.add('${result.metadata.conflicts} ride(s) had server/local metadata conflicts.'); + } + + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.warningAmber.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: colors.warningAmber.withValues(alpha: 0.5)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.info_outline, color: colors.warningAmber, size: 18), + const SizedBox(width: 8), + Text( + 'RESTORE STATUS — ${result.failures} ITEM(S) UNRESOLVED', + style: TextStyle( + fontFamily: 'Sora', + fontSize: 11, + fontWeight: FontWeight.w800, + color: colors.warningAmber, + letterSpacing: 0.5, + ), + ), + ], + ), + const SizedBox(height: 6), + ...reasons.map( + (r) => Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + '• $r', + style: const TextStyle(fontSize: 11, height: 1.35), + ), + ), + ), + ], + ), + ); + } + + Widget _buildSummaryCard(SyncOutbox outbox, dynamic syncState) { + final colors = context.motorColors; + final items = outbox.getAllItems(); + final synced = items.where((i) => i.status == SyncStatus.synced).length; + final pending = items.where((i) => i.status.isPending || i.status.isInProgress).length; + final failed = items.where((i) => i.status == SyncStatus.failed || i.status == SyncStatus.retryWait || i.status == SyncStatus.authRequired).length; + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colors.surfaceContainer, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colors.outlineVariant), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildStatColumn('Total Items', '${items.length}', colors.cyberCyan), + _buildStatColumn('Synced', '$synced', colors.launchGreen), + _buildStatColumn('Pending', '$pending', colors.warningAmber), + _buildStatColumn('Needs Action', '$failed', colors.raceRed), + ], + ), + ); + } + + Widget _buildStatColumn(String label, String value, Color color) { + return Column( + children: [ + Text( + value, + style: TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 18, + fontWeight: FontWeight.bold, + color: color, + ), + ), + const SizedBox(height: 2), + Text( + label, + style: TextStyle(fontSize: 10, color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ], + ); + } + + Widget _buildFilterChip(String label, _SyncFilter filter) { + final selected = _currentFilter == filter; + final colors = context.motorColors; + return ChoiceChip( + label: Text(label), + selected: selected, + onSelected: (_) => setState(() => _currentFilter = filter), + selectedColor: colors.cyberCyan.withValues(alpha: 0.2), + labelStyle: TextStyle( + fontSize: 11, + fontWeight: selected ? FontWeight.bold : FontWeight.normal, + color: selected ? colors.cyberCyan : null, + ), + ); + } + + Widget _buildItemCard(SyncItem item) { + final colors = context.motorColors; + final statusColor = _statusColor(item.status); + + return Card( + margin: const EdgeInsets.only(bottom: 10), + color: colors.surfaceContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.6)), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon(Icons.directions_bike, size: 16, color: colors.cyberCyan), + const SizedBox(width: 6), + Text( + 'Ride #${item.localRideId}', + style: const TextStyle( + fontFamily: 'Sora', + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: statusColor.withValues(alpha: 0.6)), + ), + child: Text( + item.status.name.toUpperCase(), + style: TextStyle( + fontFamily: 'Sora', + fontSize: 9, + fontWeight: FontWeight.bold, + color: statusColor, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + _buildDetailRow('Provider', item.providerId), + if (item.remoteId != null) + _buildDetailRow('Cloud Ride ID', item.remoteId!, isMono: true) + else + _buildDetailRow('Cloud Ride ID', 'Pending server assignment', isDim: true), + _buildDetailRow('Attempts', '${item.attemptCount}'), + if (item.lastAttemptAt != null) + _buildDetailRow('Last Attempt', _formatDate(item.lastAttemptAt!)), + if (item.nextRetryAt != null && item.status == SyncStatus.retryWait) + _buildDetailRow('Next Retry', _formatDate(item.nextRetryAt!), isHighlight: true), + if (item.uploadedAt != null) + _buildDetailRow('Uploaded At', _formatDate(item.uploadedAt!)), + + // Safe error reason display + if (item.lastError != null) ...[ + const SizedBox(height: 6), + Container( + width: double.infinity, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: colors.raceRed.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: colors.raceRed.withValues(alpha: 0.3)), + ), + child: Text( + 'Reason: ${item.lastError}', + style: TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 11, + color: colors.raceRed, + ), + ), + ), + ], + + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + icon: const Icon(Icons.info_outline, size: 14), + label: const Text('INSPECT', style: TextStyle(fontSize: 11)), + onPressed: () => _inspectItem(item), + ), + if (item.status != SyncStatus.synced) ...[ + const SizedBox(width: 8), + FilledButton.icon( + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + backgroundColor: colors.cyberCyan, + foregroundColor: Colors.black, + ), + icon: const Icon(Icons.refresh, size: 14), + label: const Text('RETRY', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold)), + onPressed: () => _retryItem(item), + ), + ], + ], + ), + ], + ), + ), + ); + } + + Widget _buildDetailRow(String label, String value, {bool isMono = false, bool isDim = false, bool isHighlight = false}) { + final colors = context.motorColors; + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant)), + Text( + value, + style: TextStyle( + fontFamily: isMono ? 'JetBrains Mono' : null, + fontSize: 11, + fontWeight: isHighlight ? FontWeight.bold : FontWeight.normal, + color: isHighlight + ? colors.warningAmber + : (isDim ? theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6) : null), + ), + ), + ], + ), + ); + } + + Color _statusColor(SyncStatus status) { + final colors = context.motorColors; + switch (status) { + case SyncStatus.synced: + return colors.launchGreen; + case SyncStatus.uploading: + return colors.cyberCyan; + case SyncStatus.queued: + return Colors.blueAccent; + case SyncStatus.waitingForNetwork: + case SyncStatus.retryWait: + return colors.warningAmber; + case SyncStatus.authRequired: + return Colors.purpleAccent; + case SyncStatus.failed: + case SyncStatus.cancelled: + return colors.raceRed; + case SyncStatus.localOnly: + return colors.outlineVariant; + } + } + + String _formatDate(DateTime dt) { + final l = dt.toLocal(); + return '${l.month.toString().padLeft(2, '0')}/${l.day.toString().padLeft(2, '0')} ${l.hour.toString().padLeft(2, '0')}:${l.minute.toString().padLeft(2, '0')}:${l.second.toString().padLeft(2, '0')}'; + } + + Future _retryItem(SyncItem item) async { + final outbox = ref.read(syncOutboxProvider); + final syncManager = ref.read(syncManagerProvider); + await outbox.enqueue(item.localRideId, item.providerId); + syncManager.processQueue(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Re-queued ride #${item.localRideId} for immediate sync.'), + duration: const Duration(seconds: 2), + ), + ); + setState(() {}); + } + } + + Future _inspectItem(SyncItem item) async { + final db = ref.read(databaseProvider); + final ride = await (db.select(db.rides)..where((r) => r.id.equals(item.localRideId))).getSingleOrNull(); + + if (!mounted) return; + + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text('Ride #${item.localRideId} Details'), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('Sync Outbox Key: ${item.key}'), + Text('Status: ${item.status.name}'), + Text('Created: ${_formatDate(item.createdAt)}'), + if (item.remoteId != null) Text('Cloud Ride ID: ${item.remoteId}'), + if (item.lastError != null) ...[ + const SizedBox(height: 8), + const Text('Last Error:', style: TextStyle(fontWeight: FontWeight.bold)), + Text(item.lastError!), + ], + const Divider(height: 20), + if (ride != null) ...[ + const Text('Local Database Ride:', style: TextStyle(fontWeight: FontWeight.bold)), + Text('Distance: ${ride.distanceMiles.toStringAsFixed(1)} mi'), + Text('Moving Time: ${ride.movingTime} s'), + Text('Cloud Owner: ${ride.cloudOwnerUid ?? 'Unclaimed (device local)'}'), + Text('Cloud Ride ID: ${ride.cloudRideId ?? 'None'}'), + ] else ...[ + const Text('Local ride not found in database (may have been purged).'), + ], + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('CLOSE')), + ], + ), + ); + } +} diff --git a/lib/features/social/feed/social_feed_screen.dart b/lib/features/social/feed/social_feed_screen.dart index a8d05da..d36d801 100644 --- a/lib/features/social/feed/social_feed_screen.dart +++ b/lib/features/social/feed/social_feed_screen.dart @@ -122,15 +122,15 @@ class _SocialFeedScreenState extends ConsumerState { if (!widget.showAppBar) { return Container( - color: const Color(0xFF0D1117), + color: Theme.of(context).colorScheme.surface, child: content, ); } return Scaffold( - backgroundColor: const Color(0xFF0D1117), + backgroundColor: Theme.of(context).colorScheme.surface, appBar: AppBar( - backgroundColor: const Color(0xFF0B0E13).withValues(alpha: 0.95), + backgroundColor: Theme.of(context).colorScheme.surface, elevation: 0, leading: IconButton( icon: const Icon(Icons.arrow_back_rounded, color: Colors.white), @@ -232,25 +232,27 @@ class _SocialFeedScreenState extends ConsumerState { ) { return Container( padding: const EdgeInsets.fromLTRB(16, 10, 16, 8), - color: const Color(0xFF0D1117), + color: Theme.of(context).colorScheme.surface, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Row 1: Status LED, Active metric & Search toggle - Wrap( - alignment: WrapAlignment.spaceBetween, - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 8, - runSpacing: 6, + // Row 1: Status LED & Search toggle + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, children: [ - // Status LED pill (Required by test suite) + // Status LED pill Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( - color: const Color(0xFF161B22), + 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: 0.25), + color: AppColors.electricCyan.withValues( + alpha: Theme.of(context).brightness == Brightness.light ? 0.6 : 0.25, + ), ), ), child: Row( @@ -272,48 +274,40 @@ class _SocialFeedScreenState extends ConsumerState { ), ), const SizedBox(width: 8), - const Text( + Text( 'SQUAD & CANYON FEED', style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 0.6, - color: Color(0xFF9CF0FF), + color: Theme.of(context).brightness == Brightness.light + ? const Color(0xFF006064) + : const Color(0xFF9CF0FF), ), ), ], ), ), - // Live sensor metric pill (Required by test suite) - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: const Color(0xFF1E2530), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: const [ - Icon( - Icons.sensors_rounded, - size: 14, - color: AppColors.electricCyan, - ), - SizedBox(width: 6), - Text( - '24 ACTIVE', - style: TextStyle( - fontFamily: AppTypography.fontTelemetry, - fontSize: 11, - fontWeight: FontWeight.w700, - letterSpacing: 0.6, - color: Color(0xFF9CF0FF), - ), - ), - ], + // Search toggle button + IconButton( + key: const ValueKey('feed_search_toggle_button'), + icon: Icon( + _isSearchExpanded ? Icons.close_rounded : Icons.search_rounded, + color: AppColors.electricCyan, + size: 20, ), + tooltip: 'Search Feed', + onPressed: () { + setState(() { + _isSearchExpanded = !_isSearchExpanded; + if (!_isSearchExpanded) { + _searchController.clear(); + ref.read(feedSearchQueryProvider.notifier).state = ''; + } + }); + }, ), ], ), @@ -372,62 +366,49 @@ class _SocialFeedScreenState extends ConsumerState { ), ], - const SizedBox(height: 10), - - // Stream Selector (Scrollable horizontally to guarantee zero RenderFlex overflow) - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: const Color(0xFF0B0E13), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xFF1E2633)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _buildFilterTab( - tab: FeedFilter.allActivity, - title: 'All Activity', - isSelected: activeFilter == FeedFilter.allActivity, - ), - const SizedBox(width: 4), - _buildFilterTab( - tab: FeedFilter.clubOnly, - title: 'Club Only', - isSelected: activeFilter == FeedFilter.clubOnly, - ), - const SizedBox(width: 4), - _buildFilterTab( - tab: FeedFilter.nearbyPacks, - title: 'Nearby Packs', - isSelected: activeFilter == FeedFilter.nearbyPacks, - ), - const SizedBox(width: 4), - _buildFilterTab( - tab: FeedFilter.forYou, - title: 'For You', - isSelected: activeFilter == FeedFilter.forYou, - ), - const SizedBox(width: 4), - _buildFilterTab( - tab: FeedFilter.following, - title: 'Following', - isSelected: activeFilter == FeedFilter.following, - ), - ], - ), - ), - ), - const SizedBox(height: 8), - // Riding Discipline Filter Chips + // Unified Single Streamlined Filter Row (Streams + Disciplines) SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ + _buildFilterTab( + tab: FeedFilter.allActivity, + title: 'All Activity', + isSelected: activeFilter == FeedFilter.allActivity, + ), + const SizedBox(width: 4), + _buildFilterTab( + tab: FeedFilter.clubOnly, + title: 'Club Only', + isSelected: activeFilter == FeedFilter.clubOnly, + ), + const SizedBox(width: 4), + _buildFilterTab( + tab: FeedFilter.nearbyPacks, + title: 'Nearby Packs', + isSelected: activeFilter == FeedFilter.nearbyPacks, + ), + const SizedBox(width: 4), + _buildFilterTab( + tab: FeedFilter.forYou, + title: 'For You', + isSelected: activeFilter == FeedFilter.forYou, + ), + const SizedBox(width: 4), + _buildFilterTab( + tab: FeedFilter.following, + title: 'Following', + isSelected: activeFilter == FeedFilter.following, + ), + const SizedBox(width: 8), + Container( + width: 1, + height: 20, + color: colors.outlineVariant.withValues(alpha: 0.4), + ), + const SizedBox(width: 8), _buildDisciplineChip( discipline: RidingDiscipline.all, label: 'All Styles', @@ -553,7 +534,9 @@ class _SocialFeedScreenState extends ConsumerState { letterSpacing: 0.1, color: isSelected ? const Color(0xFF00363D) - : const Color(0xFFCBD5E1), + : (Theme.of(context).brightness == Brightness.light + ? const Color(0xFF334155) + : const Color(0xFFCBD5E1)), ), ), ), @@ -566,6 +549,7 @@ class _SocialFeedScreenState extends ConsumerState { required bool isSelected, }) { final chipColor = _getDisciplineColor(discipline); + final isLight = Theme.of(context).brightness == Brightness.light; return InkWell( borderRadius: BorderRadius.circular(8), @@ -578,12 +562,12 @@ class _SocialFeedScreenState extends ConsumerState { decoration: BoxDecoration( color: isSelected ? chipColor.withValues(alpha: 0.22) - : const Color(0xFF161B22), + : (isLight ? const Color(0xFFF1F5F9) : const Color(0xFF161B22)), borderRadius: BorderRadius.circular(8), border: Border.all( color: isSelected ? chipColor.withValues(alpha: 0.7) - : const Color(0xFF263238), + : (isLight ? const Color(0xFFCBD5E1) : const Color(0xFF263238)), width: isSelected ? 1.2 : 1.0, ), ), @@ -593,7 +577,9 @@ class _SocialFeedScreenState extends ConsumerState { fontFamily: AppTypography.fontHeadline, fontSize: 11.5, fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, - color: isSelected ? Colors.white : const Color(0xFF94A3B8), + color: isSelected + ? (isLight ? const Color(0xFF0F172A) : Colors.white) + : (isLight ? const Color(0xFF475569) : const Color(0xFF94A3B8)), ), ), ), @@ -626,27 +612,71 @@ class _SocialFeedScreenState extends ConsumerState { hasCustomFilters ? 'No matching ride sessions' : 'No posts in ${activeFilter.displayName}', - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontHeadline, fontSize: 16, fontWeight: FontWeight.w700, - color: Colors.white, + color: Theme.of(context).colorScheme.onSurface, ), ), const SizedBox(height: 6), Text( hasCustomFilters ? 'Try relaxing your style filters or search query to discover more community rides.' - : 'Hit "Share Recent Session" below to publish telemetry from your latest ride.', + : 'Hit "Share Recent Session" below to publish telemetry, or check out group rides and clubs.', textAlign: TextAlign.center, - style: const TextStyle( + style: TextStyle( fontFamily: AppTypography.fontBody, fontSize: 13, - color: Color(0xFF94A3B8), + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: [ + OutlinedButton.icon( + key: const ValueKey('empty_feed_group_rides_button'), + icon: const Icon(Icons.two_wheeler_rounded, size: 16), + label: const Text('Group Rides'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.electricCyan, + side: BorderSide( + color: AppColors.electricCyan.withValues(alpha: 0.5), + ), + ), + onPressed: () => context.push('/group-rides'), + ), + OutlinedButton.icon( + key: const ValueKey('empty_feed_clubs_button'), + icon: const Icon(Icons.groups_rounded, size: 16), + label: const Text('Clubs'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.electricCyan, + side: BorderSide( + color: AppColors.electricCyan.withValues(alpha: 0.5), + ), + ), + onPressed: () => context.push('/social/clubs'), + ), + OutlinedButton.icon( + key: const ValueKey('empty_feed_friends_button'), + icon: const Icon(Icons.person_add_rounded, size: 16), + label: const Text('Friends'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.electricCyan, + side: BorderSide( + color: AppColors.electricCyan.withValues(alpha: 0.5), + ), + ), + onPressed: () => context.push('/social/friends'), + ), + ], + ), if (hasCustomFilters) ...[ - const SizedBox(height: 16), + const SizedBox(height: 12), OutlinedButton.icon( icon: const Icon(Icons.refresh_rounded, size: 16), label: const Text('Reset All Filters'), 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 3860cd8..55d8aee 100644 --- a/lib/features/social/feed/widgets/feed_comments_bottom_sheet.dart +++ b/lib/features/social/feed/widgets/feed_comments_bottom_sheet.dart @@ -110,26 +110,26 @@ class _FeedCommentsBottomSheetState ? livePost.comments : _localComments; - return Container( - decoration: BoxDecoration( - color: const Color(0xFF101419), - borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), - border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.5)), - boxShadow: const [ - BoxShadow( - color: Colors.black87, - blurRadius: 30, - offset: Offset(0, -10), - ), - ], + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.85, ), - padding: EdgeInsets.only(bottom: bottomInset), - child: SafeArea( - top: false, - child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.75, - ), + child: Container( + decoration: BoxDecoration( + color: const Color(0xFF101419), + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.5)), + boxShadow: const [ + BoxShadow( + color: Colors.black87, + blurRadius: 30, + offset: Offset(0, -10), + ), + ], + ), + padding: EdgeInsets.only(bottom: bottomInset), + child: SafeArea( + top: false, child: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/features/social/feed/widgets/share_session_modal.dart b/lib/features/social/feed/widgets/share_session_modal.dart index ab54daf..2852482 100644 --- a/lib/features/social/feed/widgets/share_session_modal.dart +++ b/lib/features/social/feed/widgets/share_session_modal.dart @@ -1,8 +1,11 @@ +import 'dart:math' as math; +import 'package:drift/drift.dart' as drift; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_typography.dart'; 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/social/social_feed_models.dart'; @@ -29,10 +32,8 @@ 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 Canyon Carve'); + final _locationController = TextEditingController(text: 'Skyline Blvd & Highway 9'); final _bikeController = TextEditingController(); final _distanceController = TextEditingController(text: '48.5'); final _durationController = TextEditingController(text: '52'); @@ -42,21 +43,96 @@ class _ShareSessionModalState extends ConsumerState { RidingDiscipline _selectedDiscipline = RidingDiscipline.canyonSport; 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), + ]; + @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { + WidgetsBinding.instance.addPostFrameCallback((_) async { final defaultBike = ref.read(defaultMotorcycleProvider); if (defaultBike != null) { _bikeController.text = - '${defaultBike.year} ${defaultBike.makeName} ${defaultBike.modelName}' - .trim(); + '${defaultBike.year} ${defaultBike.makeName} ${defaultBike.modelName}'.trim(); } else { _bikeController.text = '2024 Ducati Streetfighter V4'; } + + await _loadRecentRides(); }); } + Future _loadRecentRides() async { + try { + final db = ref.read(databaseProvider); + final rides = await (db.select(db.rides) + ..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); + } + }); + } + } catch (_) { + // Ignore database retrieval errors on rides + } + } + + Future _selectRide(Ride ride) async { + _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; + + if (ride.motorcycleMake != null && ride.motorcycleMake!.isNotEmpty) { + _bikeController.text = + '${ride.motorcycleYear ?? ''} ${ride.motorcycleMake} ${ride.motorcycleModel ?? ''}'.trim(); + } + + 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; + }); + } + } + } catch (_) {} + if (mounted) setState(() {}); + } + @override void dispose() { _titleController.dispose(); @@ -107,12 +183,7 @@ class _ShareSessionModalState extends ConsumerState { elevationFt: 1840, discipline: _selectedDiscipline, snapshotType: FeedSnapshotType.routeTrack, - routePoints: const [ - AppLatLng(37.3861, -122.2641), - AppLatLng(37.3500, -122.2300), - AppLatLng(37.3100, -122.2000), - AppLatLng(37.2800, -122.2200), - ], + routePoints: _selectedRoutePoints, ); // Reset filter to All Activity so published session is immediately displayed @@ -155,103 +226,168 @@ class _ShareSessionModalState extends ConsumerState { final colors = context.motorColors; final bottomInset = MediaQuery.of(context).viewInsets.bottom; - return Container( - decoration: BoxDecoration( - color: const Color(0xFF101419), - borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), - border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.5)), - boxShadow: const [ - BoxShadow( - color: Colors.black87, - blurRadius: 30, - offset: Offset(0, -10), - ), - ], + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.9, ), - padding: EdgeInsets.only(bottom: bottomInset), - child: SafeArea( - top: false, - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), + child: Container( + decoration: BoxDecoration( + color: const Color(0xFF101419), + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.5)), + boxShadow: const [ + BoxShadow( + color: Colors.black87, + blurRadius: 30, + offset: Offset(0, -10), + ), + ], + ), + padding: EdgeInsets.only(bottom: bottomInset), + child: SafeArea( + top: false, child: Form( key: _formKey, child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - // Drag handle - Center( - child: Container( - margin: const EdgeInsets.only(bottom: 12), - width: 44, - height: 4, - decoration: BoxDecoration( - color: colors.outlineVariant, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - - // Modal Header - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: AppColors.electricCyan.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(10), - ), - child: const Icon( - Icons.add_circle_outline_rounded, - color: AppColors.electricCyan, - size: 20, - ), + // Modal Header & Handle + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + Center( + child: Container( + margin: const EdgeInsets.only(bottom: 12), + width: 44, + height: 4, + decoration: BoxDecoration( + color: colors.outlineVariant, + borderRadius: BorderRadius.circular(2), ), - const SizedBox(width: 10), + ), + ), + // Modal Header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - const Text( - 'Share Recent Session', - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontFamily: AppTypography.fontHeadline, - fontSize: 16, - fontWeight: FontWeight.w700, - color: Colors.white, + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.electricCyan.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.add_circle_outline_rounded, + color: AppColors.electricCyan, + size: 20, ), ), - Text( - 'Publish telemetry & route to community feed', - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontFamily: AppTypography.fontBody, - fontSize: 12, - color: colors.outline, + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Share Recent Session', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + Text( + 'Publish telemetry & route to community feed', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 12, + color: colors.outline, + ), + ), + ], ), ), ], ), ), + IconButton( + icon: const Icon(Icons.close_rounded, + color: Color(0xFF849396)), + onPressed: () => Navigator.of(context).pop(), + ), ], ), - ), - IconButton( - icon: const Icon(Icons.close_rounded, - color: Color(0xFF849396)), - onPressed: () => Navigator.of(context).pop(), - ), - ], + ], + ), ), - const SizedBox(height: 16), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ - // Ride Title Field - _buildFieldLabel('SESSION / ROUTE TITLE', colors), + // Recent Database Rides Selector + if (_recentRides.isNotEmpty) ...[ + _buildFieldLabel('SELECT RECORDED SESSION', colors), + Container( + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + color: const Color(0xFF161B22), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.electricCyan.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( + fontFamily: AppTypography.fontHeadline, + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + items: _recentRides.map((ride) { + final dist = ride.distanceMiles > 0 ? ride.distanceMiles : ride.distance; + final maxL = math.max(ride.maxLeanLeft, ride.maxLeanRight); + return DropdownMenuItem( + value: ride, + child: Row( + children: [ + const Icon(Icons.two_wheeler_rounded, size: 16, color: AppColors.electricCyan), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Ride #${ride.id} • ${dist.toStringAsFixed(1)} mi • ${maxL.toStringAsFixed(0)}° lean • ${ride.startTime.month}/${ride.startTime.day}', + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + }).toList(), + onChanged: (val) { + if (val != null) _selectRide(val); + }, + ), + ), + ), + const SizedBox(height: 14), + ], + + // Ride Title Field + _buildFieldLabel('SESSION / ROUTE TITLE', colors), TextFormField( controller: _titleController, style: const TextStyle( @@ -491,58 +627,65 @@ class _ShareSessionModalState extends ConsumerState { ), ], ), - const SizedBox(height: 24), - - // Publish CTA Button - SizedBox( - height: 52, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.electricCyan, - foregroundColor: const Color(0xFF00363D), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - elevation: 4, - ), - onPressed: _isSubmitting ? null : _handlePublish, - child: _isSubmitting - ? const SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - strokeWidth: 2.5, - color: Color(0xFF00363D), - ), - ) - : Row( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - Icon(Icons.share_rounded, size: 20), - SizedBox(width: 8), - Flexible( - child: Text( - 'PUBLISH TO COMMUNITY FEED', - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontFamily: AppTypography.fontHeadline, - fontSize: 13, - fontWeight: FontWeight.w700, - letterSpacing: 0.5, - ), - ), - ), - ], - ), - ), - ), ], ), ), ), - ), - ); - } + + // Publish CTA Button (pinned at modal bottom) + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), + child: SizedBox( + height: 52, + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.electricCyan, + foregroundColor: const Color(0xFF00363D), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 4, + ), + onPressed: _isSubmitting ? null : _handlePublish, + child: _isSubmitting + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: Color(0xFF00363D), + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Icon(Icons.share_rounded, size: 20), + SizedBox(width: 8), + Flexible( + child: Text( + 'PUBLISH TO COMMUNITY FEED', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 13, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), +), +), +); +} Widget _buildFieldLabel(String label, MotorSignalThemeColors colors) { return Padding( diff --git a/lib/features/social/rider_profile_screen.dart b/lib/features/social/rider_profile_screen.dart index 352c7a5..38bbf42 100644 --- a/lib/features/social/rider_profile_screen.dart +++ b/lib/features/social/rider_profile_screen.dart @@ -393,15 +393,15 @@ class _RiderProfileScreenState extends ConsumerState { const SizedBox(height: 20), TextFormField( controller: _handleController, - style: const TextStyle(color: Colors.white), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), decoration: InputDecoration( labelText: 'Rider Handle', hintText: 'e.g. canyon_runner', prefixText: '@', helperText: 'Unique, 3-20 chars: letters, numbers, underscores', - helperStyle: const TextStyle( - color: Colors.white38, fontSize: 11), + helperStyle: TextStyle( + color: colors.outline, fontSize: 11), border: const OutlineInputBorder(), filled: true, fillColor: colors.surfaceContainer, @@ -439,7 +439,7 @@ class _RiderProfileScreenState extends ConsumerState { const SizedBox(height: 16), TextFormField( controller: _displayNameController, - style: const TextStyle(color: Colors.white), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), decoration: InputDecoration( labelText: 'Display Name', hintText: 'e.g. Alex Rivera', @@ -476,7 +476,7 @@ class _RiderProfileScreenState extends ConsumerState { children: [ TextFormField( controller: _regionController, - style: const TextStyle(color: Colors.white), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), decoration: InputDecoration( labelText: 'Riding Region (Optional)', hintText: 'e.g. Blue Ridge Mountains, NC', @@ -484,11 +484,13 @@ class _RiderProfileScreenState extends ConsumerState { filled: true, fillColor: colors.surfaceContainer, ), + onChanged: (_) => setState(() {}), ), + _buildRegionChips(colors), const SizedBox(height: 16), TextFormField( controller: _bioController, - style: const TextStyle(color: Colors.white), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), maxLines: 3, decoration: InputDecoration( labelText: 'Rider Bio (Optional)', @@ -569,14 +571,14 @@ class _RiderProfileScreenState extends ConsumerState { TextFormField( controller: _handleController, - style: const TextStyle(color: Colors.white), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), decoration: InputDecoration( labelText: 'Rider Handle', hintText: 'e.g. canyon_runner', prefixText: '@', helperText: 'Unique, 3-20 characters: letters, numbers, underscores', helperStyle: - const TextStyle(color: Colors.white38, fontSize: 11), + TextStyle(color: colors.outline, fontSize: 11), border: const OutlineInputBorder(), filled: true, fillColor: colors.surfaceContainerHigh, @@ -615,7 +617,7 @@ class _RiderProfileScreenState extends ConsumerState { TextFormField( controller: _displayNameController, - style: const TextStyle(color: Colors.white), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), decoration: InputDecoration( labelText: 'Display Name', hintText: 'e.g. Alex Rivera', @@ -629,7 +631,7 @@ class _RiderProfileScreenState extends ConsumerState { TextFormField( controller: _regionController, - style: const TextStyle(color: Colors.white), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), decoration: InputDecoration( labelText: 'Riding Region (Optional)', hintText: 'e.g. Blue Ridge Mountains, NC', @@ -637,12 +639,14 @@ class _RiderProfileScreenState extends ConsumerState { filled: true, fillColor: colors.surfaceContainerHigh, ), + onChanged: (_) => setState(() {}), ), + _buildRegionChips(colors), const SizedBox(height: 16), TextFormField( controller: _bioController, - style: const TextStyle(color: Colors.white), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), maxLines: 3, decoration: InputDecoration( labelText: 'Rider Bio (Optional)', @@ -730,11 +734,11 @@ class _RiderProfileScreenState extends ConsumerState { SwitchListTile( contentPadding: EdgeInsets.zero, activeThumbColor: colors.cyberCyan, - title: const Text('Discoverable by Handle', - style: TextStyle(fontSize: 14)), - subtitle: const Text( + title: Text('Discoverable by Handle', + style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurface)), + subtitle: Text( 'Allow other riders to find your profile by searching your @handle.', - style: TextStyle(fontSize: 11, color: Colors.white60), + style: TextStyle(fontSize: 11, color: colors.outline), ), value: _isSearchableByHandle, onChanged: (val) => @@ -744,11 +748,11 @@ class _RiderProfileScreenState extends ConsumerState { SwitchListTile( contentPadding: EdgeInsets.zero, activeThumbColor: colors.cyberCyan, - title: const Text('Public Profile', - style: TextStyle(fontSize: 14)), - subtitle: const Text( + title: Text('Public Profile', + style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurface)), + subtitle: Text( 'When off, non-friends only see your handle and avatar, hiding bio and region.', - style: TextStyle(fontSize: 11, color: Colors.white60), + style: TextStyle(fontSize: 11, color: colors.outline), ), value: _isProfilePublic, onChanged: (val) => @@ -758,11 +762,11 @@ class _RiderProfileScreenState extends ConsumerState { SwitchListTile( contentPadding: EdgeInsets.zero, activeThumbColor: colors.cyberCyan, - title: const Text('Allow Friend Requests', - style: TextStyle(fontSize: 14)), - subtitle: const Text( + title: Text('Allow Friend Requests', + style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurface)), + subtitle: Text( 'Allow other riders to send you friend requests.', - style: TextStyle(fontSize: 11, color: Colors.white60), + style: TextStyle(fontSize: 11, color: colors.outline), ), value: _allowFriendRequests, onChanged: (val) => @@ -774,6 +778,68 @@ class _RiderProfileScreenState extends ConsumerState { ); } + static const List _popularRegions = [ + 'Tail of the Dragon', + 'Bay Area & NorCal Canyons', + 'SoCal Canyons (ACH / PCH)', + 'Blue Ridge Parkway', + 'Twisted Sisters (TX)', + 'Smoky Mountains', + 'Colorado Rockies', + 'Ozark Twisties', + ]; + + Widget _buildRegionChips(MotorSignalThemeColors colors) { + return Padding( + padding: const EdgeInsets.only(top: 8.0, bottom: 4.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'POPULAR RIDING REGIONS', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + color: colors.outline, + ), + ), + const SizedBox(height: 6), + Wrap( + spacing: 8, + runSpacing: 8, + children: _popularRegions.map((region) { + final isSelected = _regionController.text.trim().toLowerCase() == region.toLowerCase(); + return FilterChip( + label: Text( + region, + style: TextStyle( + fontSize: 11, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + color: isSelected ? const Color(0xFF003840) : Theme.of(context).colorScheme.onSurface, + ), + ), + selected: isSelected, + selectedColor: colors.cyberCyan, + backgroundColor: colors.surfaceContainer, + checkmarkColor: const Color(0xFF003840), + side: BorderSide( + color: isSelected ? colors.cyberCyan : colors.outlineVariant.withValues(alpha: 0.6), + ), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + onSelected: (_) { + setState(() { + _regionController.text = region; + }); + }, + ); + }).toList(), + ), + ], + ), + ); + } + Widget _buildSaveButton(MotorSignalThemeColors colors) { return SizedBox( height: 48, diff --git a/lib/features/social/widgets/avatar_badge.dart b/lib/features/social/widgets/avatar_badge.dart index c6699ee..c0dbbbe 100644 --- a/lib/features/social/widgets/avatar_badge.dart +++ b/lib/features/social/widgets/avatar_badge.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import '../../../core/theme/app_typography.dart'; import '../../../services/social/rider_profile_models.dart'; /// Renders a rider's avatar using their preset styling or custom photo URL. @@ -35,10 +34,6 @@ class AvatarBadge extends StatelessWidget { backgroundImage: NetworkImage(photoUrl!), ); } else { - final initial = displayName.trim().isNotEmpty - ? displayName.trim()[0].toUpperCase() - : 'R'; - inner = CircleAvatar( radius: radius, backgroundColor: secondaryColor, @@ -51,14 +46,10 @@ class AvatarBadge extends StatelessWidget { ), ), alignment: Alignment.center, - child: Text( - initial, - style: TextStyle( - color: primaryColor, - fontWeight: FontWeight.w900, - fontSize: radius * 0.9, - fontFamily: AppTypography.fontHeadline, - ), + child: Icon( + preset.icon, + color: primaryColor, + size: radius * 1.15, ), ), ); diff --git a/lib/features/social/widgets/avatar_selector.dart b/lib/features/social/widgets/avatar_selector.dart index c499ae0..ac82e7e 100644 --- a/lib/features/social/widgets/avatar_selector.dart +++ b/lib/features/social/widgets/avatar_selector.dart @@ -18,10 +18,10 @@ class AvatarSelector extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( + Text( 'SELECT AVATAR PRESET', style: TextStyle( - color: Colors.white70, + color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 11, fontWeight: FontWeight.w800, letterSpacing: 0.8, diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart index a1a6788..375acb7 100644 --- a/lib/firebase_options.dart +++ b/lib/firebase_options.dart @@ -90,4 +90,8 @@ class DefaultFirebaseOptions { projectId: 'motoapp-d22e0', storageBucket: 'motoapp-d22e0.firebasestorage.app', ); + + /// Authoritative Web OAuth client ID for Google Sign-In on Android/Web. + static const String webClientId = + '809431485362-ar11dsgjrq7327d122b55352gs6q6sml.apps.googleusercontent.com'; } diff --git a/lib/services/alerts/ga511_road_alert_provider.dart b/lib/services/alerts/ga511_road_alert_provider.dart index 49e0b9d..3fa02b0 100644 --- a/lib/services/alerts/ga511_road_alert_provider.dart +++ b/lib/services/alerts/ga511_road_alert_provider.dart @@ -9,10 +9,12 @@ import 'road_alert_provider_interface.dart'; class Georgia511RoadAlertProvider implements RoadAlertProvider { final http.Client _client; final String backendBaseUrl; + final Future Function()? getAuthToken; Georgia511RoadAlertProvider({ http.Client? client, String? backendBaseUrl, + this.getAuthToken, }) : _client = client ?? http.Client(), backendBaseUrl = backendBaseUrl ?? _resolveDefaultBaseUrl(); @@ -38,13 +40,39 @@ class Georgia511RoadAlertProvider implements RoadAlertProvider { RoadAlertCategory.hazard, }; + Future> _buildHeaders({String? apiKey}) async { + final headers = { + 'Accept': 'application/json', + }; + if (getAuthToken != null) { + try { + final token = await getAuthToken!(); + if (token != null && token.isNotEmpty) { + headers['Authorization'] = 'Bearer $token'; + return headers; + } + } catch (e) { + if (kDebugMode) { + debugPrint('[Georgia511RoadAlertProvider] Error resolving auth token: $e'); + } + } + } + if (apiKey != null && apiKey.isNotEmpty) { + headers['Authorization'] = 'Bearer $apiKey'; + } + return headers; + } + @override Future testConnection(String apiKey) async { try { + final headers = await _buildHeaders(apiKey: apiKey); final uri = Uri.parse('$backendBaseUrl/api/v1/alerts/ops/health'); - final res = await _client.get(uri).timeout(const Duration(seconds: 4)); + final res = await _client.get(uri, headers: headers).timeout(const Duration(seconds: 4)); if (res.statusCode == 200) { return RoadAlertProviderStatus.connected; + } else if (res.statusCode == 401 || res.statusCode == 403) { + return RoadAlertProviderStatus.authError; } return RoadAlertProviderStatus.networkError; } catch (e) { @@ -81,7 +109,8 @@ class Georgia511RoadAlertProvider implements RoadAlertProvider { ); try { - final response = await _client.get(uri).timeout(const Duration(seconds: 5)); + final headers = await _buildHeaders(apiKey: apiKey); + final response = await _client.get(uri, headers: headers).timeout(const Duration(seconds: 5)); if (response.statusCode != 200) { return []; } diff --git a/lib/services/alerts/road_alert_providers.dart b/lib/services/alerts/road_alert_providers.dart index 1c38c01..b952cac 100644 --- a/lib/services/alerts/road_alert_providers.dart +++ b/lib/services/alerts/road_alert_providers.dart @@ -23,7 +23,10 @@ final zylaProviderInstance = Provider((ref) { }); final ga511ProviderInstance = Provider((ref) { - return Georgia511RoadAlertProvider(); + final authService = ref.watch(authServiceProvider); + return Georgia511RoadAlertProvider( + getAuthToken: () => authService.getIdToken(), + ); }); final wazeProviderInstance = Provider((ref) { @@ -522,7 +525,7 @@ final ga511AlertsEnabledProvider = final ga511StatusProvider = StateProvider((ref) { final enabled = ref.watch(ga511AlertsEnabledProvider); - return enabled ? RoadAlertProviderStatus.connected : RoadAlertProviderStatus.notConfigured; + return enabled ? RoadAlertProviderStatus.configured : RoadAlertProviderStatus.notConfigured; }); // Active Providers List diff --git a/lib/services/auth/auth_service.dart b/lib/services/auth/auth_service.dart index 4f9ffa7..034fd4e 100644 --- a/lib/services/auth/auth_service.dart +++ b/lib/services/auth/auth_service.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_auth/firebase_auth.dart' as fb; import 'package:google_sign_in/google_sign_in.dart'; +import '../../firebase_options.dart'; import 'auth_state.dart'; import 'auth_user.dart'; @@ -44,7 +45,10 @@ class FirebaseAuthService implements AuthService { fb.FirebaseAuth? firebaseAuth, GoogleSignIn? googleSignIn, }) : _firebaseAuth = firebaseAuth ?? _resolveFirebaseAuth(), - _googleSignIn = googleSignIn ?? GoogleSignIn() { + _googleSignIn = googleSignIn ?? + GoogleSignIn( + serverClientId: DefaultFirebaseOptions.webClientId, + ) { _init(); } @@ -274,7 +278,10 @@ class FirebaseAuthService implements AuthService { )); try { - final googleSignIn = _googleSignIn ?? GoogleSignIn(); + final googleSignIn = _googleSignIn ?? + GoogleSignIn( + serverClientId: DefaultFirebaseOptions.webClientId, + ); final GoogleSignInAccount? googleUser = await googleSignIn.signIn(); if (googleUser == null) { @@ -442,7 +449,11 @@ class FirebaseAuthService implements AuthService { if (password == null || password.isEmpty || user.email == null) throw StateError('Enter your current password.'); credential = fb.EmailAuthProvider.credential(email: user.email!, password: password); } else if (providers.contains('google.com')) { - final googleUser = await (_googleSignIn ?? GoogleSignIn()).signIn(); + final googleUser = await (_googleSignIn ?? + GoogleSignIn( + serverClientId: DefaultFirebaseOptions.webClientId, + )) + .signIn(); if (googleUser == null) throw StateError('Google reauthentication was cancelled.'); final googleAuth = await googleUser.authentication; credential = fb.GoogleAuthProvider.credential(accessToken: googleAuth.accessToken, idToken: googleAuth.idToken); diff --git a/lib/services/map/aws_map_provider.dart b/lib/services/map/aws_map_provider.dart index b8902dc..3c00cad 100644 --- a/lib/services/map/aws_map_provider.dart +++ b/lib/services/map/aws_map_provider.dart @@ -35,6 +35,7 @@ class AwsMapProvider implements AppMapProvider { required BuildContext context, required AppLatLng center, required double zoom, + double bearing = 0.0, required List markers, required List polylines, required bool isFollowMode, @@ -59,6 +60,7 @@ class AwsMapProvider implements AppMapProvider { return _AwsMapView( center: center, zoom: zoom, + bearing: bearing, markers: markers, polylines: polylines, isFollowMode: isFollowMode, @@ -72,6 +74,7 @@ class AwsMapProvider implements AppMapProvider { class _AwsMapView extends StatefulWidget { final AppLatLng center; final double zoom; + final double bearing; final List markers; final List polylines; final bool isFollowMode; @@ -82,6 +85,7 @@ class _AwsMapView extends StatefulWidget { const _AwsMapView({ required this.center, required this.zoom, + this.bearing = 0.0, required this.markers, required this.polylines, required this.isFollowMode, @@ -108,16 +112,22 @@ class _AwsMapViewState extends State<_AwsMapView> { @override void didUpdateWidget(covariant _AwsMapView oldWidget) { super.didUpdateWidget(oldWidget); + final centerLl = + ll.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 centerLl = - ll.LatLng(widget.center.latitude, widget.center.longitude); final now = DateTime.now(); final posDelta = _lastFollowPos == null ? 1.0 : (centerLl.latitude - _lastFollowPos!.latitude).abs() + (centerLl.longitude - _lastFollowPos!.longitude).abs(); - final shouldMove = _lastFollowPos == null || + final shouldMove = justBecameFollowMode || + _lastFollowPos == null || + zoomChanged || (posDelta > 0.00005 && (_lastFollowTime == null || now.difference(_lastFollowTime!).inMilliseconds > 500)); @@ -133,6 +143,24 @@ class _AwsMapViewState extends State<_AwsMapView> { } }); } + } 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 (_) {} + } + }); } } @@ -156,6 +184,7 @@ class _AwsMapViewState extends State<_AwsMapView> { options: MapOptions( initialCenter: centerLl, initialZoom: widget.zoom, + initialRotation: widget.bearing, minZoom: widget.config.minZoom, maxZoom: widget.config.maxZoom, backgroundColor: const Color(0xFF0A0F14), diff --git a/lib/services/map/google_map_provider.dart b/lib/services/map/google_map_provider.dart index 8497252..4cd2bf4 100644 --- a/lib/services/map/google_map_provider.dart +++ b/lib/services/map/google_map_provider.dart @@ -17,6 +17,7 @@ class GoogleMapProvider implements AppMapProvider { required BuildContext context, required AppLatLng center, required double zoom, + double bearing = 0.0, required List markers, required List polylines, required bool isFollowMode, diff --git a/lib/services/map/map_provider_interface.dart b/lib/services/map/map_provider_interface.dart index 517411f..b05b306 100644 --- a/lib/services/map/map_provider_interface.dart +++ b/lib/services/map/map_provider_interface.dart @@ -129,6 +129,7 @@ abstract class AppMapProvider { required BuildContext context, required AppLatLng center, required double zoom, + double bearing = 0.0, required List markers, required List polylines, required bool isFollowMode, diff --git a/lib/services/map/open_street_map_provider.dart b/lib/services/map/open_street_map_provider.dart index c25e291..6884da6 100644 --- a/lib/services/map/open_street_map_provider.dart +++ b/lib/services/map/open_street_map_provider.dart @@ -16,6 +16,7 @@ class OpenStreetMapProvider implements AppMapProvider { required BuildContext context, required AppLatLng center, required double zoom, + double bearing = 0.0, required List markers, required List polylines, required bool isFollowMode, @@ -26,6 +27,7 @@ class OpenStreetMapProvider implements AppMapProvider { return _OsmMapView( center: center, zoom: zoom, + bearing: bearing, markers: markers, polylines: polylines, isFollowMode: isFollowMode, @@ -39,6 +41,7 @@ class OpenStreetMapProvider implements AppMapProvider { class _OsmMapView extends StatefulWidget { final AppLatLng center; final double zoom; + final double bearing; final List markers; final List polylines; final bool isFollowMode; @@ -49,6 +52,7 @@ class _OsmMapView extends StatefulWidget { const _OsmMapView({ required this.center, required this.zoom, + this.bearing = 0.0, required this.markers, required this.polylines, required this.isFollowMode, @@ -75,16 +79,24 @@ class _OsmMapViewState extends State<_OsmMapView> { @override void didUpdateWidget(covariant _OsmMapView oldWidget) { super.didUpdateWidget(oldWidget); + final centerLl = ll.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 centerLl = ll.LatLng(widget.center.latitude, widget.center.longitude); final now = DateTime.now(); final posDelta = _lastFollowPos == null ? 1.0 : (centerLl.latitude - _lastFollowPos!.latitude).abs() + (centerLl.longitude - _lastFollowPos!.longitude).abs(); - final shouldMove = _lastFollowPos == null || - (posDelta > 0.00005 && (_lastFollowTime == null || now.difference(_lastFollowTime!).inMilliseconds > 500)); + final shouldMove = justBecameFollowMode || + _lastFollowPos == null || + zoomChanged || + (posDelta > 0.00005 && + (_lastFollowTime == null || + now.difference(_lastFollowTime!).inMilliseconds > 500)); if (shouldMove) { _lastFollowPos = centerLl; @@ -97,6 +109,24 @@ class _OsmMapViewState extends State<_OsmMapView> { } }); } + } 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 (_) {} + } + }); } } @@ -118,9 +148,12 @@ class _OsmMapViewState extends State<_OsmMapView> { options: MapOptions( initialCenter: centerLl, initialZoom: widget.zoom, + initialRotation: widget.bearing, minZoom: widget.config.minZoom, maxZoom: widget.config.maxZoom, - backgroundColor: const Color(0xFF0F0F0F), + backgroundColor: widget.config.isDarkMode + ? const Color(0xFF0F0F0F) + : const Color(0xFFE5E5E5), onPositionChanged: (position, hasGesture) { if (hasGesture) { widget.onUserInteraction?.call(); diff --git a/lib/services/map/unified_map_view.dart b/lib/services/map/unified_map_view.dart index e095c1c..3092221 100644 --- a/lib/services/map/unified_map_view.dart +++ b/lib/services/map/unified_map_view.dart @@ -7,6 +7,7 @@ import 'map_providers.dart'; class UnifiedMapView extends ConsumerWidget { final AppLatLng center; final double zoom; + final double bearing; final List markers; final List polylines; final ValueChanged? onPositionChanged; @@ -18,6 +19,7 @@ class UnifiedMapView extends ConsumerWidget { super.key, required this.center, this.zoom = 16.0, + this.bearing = 0.0, this.markers = const [], this.polylines = const [], this.onPositionChanged, @@ -42,7 +44,13 @@ class UnifiedMapView extends ConsumerWidget { switch (layerSettings.style) { case TacticalMapStyle.tacticalDark: provider = ref.watch(openStreetMapProviderInstance); - activeConfig = MapConfig.tacticalDark(offlineOnly: layerSettings.offlineOnly); + final isDarkTheme = Theme.of(context).brightness == Brightness.dark; + activeConfig = isDarkTheme + ? MapConfig.tacticalDark(offlineOnly: layerSettings.offlineOnly) + : MapConfig( + isDarkMode: false, + offlineOnly: layerSettings.offlineOnly, + ); break; case TacticalMapStyle.satellite4K: final aws = ref.watch(awsMapProviderInstance); @@ -75,6 +83,7 @@ class UnifiedMapView extends ConsumerWidget { context: context, center: center, zoom: zoom, + bearing: bearing, markers: markers, polylines: polylines, isFollowMode: isFollowMode, diff --git a/lib/services/navigation/navigation_session_service.dart b/lib/services/navigation/navigation_session_service.dart index 0cc849f..ac089c8 100644 --- a/lib/services/navigation/navigation_session_service.dart +++ b/lib/services/navigation/navigation_session_service.dart @@ -145,6 +145,7 @@ class NavigationSessionNotifier extends StateNotifier { bool _ttsInitialized = false; bool _isRecalculating = false; DateTime? _lastRecalculationAttempt; + int _lastMatchedSegment = 0; static const double defaultOffRouteThresholdMeters = 65.0; @@ -202,6 +203,8 @@ class NavigationSessionNotifier extends StateNotifier { return; } + _lastMatchedSegment = 0; + final initialDist = calculationResult.estimatedDistanceMeters ?? calculationResult.straightLineDistanceMeters; final initialDuration = calculationResult.estimatedDurationSeconds ?? @@ -274,11 +277,16 @@ class NavigationSessionNotifier extends StateNotifier { void updateLocation(AppLatLng riderPos, {double speedMph = 0.0}) { if (!state.isNavigating || state.maneuvers.isEmpty) return; - // Off-route detection check against polyline points final points = state.calculationResult?.points ?? []; if (points.isNotEmpty) { - final distToRoute = _distanceToPolylineMeters(riderPos, points); - if (distToRoute > defaultOffRouteThresholdMeters) { + final proj = _projectPointOntoPolyline( + riderPos, + points, + startSearchSegment: math.max(0, _lastMatchedSegment - 2), + ); + + // Off-route detection check against polyline + if (proj.distanceToRouteMeters > defaultOffRouteThresholdMeters) { if (!_isRecalculating) { final now = DateTime.now(); if (_lastRecalculationAttempt == null || @@ -289,8 +297,79 @@ class NavigationSessionNotifier extends StateNotifier { } return; } + + _lastMatchedSegment = proj.segmentIndex; + + // Check arrival at final destination stop + final lastManeuver = state.maneuvers.last; + final distToDest = _haversineMeters(riderPos, lastManeuver.location); + if (distToDest <= 25.0 && + (proj.segmentIndex >= points.length - 2 || state.currentManeuverIndex >= state.maneuvers.length - 1)) { + _completeArrival(); + return; + } + + // Remaining distance strictly counts down along route polyline + final remainingMeters = _remainingDistanceAlongPolyline( + points, + proj.segmentIndex, + proj.projectedPoint, + ); + + // Maneuver advancement based on polyline progression + final offsets = _getManeuverOffsets(state.maneuvers, points); + int currIdx = state.currentManeuverIndex; + + // If at step 0 (Depart), advance only once rider has genuinely departed (moving > 3.0 mph or past start point) + if (currIdx == 0 && state.maneuvers.first.type == ManeuverType.depart && state.maneuvers.length > 1) { + final distFromStart = _haversineMeters(riderPos, state.maneuvers.first.location); + if (speedMph > 3.0 || distFromStart > 35.0 || proj.segmentIndex > 0) { + currIdx = 1; + } + } + + // Advance through completed subsequent maneuvers along the route (step 1+) + while (currIdx > 0 && currIdx < state.maneuvers.length - 1) { + final targetOffset = offsets[currIdx]; + final targetLoc = state.maneuvers[currIdx].location; + final distToTarget = _haversineMeters(riderPos, targetLoc); + + // Advance if rider reached within 25m of maneuver point or passed its polyline index + if (distToTarget <= 25.0 || (proj.segmentIndex >= targetOffset && targetOffset > 0)) { + currIdx++; + } else { + break; + } + } + + // Announce updated maneuver if advanced + if (currIdx != state.currentManeuverIndex && currIdx < state.maneuvers.length) { + _speakInstruction(state.maneuvers[currIdx].instruction); + } + + // Distance to next active maneuver along polyline + final targetOffset = offsets[currIdx]; + final distToNext = _distanceToOffsetAlongPolyline( + points, + proj.segmentIndex, + proj.projectedPoint, + targetOffset, + targetLocation: state.maneuvers[currIdx].location, + ); + + final effectiveSpeedMps = speedMph > 5.0 ? (speedMph * 0.44704) : 13.4; + final remainingSec = (remainingMeters / effectiveSpeedMps).round(); + + state = state.copyWith( + currentManeuverIndex: currIdx, + distanceToNextManeuverMeters: distToNext, + remainingDistanceMeters: remainingMeters, + remainingDurationSeconds: remainingSec, + ); + return; } + // Fallback when polyline points are not available final currIdx = state.currentManeuverIndex; if (currIdx >= state.maneuvers.length) { _completeArrival(); @@ -300,7 +379,6 @@ class NavigationSessionNotifier extends StateNotifier { final currentManeuver = state.maneuvers[currIdx]; final distToManeuver = _haversineMeters(riderPos, currentManeuver.location); - // If within 25 meters of maneuver point, advance to next maneuver if (distToManeuver <= 25.0 && currIdx < state.maneuvers.length - 1) { final nextIdx = currIdx + 1; final nextManeuver = state.maneuvers[nextIdx]; @@ -315,13 +393,11 @@ class NavigationSessionNotifier extends StateNotifier { return; } - // Recompute remaining distance along maneuvers double remainingMeters = distToManeuver; for (int i = currIdx + 1; i < state.maneuvers.length; i++) { remainingMeters += state.maneuvers[i].distanceMeters; } - // Dynamic duration based on speed (or default 30 mph if stationary) final effectiveSpeedMps = speedMph > 5.0 ? (speedMph * 0.44704) : 13.4; final remainingSec = (remainingMeters / effectiveSpeedMps).round(); @@ -383,6 +459,7 @@ class NavigationSessionNotifier extends StateNotifier { final initialDuration = newResult.estimatedDurationSeconds ?? (initialDist / 15.0).round(); + _lastMatchedSegment = 0; state = state.copyWith( status: NavigationStatus.navigating, calculationResult: newResult, @@ -421,35 +498,159 @@ class NavigationSessionNotifier extends StateNotifier { } catch (_) {} } - /// Computes shortest distance from point to polyline in meters. - static double _distanceToPolylineMeters(AppLatLng point, List polyline) { - if (polyline.isEmpty) return double.infinity; - if (polyline.length == 1) return _haversineMeters(point, polyline.first); + /// Projects a GPS point onto the route polyline with segment index tracking. + static _PolylineProjection _projectPointOntoPolyline( + AppLatLng point, + List polyline, { + int startSearchSegment = 0, + }) { + if (polyline.isEmpty) { + return _PolylineProjection( + segmentIndex: 0, + projectedPoint: point, + distanceToRouteMeters: 0.0, + ); + } + if (polyline.length == 1) { + return _PolylineProjection( + segmentIndex: 0, + projectedPoint: polyline.first, + distanceToRouteMeters: _haversineMeters(point, polyline.first), + ); + } + int bestSegment = 0; + AppLatLng bestProj = polyline.first; double minDistance = double.infinity; - for (int i = 0; i < polyline.length - 1; i++) { - final segDist = _distanceToSegmentMeters(point, polyline[i], polyline[i + 1]); - if (segDist < minDistance) { - minDistance = segDist; + + final searchStart = startSearchSegment.clamp(0, polyline.length - 2); + for (int i = searchStart; i < polyline.length - 1; i++) { + final a = polyline[i]; + final b = polyline[i + 1]; + final proj = _projectPointOntoSegment(point, a, b); + final dist = _haversineMeters(point, proj); + if (dist < minDistance) { + minDistance = dist; + bestSegment = i; + bestProj = proj; } } - return minDistance; + + // If searching forward yielded an unacceptable match (> threshold) + // and search started midway, search earlier segments in case rider turned around + if (minDistance > defaultOffRouteThresholdMeters && searchStart > 0) { + for (int i = 0; i < searchStart; i++) { + final a = polyline[i]; + final b = polyline[i + 1]; + final proj = _projectPointOntoSegment(point, a, b); + final dist = _haversineMeters(point, proj); + if (dist < minDistance) { + minDistance = dist; + bestSegment = i; + bestProj = proj; + } + } + } + + return _PolylineProjection( + segmentIndex: bestSegment, + projectedPoint: bestProj, + distanceToRouteMeters: minDistance, + ); } - static double _distanceToSegmentMeters(AppLatLng p, AppLatLng a, AppLatLng b) { + static AppLatLng _projectPointOntoSegment(AppLatLng p, AppLatLng a, AppLatLng b) { final dAB = _haversineMeters(a, b); - if (dAB == 0) return _haversineMeters(p, a); + if (dAB == 0.0) return a; - final dAP = _haversineMeters(a, p); - final dBP = _haversineMeters(b, p); + final latRad = a.latitude * (math.pi / 180.0); + final cosLat = math.cos(latRad); + final dx = (b.longitude - a.longitude) * cosLat; + final dy = b.latitude - a.latitude; + final px = (p.longitude - a.longitude) * cosLat; + final py = p.latitude - a.latitude; - if (dBP * dBP >= dAP * dAP + dAB * dAB) return dAP; - if (dAP * dAP >= dBP * dBP + dAB * dAB) return dBP; + final lenSq = dx * dx + dy * dy; + if (lenSq == 0.0) return a; - final s = (dAB + dAP + dBP) / 2.0; - final areaSq = s * (s - dAB) * (s - dAP) * (s - dBP); - if (areaSq <= 0.0) return 0.0; - return (2.0 * math.sqrt(areaSq)) / dAB; + final t = ((px * dx + py * dy) / lenSq).clamp(0.0, 1.0); + return AppLatLng( + a.latitude + t * (b.latitude - a.latitude), + a.longitude + t * (b.longitude - a.longitude), + ); + } + + /// Calculates strictly decreasing remaining distance along the polyline. + static double _remainingDistanceAlongPolyline( + List polyline, + int segmentIndex, + AppLatLng projectedPoint, + ) { + if (polyline.isEmpty) return 0.0; + if (polyline.length == 1) return 0.0; + if (segmentIndex >= polyline.length - 1) { + return _haversineMeters(projectedPoint, polyline.last); + } + + double remaining = _haversineMeters(projectedPoint, polyline[segmentIndex + 1]); + for (int i = segmentIndex + 1; i < polyline.length - 1; i++) { + remaining += _haversineMeters(polyline[i], polyline[i + 1]); + } + return remaining; + } + + /// Calculates distance along polyline from projected point to a maneuver offset. + static double _distanceToOffsetAlongPolyline( + List polyline, + int segmentIndex, + AppLatLng projectedPoint, + int targetOffset, { + required AppLatLng targetLocation, + }) { + if (polyline.isEmpty) return _haversineMeters(projectedPoint, targetLocation); + if (segmentIndex >= targetOffset) { + return _haversineMeters(projectedPoint, targetLocation); + } + + double dist = _haversineMeters(projectedPoint, polyline[segmentIndex + 1]); + final limit = math.min(targetOffset, polyline.length - 1); + for (int i = segmentIndex + 1; i < limit; i++) { + dist += _haversineMeters(polyline[i], polyline[i + 1]); + } + return dist; + } + + /// Resolves monotonically increasing polyline indices for each maneuver. + static List _getManeuverOffsets( + List maneuvers, + List points, + ) { + final offsets = []; + for (int i = 0; i < maneuvers.length; i++) { + final m = maneuvers[i]; + if (m.geometryOffset != null && + m.geometryOffset! >= 0 && + m.geometryOffset! < points.length) { + offsets.add(m.geometryOffset!); + } else { + int bestIdx = 0; + double bestDist = double.infinity; + for (int j = 0; j < points.length; j++) { + final d = _haversineMeters(points[j], m.location); + if (d < bestDist) { + bestDist = d; + bestIdx = j; + } + } + offsets.add(bestIdx); + } + } + for (int i = 1; i < offsets.length; i++) { + if (offsets[i] < offsets[i - 1]) { + offsets[i] = offsets[i - 1]; + } + } + return offsets; } static double _haversineMeters(AppLatLng p1, AppLatLng p2) { @@ -469,6 +670,18 @@ class NavigationSessionNotifier extends StateNotifier { } } +class _PolylineProjection { + final int segmentIndex; + final AppLatLng projectedPoint; + final double distanceToRouteMeters; + + const _PolylineProjection({ + required this.segmentIndex, + required this.projectedPoint, + required this.distanceToRouteMeters, + }); +} + /// Global shared navigation session provider across Map and Dashboard. final navigationSessionProvider = StateNotifierProvider( diff --git a/lib/services/routes/aws_location_route_provider.dart b/lib/services/routes/aws_location_route_provider.dart index cb7d18c..2f7a5e6 100644 --- a/lib/services/routes/aws_location_route_provider.dart +++ b/lib/services/routes/aws_location_route_provider.dart @@ -64,6 +64,19 @@ class AwsLocationRouteProvider implements RouteProvider { final seenIds = {}; final combined = []; + // Sort text results with coordinates by physical proximity to rider + if (nearLocation != null) { + textItems.sort((a, b) { + final da = (a.latitude != null && a.longitude != null) + ? _distanceMeters(nearLocation, AppLatLng(a.latitude!, a.longitude!)) + : double.infinity; + final db = (b.latitude != null && b.longitude != null) + ? _distanceMeters(nearLocation, AppLatLng(b.latitude!, b.longitude!)) + : double.infinity; + return da.compareTo(db); + }); + } + // Prefer text items first because coordinates are already populated for (final item in textItems) { if (seenIds.add(item.providerId)) { @@ -101,6 +114,9 @@ class AwsLocationRouteProvider implements RouteProvider { body: jsonEncode({ 'QueryText': clean, 'BiasPosition': bias, + 'Filter': { + 'IncludeCountries': ['USA'], + }, }), ) .timeout(const Duration(seconds: 8)); @@ -142,6 +158,9 @@ class AwsLocationRouteProvider implements RouteProvider { body: jsonEncode({ 'QueryText': clean, 'BiasPosition': bias, + 'Filter': { + 'IncludeCountries': ['USA'], + }, }), ) .timeout(const Duration(seconds: 8)); @@ -276,6 +295,12 @@ class AwsLocationRouteProvider implements RouteProvider { .toList() : null; + final avoidMap = { + if (effectiveAvoidHighways) 'ControlledAccessHighways': true, + if (effectiveAvoidTolls) 'TollRoads': true, + if (effectiveAvoidFerries) 'Ferries': true, + }; + final travelOptions = { 'Car': { 'Avoid': { @@ -291,12 +316,15 @@ class AwsLocationRouteProvider implements RouteProvider { 'Destination': destination, 'TravelMode': 'Car', 'TravelModeOptions': travelOptions, + if (avoidMap.isNotEmpty) 'Avoid': avoidMap, 'OptimizeRoutingFor': optimizeFor, 'LegGeometryFormat': 'Simple', 'LegAdditionalInputs': ['TravelSteps'], 'Instructions': true, if (options?.preference == RoutingPreference.twisty || - options?.preference == RoutingPreference.scenic) + options?.preference == RoutingPreference.scenic || + options?.preference == RoutingPreference.fastest || + options?.preference == RoutingPreference.shortest) 'MaxAlternatives': 2, }; if (waypoints != null && waypoints.isNotEmpty) { @@ -358,6 +386,18 @@ class AwsLocationRouteProvider implements RouteProvider { } } + if (geom.isEmpty) { + final rGMap = r['Geometry'] as Map?; + final rLineString = rGMap?['LineString'] as List?; + if (rLineString != null) { + for (final pt in rLineString) { + if (pt is List && pt.length >= 2) { + geom.add(AppLatLng((pt[1] as num).toDouble(), (pt[0] as num).toDouble())); + } + } + } + } + final effectiveGeom = geom.isNotEmpty ? geom : ordered.map((s) => s.latLng).toList(); final effectiveDist = distanceM > 0 ? distanceM : straightLine; final distMi = effectiveDist * 0.000621371; @@ -393,6 +433,9 @@ class AwsLocationRouteProvider implements RouteProvider { } else if (options?.preference == RoutingPreference.fastest) { candidates.sort((a, b) => a.durationSeconds.compareTo(b.durationSeconds)); selected = candidates.first; + } else if (options?.preference == RoutingPreference.scenic) { + candidates.sort((a, b) => b.curvature.compareTo(a.curvature)); + selected = candidates.first; } } @@ -431,15 +474,38 @@ class AwsLocationRouteProvider implements RouteProvider { for (final rawStep in travelSteps) { if (rawStep is! Map) continue; - final instruction = rawStep['Instruction']?.toString() ?? 'Continue'; - final stepType = rawStep['Type']?.toString(); - final turn = rawStep['Turn']?.toString(); + final stepType = rawStep['Type']?.toString() ?? 'Continue'; final dist = (rawStep['Distance'] as num?)?.toDouble() ?? 0.0; final dur = (rawStep['Duration'] as num?)?.toInt() ?? 0; final road = rawStep['NextRoad']?.toString() ?? rawStep['CurrentRoad']?.toString(); final offset = (rawStep['GeometryOffset'] as num?)?.toInt(); + // Extract steering direction from AWS Location Routes V2 details objects + final turnStep = rawStep['TurnStepDetails'] as Map?; + final rampStep = rawStep['RampStepDetails'] as Map?; + final keepStep = rawStep['KeepStepDetails'] as Map?; + final uTurnStep = rawStep['UTurnStepDetails'] as Map?; + final roundaboutStep = rawStep['RoundaboutStepDetails'] as Map?; + + final turn = turnStep?['SteeringDirection']?.toString() ?? + rampStep?['SteeringDirection']?.toString() ?? + keepStep?['SteeringDirection']?.toString() ?? + uTurnStep?['SteeringDirection']?.toString() ?? + roundaboutStep?['SteeringDirection']?.toString() ?? + rawStep['Turn']?.toString(); + + final initialType = _mapManeuverType(stepType, turn); + final canonical = _canonicalizeManeuver( + stepType: stepType, + turn: turn, + road: road, + rawInstruction: rawStep['Instruction']?.toString(), + initialType: initialType, + offset: offset, + geometry: geometry, + ); + AppLatLng loc; if (offset != null && offset >= 0 && offset < geometry.length) { loc = geometry[offset]; @@ -452,8 +518,8 @@ class AwsLocationRouteProvider implements RouteProvider { maneuvers.add( RouteManeuver( index: maneuvers.length, - instruction: instruction, - type: _mapManeuverType(stepType, turn), + instruction: canonical.instruction, + type: canonical.type, distanceMeters: dist, durationSeconds: dur, streetName: road, @@ -472,6 +538,202 @@ class AwsLocationRouteProvider implements RouteProvider { return maneuvers; } + static ({ManeuverType type, String instruction}) _canonicalizeManeuver({ + required String stepType, + required String? turn, + required String? road, + required String? rawInstruction, + required ManeuverType initialType, + required int? offset, + required List geometry, + }) { + ManeuverType resolvedType = initialType; + String resolvedInstruction = _buildManeuverInstruction( + stepType: stepType, + turn: turn, + road: road, + rawInstruction: rawInstruction, + maneuverType: initialType, + ); + + // Calculate polyline bearing delta at offset if geometry is available + double? bearingDelta; + if (geometry.length >= 2 && offset != null && offset >= 0 && offset < geometry.length - 1) { + final prevIdx = (offset > 0) ? offset - 1 : 0; + final nextIdx = (offset + 1 < geometry.length) ? offset + 1 : offset; + if (prevIdx != offset && offset != nextIdx) { + final b1 = _bearing(geometry[prevIdx], geometry[offset]); + final b2 = _bearing(geometry[offset], geometry[nextIdx]); + var d = b2 - b1; + while (d < -180.0) { + d += 360.0; + } + while (d > 180.0) { + d -= 360.0; + } + bearingDelta = d; + } + } + + final rawLower = (rawInstruction ?? '').toLowerCase().trim(); + final bool rawHasLeft = rawLower.contains('left'); + final bool rawHasRight = rawLower.contains('right'); + final bool rawHasUturn = rawLower.contains('u-turn') || rawLower.contains('uturn'); + + // 1. If raw instruction explicitly states U-turn + if (rawHasUturn) { + resolvedType = ManeuverType.uTurn; + } + // 2. If raw instruction specifies turn direction but resolvedType is continueStraight + else if (resolvedType == ManeuverType.continueStraight) { + if (rawHasLeft) { + if (rawLower.contains('sharp')) { + resolvedType = ManeuverType.turnSharpLeft; + } else if (rawLower.contains('slight') || rawLower.contains('bear')) { + resolvedType = ManeuverType.turnSlightLeft; + } else if (rawLower.contains('keep')) { + resolvedType = ManeuverType.keepLeft; + } else { + resolvedType = ManeuverType.turnLeft; + } + } else if (rawHasRight) { + if (rawLower.contains('sharp')) { + resolvedType = ManeuverType.turnSharpRight; + } else if (rawLower.contains('slight') || rawLower.contains('bear')) { + resolvedType = ManeuverType.turnSlightRight; + } else if (rawLower.contains('keep')) { + resolvedType = ManeuverType.keepRight; + } else { + resolvedType = ManeuverType.turnRight; + } + } else if (stepType.toLowerCase() == 'turn' && + bearingDelta != null && + bearingDelta.abs() >= 20.0) { + // Only convert to turn if step was an explicit Turn step lacking steering direction. + // Pure road bends (Type: 'Continue' / 'Follow') along the existing road remain continueStraight. + if (bearingDelta > 120.0) { + resolvedType = ManeuverType.turnSharpRight; + } else if (bearingDelta > 45.0) { + resolvedType = ManeuverType.turnRight; + } else if (bearingDelta > 20.0) { + resolvedType = ManeuverType.turnSlightRight; + } else if (bearingDelta < -120.0) { + resolvedType = ManeuverType.turnSharpLeft; + } else if (bearingDelta < -45.0) { + resolvedType = ManeuverType.turnLeft; + } else if (bearingDelta < -20.0) { + resolvedType = ManeuverType.turnSlightLeft; + } + resolvedInstruction = _buildManeuverInstruction( + stepType: stepType, + turn: resolvedType.name, + road: road, + rawInstruction: null, + maneuverType: resolvedType, + ); + } + } + // 3. Direct conflict resolution: instruction says left but type says right (or vice versa) + else if (resolvedType.isLeft && rawHasRight && !rawHasLeft) { + if (bearingDelta != null && bearingDelta > 20.0) { + resolvedType = ManeuverType.turnRight; + } else if (bearingDelta != null && bearingDelta < -20.0) { + final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : ''; + resolvedInstruction = 'Turn left$roadSuffix'; + } else { + resolvedType = ManeuverType.turnRight; + } + } else if (resolvedType.isRight && rawHasLeft && !rawHasRight) { + if (bearingDelta != null && bearingDelta < -20.0) { + resolvedType = ManeuverType.turnLeft; + } else if (bearingDelta != null && bearingDelta > 20.0) { + final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : ''; + resolvedInstruction = 'Turn right$roadSuffix'; + } else { + resolvedType = ManeuverType.turnLeft; + } + } + + // 4. Authoritative geometry override: only for explicit Turn steps where provider claimed wrong direction + if (bearingDelta != null && (stepType.toLowerCase() == 'turn' || rawHasLeft || rawHasRight)) { + if (resolvedType.isRight && bearingDelta < -35.0) { + resolvedType = bearingDelta < -120.0 + ? ManeuverType.turnSharpLeft + : (bearingDelta < -45.0 ? ManeuverType.turnLeft : ManeuverType.turnSlightLeft); + final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : ''; + resolvedInstruction = 'Turn left$roadSuffix'; + } else if (resolvedType.isLeft && bearingDelta > 35.0) { + resolvedType = bearingDelta > 120.0 + ? ManeuverType.turnSharpRight + : (bearingDelta > 45.0 ? ManeuverType.turnRight : ManeuverType.turnSlightRight); + final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : ''; + resolvedInstruction = 'Turn right$roadSuffix'; + } + } + + // Re-verify that resolved instruction and resolved type agree on direction + if (resolvedType.isLeft && resolvedInstruction.toLowerCase().contains('right')) { + final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : ''; + resolvedInstruction = 'Turn left$roadSuffix'; + } else if (resolvedType.isRight && resolvedInstruction.toLowerCase().contains('left')) { + final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : ''; + resolvedInstruction = 'Turn right$roadSuffix'; + } + + return (type: resolvedType, instruction: resolvedInstruction); + } + + static String _buildManeuverInstruction({ + required String stepType, + required String? turn, + required String? road, + required String? rawInstruction, + required ManeuverType maneuverType, + }) { + if (rawInstruction != null && + rawInstruction.trim().isNotEmpty && + rawInstruction.trim().toLowerCase() != 'continue') { + return rawInstruction.trim(); + } + + final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : ''; + + switch (maneuverType) { + case ManeuverType.depart: + return roadSuffix.isNotEmpty ? 'Head out$roadSuffix' : 'Head towards destination'; + case ManeuverType.arrive: + return 'Arrive at destination'; + case ManeuverType.turnRight: + return 'Turn right$roadSuffix'; + case ManeuverType.turnLeft: + return 'Turn left$roadSuffix'; + case ManeuverType.turnSharpRight: + return 'Turn sharp right$roadSuffix'; + case ManeuverType.turnSharpLeft: + return 'Turn sharp left$roadSuffix'; + case ManeuverType.turnSlightRight: + return 'Turn slight right$roadSuffix'; + case ManeuverType.turnSlightLeft: + return 'Turn slight left$roadSuffix'; + case ManeuverType.uTurn: + return 'Make a U-turn$roadSuffix'; + case ManeuverType.ramp: + final side = (turn?.toLowerCase() == 'left') ? ' on the left' : ' on the right'; + return 'Take the ramp$side$roadSuffix'; + case ManeuverType.keepRight: + return 'Keep right$roadSuffix'; + case ManeuverType.keepLeft: + return 'Keep left$roadSuffix'; + case ManeuverType.enterRoundabout: + return 'Enter roundabout$roadSuffix'; + case ManeuverType.exitRoundabout: + return 'Exit roundabout$roadSuffix'; + case ManeuverType.continueStraight: + default: + return roadSuffix.isNotEmpty ? 'Continue$roadSuffix' : 'Continue straight'; + } + } + static ManeuverType _mapManeuverType(String? typeStr, String? turnStr) { final t = (typeStr ?? '').toLowerCase(); final turn = (turnStr ?? '').toLowerCase(); @@ -492,6 +754,15 @@ class AwsLocationRouteProvider implements RouteProvider { if (turn == 'left' || t == 'turnleft') return ManeuverType.turnLeft; if (turn == 'keepright') return ManeuverType.keepRight; if (turn == 'keepleft') return ManeuverType.keepLeft; + if (t == 'keep') { + if (turn.contains('left')) return ManeuverType.keepLeft; + if (turn.contains('right')) return ManeuverType.keepRight; + return ManeuverType.continueStraight; + } + if (t == 'exit') { + if (turn.contains('left')) return ManeuverType.turnLeft; + return ManeuverType.turnRight; + } if (t == 'continue') return ManeuverType.continueStraight; return ManeuverType.continueStraight; } @@ -606,6 +877,18 @@ class AwsLocationRouteProvider implements RouteProvider { final brng = math.atan2(y, x) * (180.0 / math.pi); return (brng + 360.0) % 360.0; } + + static double _distanceMeters(AppLatLng p1, AppLatLng p2) { + const double r = 6371000.0; + final lat1 = p1.latitude * (math.pi / 180.0); + final lat2 = p2.latitude * (math.pi / 180.0); + final dLat = (p2.latitude - p1.latitude) * (math.pi / 180.0); + final dLon = (p2.longitude - p1.longitude) * (math.pi / 180.0); + final a = math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(lat1) * math.cos(lat2) * math.sin(dLon / 2) * math.sin(dLon / 2); + final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); + return r * c; + } } class _CandidateRoute { diff --git a/lib/services/routes/route_models.dart b/lib/services/routes/route_models.dart index ef66ade..8de3733 100644 --- a/lib/services/routes/route_models.dart +++ b/lib/services/routes/route_models.dart @@ -518,6 +518,18 @@ enum ManeuverType { return 'CONTINUE'; } } + + bool get isLeft => + this == ManeuverType.turnLeft || + this == ManeuverType.turnSharpLeft || + this == ManeuverType.turnSlightLeft || + this == ManeuverType.keepLeft; + + bool get isRight => + this == ManeuverType.turnRight || + this == ManeuverType.turnSharpRight || + this == ManeuverType.turnSlightRight || + this == ManeuverType.keepRight; } /// A single turn-by-turn navigation maneuver step. diff --git a/lib/services/sensor_fusion_service.dart b/lib/services/sensor_fusion_service.dart index ca96ea0..c8246d2 100644 --- a/lib/services/sensor_fusion_service.dart +++ b/lib/services/sensor_fusion_service.dart @@ -47,6 +47,9 @@ class SensorFusionDiagnostics { final String dashboardDirection; final bool isCalibrated; final String mountOrientation; + final double eventRateHz; + final double longitudinalG; + final double lateralG; const SensorFusionDiagnostics({ required this.rawAccelX, @@ -64,6 +67,9 @@ class SensorFusionDiagnostics { required this.dashboardDirection, required this.isCalibrated, required this.mountOrientation, + this.eventRateHz = 50.0, + this.longitudinalG = 0.0, + this.lateralG = 0.0, }); Map toMap() => { @@ -78,6 +84,9 @@ class SensorFusionDiagnostics { 'dashboardDirection': dashboardDirection, 'isCalibrated': isCalibrated, 'mountOrientation': mountOrientation, + 'eventRateHz': eventRateHz, + 'longitudinalG': longitudinalG, + 'lateralG': lateralG, }; } @@ -135,6 +144,7 @@ class SensorFusionService with WidgetsBindingObserver { bool _isCalibrated = false; bool get isCalibrated => _isCalibrated; int _remountAnomalyCount = 0; + int _remountReturnCount = 0; // Stored baseline gravity vector at calibration for remount detection double _calGravityX = 0.0; @@ -149,6 +159,9 @@ class SensorFusionService with WidgetsBindingObserver { double _latestRawGyroY = 0.0; double _latestRawGyroZ = 0.0; double _latestRollRateDegPerSec = 0.0; + double _eventRateHz = 50.0; + int _sensorEventCounter = 0; + DateTime? _lastRateWindow; double get latestRawAccelX => _latestRawAccelX; double get latestRawAccelY => _latestRawAccelY; @@ -157,6 +170,7 @@ class SensorFusionService with WidgetsBindingObserver { double get latestRawGyroY => _latestRawGyroY; double get latestRawGyroZ => _latestRawGyroZ; double get latestRollRateDegPerSec => _latestRollRateDegPerSec; + double get eventRateHz => _eventRateHz; // Sensor fusion state double _currentRoll = 0.0; @@ -182,6 +196,12 @@ class SensorFusionService with WidgetsBindingObserver { MountOrientation _mountOrientation = MountOrientation.portraitUpright; MountOrientation get mountOrientation => _mountOrientation; + double _speedMph = 0.0; + double get speedMph => _speedMph; + void updateVehicleSpeed(double speedMph) { + _speedMph = speedMph; + } + static const double _gravityConst = 9.80665; // Low-pass filter for G-Force @@ -304,13 +324,29 @@ class SensorFusionService with WidgetsBindingObserver { _updateDisplayOrientation(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _updateDisplayOrientation(); + _ensureSensorsListening(); + if (!_isCalibrated) { + loadCalibrationForMotorcycle(null); + } + } + } + + void _ensureSensorsListening() { + if (_accelSub == null || _gyroSub == null) { + _startSensors(); + } + } + void _updateDisplayOrientation() { try { final view = WidgetsBinding.instance.platformDispatcher.views.firstOrNull; if (view != null && view.physicalSize.height > 0) { final size = view.physicalSize; - // Only adapt automatically if display dimensions are realistic mobile dimensions - if (size.width > size.height && size.width < 1500) { + if (size.width > size.height) { _isLandscape = true; _updateMountOrientation(); _recomputeBasisVectors(); @@ -345,7 +381,6 @@ class SensorFusionService with WidgetsBindingObserver { } void _checkRemountValidity(double ax, double ay, double az) { - if (!_isCalibrated) return; final calNorm = math.sqrt(_calGravityX * _calGravityX + _calGravityY * _calGravityY + _calGravityZ * _calGravityZ); final curNorm = math.sqrt(ax * ax + ay * ay + az * az); if (calNorm < 3.0 || curNorm < 3.0) return; @@ -355,35 +390,56 @@ class SensorFusionService with WidgetsBindingObserver { final ny = ay / curNorm; final nz = az / curNorm; - // Project current vector onto 3D Vehicle Coordinate Frame: - // vUp: component along calibrated upright axis - // vForward: component along motorcycle longitudinal/forward axis - final vUp = (nx * _upVector[0] + ny * _upVector[1] + nz * _upVector[2]).clamp(-1.0, 1.0); - final vForward = (nx * _forwardVector[0] + ny * _forwardVector[1] + nz * _forwardVector[2]).clamp(-1.0, 1.0); - - // Total 3D angular deviation from calibrated upright gravity + // Project current vector onto calibrated upright gravity axis + final vUp = (nx * (_calGravityX / calNorm) + ny * (_calGravityY / calNorm) + nz * (_calGravityZ / calNorm)).clamp(-1.0, 1.0); final totalAngleDiffDeg = math.acos(vUp) * (180.0 / math.pi); - // Pitch tilt (tilt out of motorcycle roll plane) + if (!_isCalibrated) { + // Automatic re-lock: if the device returns to the motorcycle mount (within 25° of calibrated gravity) + if (totalAngleDiffDeg < 25.0) { + _remountReturnCount++; + if (_remountReturnCount >= 15) { + _isCalibrated = true; + _remountAnomalyCount = 0; + _remountReturnCount = 0; + _recomputeBasisVectors(); + _emitDiagnostics(0.0); + } + } else { + _remountReturnCount = 0; + } + return; + } + + _remountReturnCount = 0; + + // Do not invalidate calibration while the vehicle is actively in motion or experiencing dynamic rotation + if (_speedMph > 2.0 || _latestRollRateDegPerSec.abs() > 3.0) { + _remountAnomalyCount = 0; + return; + } + + // Project current vector onto 3D Vehicle Coordinate Frame: + final vForward = (nx * _forwardVector[0] + ny * _forwardVector[1] + nz * _forwardVector[2]).clamp(-1.0, 1.0); final pitchTiltDeg = (math.asin(vForward)).abs() * (180.0 / math.pi); // Motorcycle Lean & Remount Physics: // 1. Roll-axis rotation (up to 65°) is normal motorcycle riding, cornering, and kickstand parking (20°-35°). - // Deviations within the roll plane with pitchTiltDeg <= 35° and totalAngleDiffDeg <= 68° - // must NEVER invalidate calibration! - // 2. True remount / phone removal: + // Deviations within the roll plane must NEVER invalidate calibration. + // 2. Cockpit mounts: Handlebar mounts (QuadLock, RAM) are commonly pitched backwards 35°-55° facing the rider. + // 3. True remount / phone removal: // - Placed flat on desk (screen up): totalAngleDiffDeg ~ 90°, pitchTiltDeg ~ 90° - // - Placed in pocket/bag: totalAngleDiffDeg >= 90° - // - Mount rotated or twisted: totalAngleDiffDeg > 68° or pitchTiltDeg > 38° - final isImmediateRemount = totalAngleDiffDeg >= 75.0 || pitchTiltDeg >= 42.0; - final isSuspectedRemount = totalAngleDiffDeg > 68.0 || pitchTiltDeg > 35.0; + // - Placed in pocket/bag: totalAngleDiffDeg >= 80° + // - Mount rotated or twisted when stationary: totalAngleDiffDeg >= 80° or pitchTiltDeg >= 65° + final isImmediateRemount = totalAngleDiffDeg >= 80.0 || pitchTiltDeg >= 65.0; + final isSuspectedRemount = totalAngleDiffDeg > 70.0 || pitchTiltDeg > 55.0; if (isImmediateRemount) { _invalidateRemount(totalAngleDiffDeg); } else if (isSuspectedRemount) { _remountAnomalyCount++; - // Require 5 consecutive stationary samples (100ms) to filter out transient bumps or noise - if (_remountAnomalyCount >= 5) { + // Require 50 consecutive stationary samples (1000ms) to filter out transient bumps, fork dive, or vibrations + if (_remountAnomalyCount >= 50) { _invalidateRemount(totalAngleDiffDeg); } } else { @@ -392,17 +448,14 @@ class SensorFusionService with WidgetsBindingObserver { } void _invalidateRemount(double angleDiffDeg) { - debugPrint('[SensorFusion] Remount detected: device orientation shifted ${angleDiffDeg.toStringAsFixed(1)}° from saved calibration. Invalidating calibration.'); + debugPrint('[SensorFusion] Remount detected: device orientation shifted ${angleDiffDeg.toStringAsFixed(1)}° from saved calibration. Invalidating runtime calibration.'); _calibrationVersion++; _isCalibrated = false; _remountAnomalyCount = 0; + _remountReturnCount = 0; _rollOffset = 0.0; _pitchOffset = 0.0; - _calGravityX = 0.0; - _calGravityY = 0.0; - _calGravityZ = 0.0; _recomputeBasisVectors(); - _calibrationService.clearCalibration(); _emitDiagnostics(0.0); } @@ -494,6 +547,19 @@ class SensorFusionService with WidgetsBindingObserver { } void handleAccelerometerEvent(AccelerometerEvent event) { + _sensorEventCounter++; + final now = DateTime.now(); + if (_lastRateWindow == null) { + _lastRateWindow = now; + } else { + final elapsedMs = now.difference(_lastRateWindow!).inMilliseconds; + if (elapsedMs >= 1000) { + _eventRateHz = (_sensorEventCounter * 1000.0) / elapsedMs; + _sensorEventCounter = 0; + _lastRateWindow = now; + } + } + _latestRawAccelX = event.x; _latestRawAccelY = event.y; _latestRawAccelZ = event.z; @@ -685,6 +751,9 @@ class SensorFusionService with WidgetsBindingObserver { dashboardDirection: direction, isCalibrated: _isCalibrated, mountOrientation: _mountOrientation.name, + eventRateHz: _eventRateHz, + longitudinalG: _filteredLongitudinalG, + lateralG: _filteredLateralG, ); _latestDiagnostics = diag; @@ -728,7 +797,13 @@ class SensorFusionService with WidgetsBindingObserver { _isCalibrated = isCal; _remountAnomalyCount = 0; + _remountReturnCount = 0; _recomputeBasisVectors(); + if (_isCalibrated) { + _leanAngleController.add(0.0); + _pitchAngleController.add(0.0); + _emitDiagnostics(0.0); + } } catch (_) { if (version != _calibrationVersion) return; _isCalibrated = false; @@ -746,6 +821,7 @@ class SensorFusionService with WidgetsBindingObserver { _calGravityZ = _latestRawAccelZ; _isCalibrated = true; _remountAnomalyCount = 0; + _remountReturnCount = 0; // Auto-detect mount orientation from gravity final ax = _calGravityX.abs(); @@ -793,6 +869,7 @@ class SensorFusionService with WidgetsBindingObserver { _calibrationVersion++; _isCalibrated = false; _remountAnomalyCount = 0; + _remountReturnCount = 0; _rollOffset = 0.0; _pitchOffset = 0.0; _calGravityX = 0.0; diff --git a/lib/services/social/rider_profile_models.dart b/lib/services/social/rider_profile_models.dart index 889f1bf..3e10fb9 100644 --- a/lib/services/social/rider_profile_models.dart +++ b/lib/services/social/rider_profile_models.dart @@ -1,4 +1,4 @@ -import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; /// Preset avatar options available for all riders without external image hosting. class PresetAvatar { @@ -6,12 +6,14 @@ class PresetAvatar { final String label; final int primaryColorHex; final int secondaryColorHex; + final IconData icon; const PresetAvatar({ required this.id, required this.label, required this.primaryColorHex, required this.secondaryColorHex, + this.icon = Icons.two_wheeler, }); static const List availablePresets = [ @@ -20,48 +22,56 @@ class PresetAvatar { label: 'Cyan Apex', primaryColorHex: 0xFF00E5FF, secondaryColorHex: 0xFF003840, + icon: Icons.sports_motorsports, ), PresetAvatar( id: 'helmet_amber', label: 'Amber Sector', primaryColorHex: 0xFFFFB300, secondaryColorHex: 0xFF4A3200, + icon: Icons.two_wheeler, ), PresetAvatar( id: 'tach_neon', label: 'Neon Tach', primaryColorHex: 0xFF00E676, secondaryColorHex: 0xFF00381B, + icon: Icons.speed, ), PresetAvatar( id: 'speed_violet', label: 'Violet Velocity', primaryColorHex: 0xFFD500F9, secondaryColorHex: 0xFF3E004A, + icon: Icons.electric_bolt, ), PresetAvatar( id: 'cruiser_red', label: 'Crimson Cruiser', primaryColorHex: 0xFFFF1744, secondaryColorHex: 0xFF4A0513, + icon: Icons.motorcycle, ), PresetAvatar( id: 'adv_blue', label: 'Adventure Azure', primaryColorHex: 0xFF2979FF, secondaryColorHex: 0xFF0A224A, + icon: Icons.terrain, ), PresetAvatar( id: 'cafe_gold', label: 'Cafe Gold', primaryColorHex: 0xFFFFD700, secondaryColorHex: 0xFF4A3E00, + icon: Icons.workspace_premium, ), PresetAvatar( id: 'touring_slate', label: 'Touring Slate', primaryColorHex: 0xFF90A4AE, secondaryColorHex: 0xFF263238, + icon: Icons.navigation, ), ]; diff --git a/lib/services/sync/cloud_restore_controller.dart b/lib/services/sync/cloud_restore_controller.dart index 45f1b9a..93a156f 100644 --- a/lib/services/sync/cloud_restore_controller.dart +++ b/lib/services/sync/cloud_restore_controller.dart @@ -138,12 +138,34 @@ class CloudRestoreController extends StateNotifier { : result.telemetryAlreadyPresent; parts.add('$alreadyPresent ride(s) already on this device'); } + final failureDetails = []; + if (result.telemetryFailed > 0) { + failureDetails.add( + '${result.telemetryFailed} ride(s) pending telemetry upload from source device', + ); + } + if (result.telemetryAuthFailures > 0) { + failureDetails.add('${result.telemetryAuthFailures} ride(s) require re-authentication'); + } + if (result.metadata.conflicts > 0) { + failureDetails.add('${result.metadata.conflicts} conflict(s)'); + } + if (result.metadata.skipped > 0) { + failureDetails.add('${result.metadata.skipped} skipped'); + } + if (result.failures > 0) { - parts.add('${result.failures} item(s) need attention'); + final explanation = failureDetails.isNotEmpty + ? failureDetails.join('; ') + : '${result.failures} item(s) need attention'; + parts.add('Unresolved: $explanation'); } if (parts.isEmpty) { return 'No restorable MotorCloud rides were found for this account.'; } - return 'MotorCloud restore complete: ${parts.join(', ')}.'; + final prefix = result.failures > 0 + ? 'MotorCloud restore partially completed' + : 'MotorCloud restore complete'; + return '$prefix: ${parts.join(', ')}.'; } } diff --git a/lib/services/telemetry_service.dart b/lib/services/telemetry_service.dart index 132deb2..ab21a89 100644 --- a/lib/services/telemetry_service.dart +++ b/lib/services/telemetry_service.dart @@ -90,6 +90,9 @@ class TelemetryService { }; _locationSub = _locationService.getFilteredPositionStream().listen((pos) { + final speedMph = pos.speed * 2.23694; + _sensorFusionService.updateVehicleSpeed(speedMph); + if (_latestPosition != null) { final dt = pos.timestamp.difference(_latestPosition!.timestamp).inMicroseconds.abs() / 1000000.0; if (dt >= 0.05 && dt <= 3.0) { @@ -98,7 +101,6 @@ class TelemetryService { final accelMps2 = (currentSpeedMps - prevSpeedMps) / dt; _gpsLongitudinalG = (accelMps2 / 9.80665).clamp(-1.5, 1.5); - final speedMph = pos.speed * 2.23694; if (speedMph >= 2.5 && pos.heading >= 0 && _latestPosition!.heading >= 0) { var dHeading = pos.heading - _latestPosition!.heading; while (dHeading > 180) { diff --git a/test/aws_location_route_provider_test.dart b/test/aws_location_route_provider_test.dart index 14868a1..a06996a 100644 --- a/test/aws_location_route_provider_test.dart +++ b/test/aws_location_route_provider_test.dart @@ -31,6 +31,7 @@ void main() { expect(request.url.queryParameters['key'], equals('test-key')); final body = jsonDecode(request.body); expect(body['QueryText'], equals('Atlanta')); + expect(body['Filter']?['IncludeCountries'], equals(['USA'])); return http.Response( jsonEncode({ 'ResultItems': [ @@ -44,6 +45,8 @@ void main() { 200, ); } else if (request.url.path.contains('/v2/search-text')) { + final body = jsonDecode(request.body); + expect(body['Filter']?['IncludeCountries'], equals(['USA'])); return http.Response( jsonEncode({ 'ResultItems': [ @@ -423,5 +426,392 @@ void main() { ), ); }); + + test('calculateRoute parses real AWS Routes V2 TurnStepDetails and ControlledAccessHighways', () async { + final mockClient = MockClient((request) async { + final body = jsonDecode(request.body); + expect(body['Avoid']['ControlledAccessHighways'], isTrue); + expect(body['TravelModeOptions']['Car']['Avoid']['Highways'], isTrue); + + return http.Response( + jsonEncode({ + 'Routes': [ + { + 'Summary': {'Distance': 8500.0, 'Duration': 540}, + 'Legs': [ + { + 'Geometry': { + 'LineString': [ + [-84.0, 34.0], + [-84.02, 34.02], + [-84.05, 34.05], + [-84.08, 34.08], + ], + }, + 'VehicleLegDetails': { + 'TravelSteps': [ + { + 'Type': 'Depart', + 'Distance': 100.0, + 'Duration': 15, + 'GeometryOffset': 0, + 'CurrentRoad': 'Main St', + }, + { + 'Type': 'Turn', + 'Distance': 2400.0, + 'Duration': 180, + 'GeometryOffset': 1, + 'NextRoad': 'Canyon Blvd', + 'TurnStepDetails': { + 'SteeringDirection': 'Right', + 'TurnIntensity': 'Typical', + }, + }, + { + 'Type': 'Ramp', + 'Distance': 3000.0, + 'Duration': 200, + 'GeometryOffset': 2, + 'NextRoad': 'Mountain Pkwy', + 'RampStepDetails': { + 'SteeringDirection': 'Left', + }, + }, + { + 'Type': 'Arrive', + 'Distance': 0.0, + 'Duration': 0, + 'GeometryOffset': 3, + }, + ], + }, + }, + ], + }, + ], + }), + 200, + ); + }); + + final provider = AwsLocationRouteProvider( + apiKey: 'test-key', + client: mockClient, + ); + + final stops = [ + const RouteStop(id: '1', displayName: 'Start', latitude: 34.0, longitude: -84.0), + const RouteStop(id: '2', displayName: 'End', latitude: 34.08, longitude: -84.08), + ]; + + final result = await provider.calculateRoute( + stops, + options: const RoutingOptions( + avoidHighways: true, + ), + ); + + expect(result.maneuvers.length, equals(4)); + expect(result.maneuvers[0].type, equals(ManeuverType.depart)); + expect(result.maneuvers[0].instruction, equals('Head out onto Main St')); + expect(result.maneuvers[1].type, equals(ManeuverType.turnRight)); + expect(result.maneuvers[1].instruction, equals('Turn right onto Canyon Blvd')); + expect(result.maneuvers[2].type, equals(ManeuverType.ramp)); + expect(result.maneuvers[2].instruction, equals('Take the ramp on the left onto Mountain Pkwy')); + expect(result.maneuvers[3].type, equals(ManeuverType.arrive)); + expect(result.maneuvers[3].instruction, equals('Arrive at destination')); + }); + + test('calculateRoute canonicalizes conflicting maneuver instructions and types', () async { + final mockClient = MockClient((request) async { + return http.Response( + jsonEncode({ + 'Routes': [ + { + 'Distance': 5000.0, + 'Duration': 300, + 'Geometry': { + 'LineString': [ + [-84.00, 34.00], + [-84.00, 34.01], + [-84.02, 34.01], // Turns left + ], + }, + 'Legs': [ + { + 'VehicleLegDetails': { + 'TravelSteps': [ + { + 'Type': 'Depart', + 'Instruction': 'Head out', + 'NextRoad': 'Main St', + 'Distance': 1000.0, + 'Duration': 60, + 'GeometryOffset': 0, + }, + { + // Raw instruction says left, but Type is generic Continue and no turn detail + 'Type': 'Continue', + 'Instruction': 'Turn left onto Peachtree St', + 'NextRoad': 'Peachtree St', + 'Distance': 2000.0, + 'Duration': 120, + 'GeometryOffset': 1, + }, + { + 'Type': 'Arrive', + 'Instruction': 'Arrive at destination', + 'Distance': 0.0, + 'Duration': 0, + 'GeometryOffset': 2, + }, + ], + }, + }, + ], + }, + ], + }), + 200, + ); + }); + + final provider = AwsLocationRouteProvider( + apiKey: 'test-key', + client: mockClient, + ); + + final stops = [ + const RouteStop(id: '1', displayName: 'Start', latitude: 34.0, longitude: -84.0), + const RouteStop(id: '2', displayName: 'End', latitude: 34.01, longitude: -84.02), + ]; + + final result = await provider.calculateRoute(stops); + + expect(result.maneuvers.length, equals(3)); + // Step 1 MUST be canonicalized to turnLeft matching its text instruction and left geometry + expect(result.maneuvers[1].type, equals(ManeuverType.turnLeft)); + expect(result.maneuvers[1].instruction, equals('Turn left onto Peachtree St')); + }); + + test('calculateRoute handles bearing wraparound across North 0/360 degrees', () async { + final mockClient = MockClient((request) async { + return http.Response( + jsonEncode({ + 'Routes': [ + { + 'Distance': 3000.0, + 'Duration': 180, + 'Geometry': { + 'LineString': [ + [-84.00000, 34.00000], // Start + [-84.00176, 34.01000], // Heading ~350 degrees NNW + [-83.99824, 34.02000], // Turns to heading ~20 degrees NNE (delta = +30 deg right) + ], + }, + 'Legs': [ + { + 'VehicleLegDetails': { + 'TravelSteps': [ + { + 'Type': 'Depart', + 'Instruction': 'Head out', + 'Distance': 1000.0, + 'Duration': 60, + 'GeometryOffset': 0, + }, + { + 'Type': 'Turn', + 'Instruction': 'Turn onto Mountain Pass Rd', + 'NextRoad': 'Mountain Pass Rd', + 'Distance': 1000.0, + 'Duration': 60, + 'GeometryOffset': 1, + }, + { + 'Type': 'Arrive', + 'Instruction': 'Arrive at summit', + 'Distance': 0.0, + 'Duration': 0, + 'GeometryOffset': 2, + }, + ], + }, + }, + ], + }, + ], + }), + 200, + ); + }); + + final provider = AwsLocationRouteProvider( + apiKey: 'test-key', + client: mockClient, + ); + + final stops = [ + const RouteStop(id: '1', displayName: 'Start', latitude: 34.0, longitude: -84.0), + const RouteStop(id: '2', displayName: 'Summit', latitude: 34.02, longitude: -83.99824), + ]; + + final result = await provider.calculateRoute(stops); + + expect(result.maneuvers.length, equals(3)); + // Wrapped delta from 350 deg to 20 deg is +30 deg, which resolves to slight right turn + expect(result.maneuvers[1].type.isRight, isTrue); + expect(result.maneuvers[1].instruction.toLowerCase(), contains('right')); + }); + + test('calculateRoute resolves provider text vs geometry mismatch using geometry truth', () async { + final mockClient = MockClient((request) async { + return http.Response( + jsonEncode({ + 'Routes': [ + { + 'Distance': 4000.0, + 'Duration': 240, + 'Geometry': { + 'LineString': [ + [-84.00, 34.00], + [-84.00, 34.01], // Heading 0 degrees North + [-84.02, 34.01], // Heading 270 degrees West (delta = -90 deg Left turn) + ], + }, + 'Legs': [ + { + 'VehicleLegDetails': { + 'TravelSteps': [ + { + 'Type': 'Depart', + 'Instruction': 'Head out', + 'Distance': 1000.0, + 'Duration': 60, + 'GeometryOffset': 0, + }, + { + // Bug in raw provider: says Turn right, but geometry is 90 degree left turn! + 'Type': 'Turn', + 'TurnDetail': {'SteeringDirection': 'Right'}, + 'Instruction': 'Turn right onto West Pkwy', + 'NextRoad': 'West Pkwy', + 'Distance': 2000.0, + 'Duration': 120, + 'GeometryOffset': 1, + }, + { + 'Type': 'Arrive', + 'Instruction': 'Arrive at destination', + 'Distance': 0.0, + 'Duration': 0, + 'GeometryOffset': 2, + }, + ], + }, + }, + ], + }, + ], + }), + 200, + ); + }); + + final provider = AwsLocationRouteProvider( + apiKey: 'test-key', + client: mockClient, + ); + + final stops = [ + const RouteStop(id: '1', displayName: 'Start', latitude: 34.0, longitude: -84.0), + const RouteStop(id: '2', displayName: 'End', latitude: 34.01, longitude: -84.02), + ]; + + final result = await provider.calculateRoute(stops); + + expect(result.maneuvers.length, equals(3)); + // Step 1 MUST be corrected to turnLeft because geometry bearing delta is -90 degrees + expect(result.maneuvers[1].type, equals(ManeuverType.turnLeft)); + expect(result.maneuvers[1].instruction, equals('Turn left onto West Pkwy')); + expect(result.maneuvers[1].type.isLeft, isTrue); + expect(result.maneuvers[1].type.isRight, isFalse); + }); + + test('calculateRoute preserves continueStraight for pure road bends without generating false turn announcements', () async { + final mockClient = MockClient((request) async { + return http.Response( + jsonEncode({ + 'Routes': [ + { + 'Distance': 6000.0, + 'Duration': 360, + 'Geometry': { + 'LineString': [ + [-84.00, 34.00], + [-84.00, 34.01], + [-84.02, 34.01], // Curving road bend + [-84.02, 34.02], + ], + }, + 'Legs': [ + { + 'VehicleLegDetails': { + 'TravelSteps': [ + { + 'Type': 'Depart', + 'Instruction': 'Head out onto US-129', + 'NextRoad': 'US-129', + 'Distance': 1000.0, + 'Duration': 60, + 'GeometryOffset': 0, + }, + { + // Pure road bend on US-129 - Must NOT be announced as a turn! + 'Type': 'Continue', + 'Instruction': 'Continue onto US-129', + 'NextRoad': 'US-129', + 'Distance': 4000.0, + 'Duration': 240, + 'GeometryOffset': 1, + }, + { + 'Type': 'Arrive', + 'Instruction': 'Arrive at Deals Gap', + 'Distance': 0.0, + 'Duration': 0, + 'GeometryOffset': 3, + }, + ], + }, + }, + ], + }, + ], + }), + 200, + ); + }); + + final provider = AwsLocationRouteProvider( + apiKey: 'test-key', + client: mockClient, + ); + + final stops = [ + const RouteStop(id: '1', displayName: 'Start', latitude: 34.0, longitude: -84.0), + const RouteStop(id: '2', displayName: 'Deals Gap', latitude: 34.02, longitude: -84.02), + ]; + + final result = await provider.calculateRoute(stops); + + expect(result.maneuvers.length, equals(3)); + // Step 1 MUST remain continueStraight, NOT turn left or right + expect(result.maneuvers[1].type, equals(ManeuverType.continueStraight)); + expect(result.maneuvers[1].instruction, equals('Continue onto US-129')); + expect(result.maneuvers[1].type.isLeft, isFalse); + expect(result.maneuvers[1].type.isRight, isFalse); + }); }); } diff --git a/test/canonical_dashboard_responsive_test.dart b/test/canonical_dashboard_responsive_test.dart index 23c731f..0ac1700 100644 --- a/test/canonical_dashboard_responsive_test.dart +++ b/test/canonical_dashboard_responsive_test.dart @@ -324,7 +324,7 @@ void main() { await tester.pump(const Duration(milliseconds: 50)); expect(find.text('HAZARD'), findsOneWidget); - expect(find.text('250m Ahead • Skyline Blvd'), findsOneWidget); + expect(find.text('0.2 mi Ahead • Skyline Blvd'), findsOneWidget); expect(find.byIcon(Icons.close), findsOneWidget); }); diff --git a/test/canonical_whole_app_regression_matrix_test.dart b/test/canonical_whole_app_regression_matrix_test.dart index cc13701..f1a994f 100644 --- a/test/canonical_whole_app_regression_matrix_test.dart +++ b/test/canonical_whole_app_regression_matrix_test.dart @@ -407,6 +407,7 @@ class _FakeMapProvider implements AppMapProvider { required BuildContext context, required AppLatLng center, required double zoom, + double bearing = 0.0, required List markers, required List polylines, required bool isFollowMode, diff --git a/test/community_social_feed_test.dart b/test/community_social_feed_test.dart index aefaeec..323c6f7 100644 --- a/test/community_social_feed_test.dart +++ b/test/community_social_feed_test.dart @@ -207,7 +207,7 @@ void main() { expect(find.text('Community Feed'), findsOneWidget); expect(find.text('SQUAD & CANYON FEED'), findsOneWidget); - expect(find.text('24 ACTIVE'), findsOneWidget); + expect(find.byKey(const ValueKey('feed_search_toggle_button')), findsOneWidget); expect(find.text('All Activity'), findsOneWidget); expect(find.text('Club Only'), findsOneWidget); expect(find.text('Nearby Packs'), findsOneWidget); diff --git a/test/destination_autocomplete_remediation_test.dart b/test/destination_autocomplete_remediation_test.dart new file mode 100644 index 0000000..9d6e83c --- /dev/null +++ b/test/destination_autocomplete_remediation_test.dart @@ -0,0 +1,285 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:motorcycle_dash/core/theme/theme_extensions.dart'; +import 'package:motorcycle_dash/features/map/map_screen.dart'; +import 'package:motorcycle_dash/features/dashboard/widgets/dashboard_alert_region.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_provider_interface.dart'; +import 'package:motorcycle_dash/services/routes/route_models.dart'; +import 'package:motorcycle_dash/services/routes/route_provider.dart'; +import 'package:motorcycle_dash/services/routes/route_providers.dart'; + +class _FakeRouteProvider implements RouteProvider { + final RoutingPreference preference; + final bool avoidHighways; + + _FakeRouteProvider({ + this.preference = RoutingPreference.fastest, + this.avoidHighways = false, + }); + + @override + String get name => 'Fake Provider'; + + @override + Future> searchPlaces( + String query, { + AppLatLng? nearLocation, + }) async { + return const [ + PlaceSearchResult( + providerId: 'blood_mtn', + primaryText: 'Blood Mountain Gap', + secondaryText: 'US-19, Blairsville, GA', + latitude: 34.7330, + longitude: -83.9180, + ), + ]; + } + + @override + Future resolvePlace(String placeId) async { + return const ResolvedPlace( + providerId: 'blood_mtn', + displayName: 'Blood Mountain Gap', + formattedAddress: 'Blood Mountain Gap, GA', + latitude: 34.7330, + longitude: -83.9180, + ); + } + + @override + Future calculateRoute( + List stops, { + bool avoidHighways = false, + bool avoidTolls = false, + bool avoidFerries = false, + RoutingOptions? options, + }) async { + return RouteCalculationResult( + straightLineDistanceMeters: 10000.0, + estimatedDistanceMeters: 12000.0, + estimatedDurationSeconds: 900, + geometry: [ + AppLatLng(stops.first.latitude, stops.first.longitude), + AppLatLng(stops.last.latitude, stops.last.longitude), + ], + isStraightLine: false, + providerName: name, + options: options ?? RoutingOptions( + preference: preference, + avoidHighways: avoidHighways, + ), + curvatureMetric: 85.0, // Twisty curves + ); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + Widget buildSubject({ + ThemeMode themeMode = ThemeMode.light, + RoutingPreference preference = RoutingPreference.fastest, + bool avoidHighways = false, + }) { + return ProviderScope( + overrides: [ + activeRouteCalculationProvider.overrideWithValue( + _FakeRouteProvider( + preference: preference, + avoidHighways: avoidHighways, + ), + ), + locationStreamProvider.overrideWith( + (ref) => Stream.value( + Position( + latitude: 34.7200, + longitude: -83.9100, + timestamp: DateTime.now(), + accuracy: 5.0, + altitude: 300.0, + altitudeAccuracy: 1.0, + heading: 45.0, + headingAccuracy: 1.0, + speed: 15.0, + speedAccuracy: 0.5, + ), + ), + ), + ], + child: MaterialApp( + themeMode: themeMode, + theme: ThemeData.light().copyWith( + extensions: const [MotorSignalThemeColors.light], + ), + darkTheme: ThemeData.dark().copyWith( + extensions: const [MotorSignalThemeColors.dark], + ), + home: const MapScreen(), + ), + ); + } + + group('Destination Autocomplete & Map Screen Remediations', () { + testWidgets('Search results overlay uses high contrast theme tokens in light mode', (tester) async { + await tester.binding.setSurfaceSize(const Size(412, 915)); + await tester.pumpWidget(buildSubject(themeMode: ThemeMode.light)); + await tester.pumpAndSettle(); + + final searchField = find.byType(TextField); + await tester.enterText(searchField, 'Blood Mountain'); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pumpAndSettle(); + + final overlayCard = tester.widget(find.byKey(const ValueKey('search_results_overlay'))); + expect(overlayCard.color, isNotNull); + + // Verify title text is rendered and readable + final titleText = tester.widget(find.text('Blood Mountain Gap')); + expect(titleText.style?.color, isNotNull); + // In light mode, text color should not be hardcoded white + expect(titleText.style?.color != Colors.white, isTrue); + + final subtitleText = tester.widget(find.text('US-19, Blairsville, GA')); + expect(subtitleText.style?.color != Colors.white70, isTrue); + + // Report button is hidden while search results are showing + expect(find.byKey(const ValueKey('quick_swarm_report_button')), findsNothing); + }); + + testWidgets('Route preview displays confirmed mode badge, labeled curves, and repositioned report FAB', (tester) async { + await tester.binding.setSurfaceSize(const Size(412, 915)); + await tester.pumpWidget(buildSubject( + themeMode: ThemeMode.dark, + preference: RoutingPreference.fastest, + avoidHighways: true, + )); + await tester.pumpAndSettle(); + + // Enter search and select destination + final searchField = find.byType(TextField); + await tester.enterText(searchField, 'Blood Mountain'); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Blood Mountain Gap')); + await tester.pumpAndSettle(); + + // Verify preview card rendered + expect(find.byKey(const ValueKey('route_preview_card')), findsOneWidget); + + // Confirmed mode badge displays FASTEST + expect(find.text('FASTEST'), findsOneWidget); + + // Twistiness is clearly labeled as CURVES: Twisty Curves + expect(find.text('CURVES: Twisty Curves'), findsOneWidget); + + // Scroll preference chips row horizontally to reveal Avoid Highways chip + final chipFinder = find.byKey(const ValueKey('preference_avoid_highways')); + await tester.scrollUntilVisible( + chipFinder, + 150.0, + scrollable: find.byType(Scrollable).last, + ); + await tester.pumpAndSettle(); + await tester.tap(chipFinder); + await tester.pumpAndSettle(); + + // Avoid highways badge is displayed when enabled + expect(find.text('NO HIGHWAYS'), findsOneWidget); + + // Report FAB is visible and repositioned safely above route preview card (bottom: 295) + final reportFabPositioned = tester.widget( + find.ancestor( + of: find.byKey(const ValueKey('quick_swarm_report_button')), + matching: find.byType(Positioned), + ).first, + ); + expect(reportFabPositioned.bottom, 295.0); + }); + + testWidgets('Dashboard alert distance formatting avoids 1 ft Ahead and uses Nearby for close alerts', (tester) async { + final closeAlert = RoadAlert( + id: 'alert_close', + providerId: 'swarm_alerts', + category: RoadAlertCategory.hazard, + latitude: 34.7200, + longitude: -83.9100, + description: 'Road gravel', + reportedAt: DateTime.now(), + lastUpdated: DateTime.now(), + source: 'swarm', + distanceFromUser: 5.0, // 5 meters + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.light().copyWith( + extensions: const [MotorSignalThemeColors.light], + ), + home: Scaffold( + body: ProviderScope( + overrides: [ + locationPermissionGrantedProvider.overrideWith((ref) => true), + activeAlertsStreamProvider.overrideWith((ref) => Stream.value([closeAlert])), + ], + child: const DashboardAlertRegion(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Close alert formats as "Nearby" + expect(find.textContaining('Nearby'), findsOneWidget); + expect(find.textContaining('1 ft Ahead'), findsNothing); + }); + + testWidgets('Dashboard alert distance formatting rounds mid-distance to clean 10ft units', (tester) async { + final midAlert = RoadAlert( + id: 'alert_mid', + providerId: 'swarm_alerts', + category: RoadAlertCategory.police, + latitude: 34.7200, + longitude: -83.9100, + description: 'Speed trap', + reportedAt: DateTime.now(), + lastUpdated: DateTime.now(), + source: 'swarm', + distanceFromUser: 45.0, // 45 meters (~150 ft) + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.light().copyWith( + extensions: const [MotorSignalThemeColors.light], + ), + home: Scaffold( + body: ProviderScope( + overrides: [ + locationPermissionGrantedProvider.overrideWith((ref) => true), + activeAlertsStreamProvider.overrideWith((ref) => Stream.value([midAlert])), + ], + child: const DashboardAlertRegion(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Mid alert formats in clean rounded feet increments (e.g. 150 ft Ahead) + expect(find.textContaining('150 ft Ahead'), findsOneWidget); + expect(find.textContaining('1 ft Ahead'), findsNothing); + }); + }); +} diff --git a/test/firebase_options_qa_guard_test.dart b/test/firebase_options_qa_guard_test.dart index a41c348..d01111d 100644 --- a/test/firebase_options_qa_guard_test.dart +++ b/test/firebase_options_qa_guard_test.dart @@ -13,6 +13,13 @@ void main() { expect(DefaultFirebaseOptions.ios.projectId, equals('motoapp-d22e0')); }); + test('DefaultFirebaseOptions exposes authoritative webClientId for Google Sign-In', () { + expect( + DefaultFirebaseOptions.webClientId, + equals('809431485362-ar11dsgjrq7327d122b55352gs6q6sml.apps.googleusercontent.com'), + ); + }); + test('androidQa options strictly target demo-motorsignal-qa and non-prod credentials', () { expect(DefaultFirebaseOptions.androidQa.projectId, equals('demo-motorsignal-qa')); expect(DefaultFirebaseOptions.androidQa.apiKey, equals('demo-key')); diff --git a/test/group_ride_service_test.dart b/test/group_ride_service_test.dart index 09103e7..405875a 100644 --- a/test/group_ride_service_test.dart +++ b/test/group_ride_service_test.dart @@ -188,7 +188,7 @@ void main() { ); rideStreamController.add(rideDoc); - await Future.delayed(const Duration(milliseconds: 20)); + await Future.delayed(const Duration(milliseconds: 50)); fakeService.getParticipantController('user_accepted').add( GroupRideParticipant( @@ -224,7 +224,7 @@ void main() { ), ); - await Future.delayed(const Duration(milliseconds: 20)); + await Future.delayed(const Duration(milliseconds: 50)); final presenceAlice = LiveGroupPresence( userId: 'user_accepted', @@ -238,7 +238,7 @@ void main() { ); fakeService.getPresenceController('user_accepted').add(presenceAlice); - await Future.delayed(const Duration(milliseconds: 20)); + await Future.delayed(const Duration(milliseconds: 50)); expect(states.isNotEmpty, isTrue); expect(states.last.any((p) => p.userId == 'user_accepted'), isTrue); @@ -282,7 +282,7 @@ void main() { ); rideStreamController.add(rideDoc); - await Future.delayed(const Duration(milliseconds: 20)); + await Future.delayed(const Duration(milliseconds: 50)); fakeService.getParticipantController('user_alice').add( GroupRideParticipant( @@ -307,7 +307,7 @@ void main() { ), ); - await Future.delayed(const Duration(milliseconds: 20)); + await Future.delayed(const Duration(milliseconds: 50)); final presenceAlice = LiveGroupPresence( userId: 'user_alice', @@ -322,7 +322,7 @@ void main() { fakeService.getPresenceController('user_alice').add(presenceAlice); fakeService.getPresenceController('user_bob').addError('permission-denied'); - await Future.delayed(const Duration(milliseconds: 20)); + await Future.delayed(const Duration(milliseconds: 50)); expect(states.isNotEmpty, isTrue); expect(states.last.length, 1); @@ -365,7 +365,7 @@ void main() { ); rideStreamController.add(rideDoc); - await Future.delayed(const Duration(milliseconds: 20)); + await Future.delayed(const Duration(milliseconds: 50)); fakeService.getParticipantController('user_alice').add( GroupRideParticipant( @@ -390,7 +390,7 @@ void main() { ), ); - await Future.delayed(const Duration(milliseconds: 20)); + await Future.delayed(const Duration(milliseconds: 50)); expect(states.isNotEmpty, isTrue); expect(states.last.length, 2); diff --git a/test/hybrid_v1_phase1_test.dart b/test/hybrid_v1_phase1_test.dart index 482f44e..1b1bed2 100644 --- a/test/hybrid_v1_phase1_test.dart +++ b/test/hybrid_v1_phase1_test.dart @@ -593,11 +593,14 @@ void main() { expect(find.byKey(const ValueKey('hybrid_start_ride_button')), findsNothing); final pauseFinder = find.byKey(const ValueKey('hybrid_pause_resume_ride_button')); + final hazardFinder = find.byKey(const ValueKey('hybrid_report_hazard_button')); final finishFinder = find.byKey(const ValueKey('hybrid_finish_ride_button')); expect(pauseFinder, findsOneWidget); + expect(hazardFinder, findsOneWidget); expect(finishFinder, findsOneWidget); expect(find.text('Pause Ride'), findsOneWidget); + expect(find.text('Hazard'), findsOneWidget); expect(find.text('Finish Ride'), findsOneWidget); // Tap Pause @@ -605,12 +608,49 @@ void main() { await tester.pumpAndSettle(); expect(fakeMachine.pauseRideCalled, isTrue); - // Tap Finish + // Tap Finish & Confirm Dialog await tester.tap(finishFinder); await tester.pumpAndSettle(); + if (find.text('End Ride').evaluate().isNotEmpty) { + await tester.tap(find.text('End Ride')); + await tester.pumpAndSettle(); + } expect(fakeMachine.finishRideCalled, isTrue); }); + testWidgets('cancelling End Ride confirmation dialog preserves active ride session', (tester) async { + final fakeMachine = _FakeRideStateMachine( + RideSession(state: RideState.riding, startTime: DateTime.now()), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + rideStateMachineProvider.overrideWith((ref) => fakeMachine), + ], + child: MaterialApp( + theme: AppTheme.darkTheme, + home: const Scaffold( + body: HybridRidingActionBar(), + ), + ), + ), + ); + + final finishFinder = find.byKey(const ValueKey('hybrid_finish_ride_button')); + await tester.tap(finishFinder); + await tester.pumpAndSettle(); + + expect(find.text('End Ride?'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(fakeMachine.finishRideCalled, isFalse); + expect(find.text('End Ride?'), findsNothing); + }); + testWidgets('renders Resume button when paused and invokes resumeRide', (tester) async { final fakeMachine = _FakeRideStateMachine( RideSession(state: RideState.paused, startTime: DateTime.now()), diff --git a/test/navigation_session_service_test.dart b/test/navigation_session_service_test.dart index b32d1b2..236231f 100644 --- a/test/navigation_session_service_test.dart +++ b/test/navigation_session_service_test.dart @@ -188,7 +188,12 @@ void main() { await notifier.startNavigation(calculationResult: calcResult); - // Rider reaches within 10 meters of maneuver 0 point along the route + // Stationary rider at departure does NOT prematurely advance to step 1 + notifier.updateLocation(const AppLatLng(33.7500, -84.3880), speedMph: 0.0); + expect(notifier.state.currentManeuverIndex, equals(0)); + expect(notifier.state.currentManeuver?.instruction, equals('Head north on Main')); + + // Rider departs and is moving (> 3.0 mph), advancing to step 1 notifier.updateLocation(const AppLatLng(33.7500, -84.3880), speedMph: 25.0); expect(notifier.state.currentManeuverIndex, equals(1)); expect(notifier.state.currentManeuver?.instruction, equals('Turn left on Elm')); @@ -280,5 +285,88 @@ void main() { notifier.toggleTts(); expect(notifier.state.ttsEnabled, isTrue); }); + + test('remaining distance strictly counts down and maneuvers advance as rider travels', () async { + final notifier = NavigationSessionNotifier(); + // Polyline with 5 points, ~1000m total + const p0 = AppLatLng(33.7500, -84.3880); + const p1 = AppLatLng(33.7525, -84.3880); // ~278m north + const p2 = AppLatLng(33.7550, -84.3880); // ~278m north (turn right) + const p3 = AppLatLng(33.7550, -84.3850); // ~278m east + const p4 = AppLatLng(33.7550, -84.3820); // ~278m east (destination) + + final maneuvers = [ + const RouteManeuver( + index: 0, + instruction: 'Depart north on Main St', + type: ManeuverType.depart, + distanceMeters: 556.0, + durationSeconds: 60, + location: p0, + geometryOffset: 0, + ), + const RouteManeuver( + index: 1, + instruction: 'Turn right on Oak St', + type: ManeuverType.turnRight, + distanceMeters: 556.0, + durationSeconds: 60, + location: p2, + geometryOffset: 2, + ), + const RouteManeuver( + index: 2, + instruction: 'Arrive at destination', + type: ManeuverType.arrive, + distanceMeters: 0.0, + durationSeconds: 0, + location: p4, + geometryOffset: 4, + ), + ]; + + final calcResult = RouteCalculationResult( + straightLineDistanceMeters: 1000.0, + estimatedDistanceMeters: 1112.0, + estimatedDurationSeconds: 120, + geometry: const [p0, p1, p2, p3, p4], + isStraightLine: false, + providerName: 'AWS Location', + maneuvers: maneuvers, + ); + + await notifier.startNavigation(calculationResult: calcResult); + final initialRemaining = notifier.state.remainingDistanceMeters; + expect(initialRemaining, greaterThan(1000.0)); + + // Rider moves to p1 (halfway to turn) + notifier.updateLocation(p1, speedMph: 30.0); + final remainingAtP1 = notifier.state.remainingDistanceMeters; + // Remaining distance MUST decrease (never count up!) + expect(remainingAtP1, lessThan(initialRemaining)); + expect(notifier.state.currentManeuverIndex, equals(1)); + expect(notifier.state.currentManeuver?.instruction, equals('Turn right on Oak St')); + final distToTurnP1 = notifier.state.distanceToNextManeuverMeters; + expect(distToTurnP1, greaterThan(200.0)); + expect(distToTurnP1, lessThan(350.0)); + + // Rider approaches turn at p2 (within 10m of p2) + notifier.updateLocation(p2, speedMph: 20.0); + final remainingAtP2 = notifier.state.remainingDistanceMeters; + expect(remainingAtP2, lessThan(remainingAtP1)); + // Maneuver advances to arrival! + expect(notifier.state.currentManeuverIndex, equals(2)); + expect(notifier.state.currentManeuver?.instruction, equals('Arrive at destination')); + + // Rider continues to p3 + notifier.updateLocation(p3, speedMph: 25.0); + final remainingAtP3 = notifier.state.remainingDistanceMeters; + expect(remainingAtP3, lessThan(remainingAtP2)); + + // Rider arrives at destination p4 + notifier.updateLocation(p4, speedMph: 5.0); + expect(notifier.state.status, equals(NavigationStatus.arrived)); + expect(notifier.state.remainingDistanceMeters, equals(0.0)); + }); }); } diff --git a/test/rid2_stop_button_error_containment_test.dart b/test/rid2_stop_button_error_containment_test.dart index 4504f6c..a776c6d 100644 --- a/test/rid2_stop_button_error_containment_test.dart +++ b/test/rid2_stop_button_error_containment_test.dart @@ -101,10 +101,14 @@ void main() { await tester.tap(find.text('Stop Session')); await tester.pumpAndSettle(); + if (find.text('End Ride').evaluate().isNotEmpty) { + await tester.tap(find.text('End Ride')); + await tester.pumpAndSettle(); + } + // Had the rejected Future been left unhandled, flutter_test would have // surfaced it as an unhandled async error and failed this test. expect(fake.finishCalls, 1); - final rideStopLines = logLines.where((l) => l.contains('[RideStop]')).toList(); expect(rideStopLines, hasLength(1), @@ -136,6 +140,11 @@ void main() { await tester.tap(find.text('Stop Session')); await tester.pumpAndSettle(); + if (find.text('End Ride').evaluate().isNotEmpty) { + await tester.tap(find.text('End Ride')); + await tester.pumpAndSettle(); + } + expect(fake.finishCalls, 1); expect(logLines.where((l) => l.contains('[RideStop]')), isEmpty, reason: 'no diagnostic on a successful finish'); diff --git a/test/services/sensor_fusion_lean_angle_regression_test.dart b/test/services/sensor_fusion_lean_angle_regression_test.dart index 34dd1e1..6209cc7 100644 --- a/test/services/sensor_fusion_lean_angle_regression_test.dart +++ b/test/services/sensor_fusion_lean_angle_regression_test.dart @@ -580,6 +580,35 @@ void main() { service.dispose(); }); + + test('18. Vehicle in motion (>2.0 mph) preserves calibration even with high pitch tilt during hard braking', () async { + final calibrationService = CalibrationService(); + final service = SensorFusionService(calibrationService, autoStartSensors: false); + + service.handleAccelerometerEvent(AccelerometerEvent(0.0, g, 0.0, DateTime.now())); + await service.calibrateUpright(); + expect(service.isCalibrated, isTrue); + + // Simulate riding at speed (35 mph) + service.updateVehicleSpeed(35.0); + + // Simulate hard braking pitch tilt / hill climb (>65° apparent gravity shift due to deceleration) + final hardBrakeAngleRad = 70.0 * (math.pi / 180.0); + for (int i = 0; i < 60; i++) { + service.handleAccelerometerEvent(AccelerometerEvent( + 0.0, + g * math.cos(hardBrakeAngleRad), + g * math.sin(hardBrakeAngleRad), + DateTime.now(), + )); + } + await Future.delayed(const Duration(milliseconds: 15)); + + // Calibration must NOT be wiped while actively riding + expect(service.isCalibrated, isTrue); + + service.dispose(); + }); }); group('Dashboard UI: Lean Gauge Display & Availability Semantics', () { diff --git a/test/social_hybrid_ui_test.dart b/test/social_hybrid_ui_test.dart index 35547f7..a57a9fa 100644 --- a/test/social_hybrid_ui_test.dart +++ b/test/social_hybrid_ui_test.dart @@ -79,7 +79,7 @@ void main() { // Verify header and filter components render cleanly expect(find.text('Community Feed'), findsOneWidget); expect(find.text('SQUAD & CANYON FEED'), findsOneWidget); - expect(find.text('24 ACTIVE'), findsOneWidget); + expect(find.byKey(const ValueKey('feed_search_toggle_button')), findsOneWidget); expect(find.text('SHARE RECENT SESSION'), findsOneWidget); // Verify that NO false GPS Verified badge is rendered diff --git a/test/truthful_heading_location_test.dart b/test/truthful_heading_location_test.dart index d3f1ec4..9d40dbe 100644 --- a/test/truthful_heading_location_test.dart +++ b/test/truthful_heading_location_test.dart @@ -70,6 +70,8 @@ class _NullSensorFusionService extends Fake implements SensorFusionService { double get latestRawGyroY => 0.0; @override double get latestRawGyroZ => 0.0; + @override + void updateVehicleSpeed(double speedMph) {} } void main() {