From 493192078f8fc5dc8314bcfb3e1a6f83f0d70a30 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 13 Oct 2026 18:11:47 -0400 Subject: [PATCH] fix(ui): landscape dashboard 3-column layout, decoupled alert presentation policy, truthful GPS contract, and diagnostics HUD --- .../widgets/dashboard_alert_region.dart | 107 +++++- .../widgets/evolution_portrait_dashboard.dart | 17 +- .../widgets/gps_status_indicator.dart | 27 +- .../widgets/navigation_maneuver_card.dart | 76 +++- .../widgets/responsive_dashboard_layout.dart | 124 +++--- .../widgets/road_alert_diagnostics_sheet.dart | 171 ++++++--- .../alerts/road_alert_diagnostics.dart | 51 ++- .../road_alert_presentation_policy.dart | 354 ++++++++++++++++++ lib/services/alerts/road_alert_providers.dart | 16 + lib/services/alerts/road_alert_service.dart | 123 +++++- test/canonical_dashboard_responsive_test.dart | 2 +- test/dashboard_landscape_responsive_test.dart | 170 +++++++++ ...lution_dashboard_truthful_status_test.dart | 21 +- test/rid3c2_truthful_recording_ui_test.dart | 2 +- ...oad_alert_diagnostics_responsive_test.dart | 129 +++++++ test/road_alert_presentation_policy_test.dart | 271 ++++++++++++++ test/truthful_heading_location_test.dart | 10 +- 17 files changed, 1541 insertions(+), 130 deletions(-) create mode 100644 lib/services/alerts/road_alert_presentation_policy.dart create mode 100644 test/dashboard_landscape_responsive_test.dart create mode 100644 test/road_alert_diagnostics_responsive_test.dart create mode 100644 test/road_alert_presentation_policy_test.dart diff --git a/lib/features/dashboard/widgets/dashboard_alert_region.dart b/lib/features/dashboard/widgets/dashboard_alert_region.dart index 2e1fbdb..6668010 100644 --- a/lib/features/dashboard/widgets/dashboard_alert_region.dart +++ b/lib/features/dashboard/widgets/dashboard_alert_region.dart @@ -23,28 +23,31 @@ class DashboardAlertRegion extends ConsumerWidget { final hasLocPermissionAsync = ref.watch(locationPermissionGrantedProvider); final hasLocPermission = hasLocPermissionAsync.value ?? true; - final alertsAsync = ref.watch(activeAlertsStreamProvider); - final activeAlerts = alertsAsync.value ?? []; - final closestAlert = activeAlerts.isNotEmpty ? activeAlerts.first : null; - + final dashboardAlerts = ref.watch(dashboardAlertsStreamProvider).value; + final fallbackAlerts = ref.watch(activeAlertsStreamProvider).value; + final RoadAlert? closestAlert = (dashboardAlerts != null && dashboardAlerts.isNotEmpty) + ? dashboardAlerts.first + : (ref.watch(dashboardBannerAlertProvider) ?? + (fallbackAlerts != null && fallbackAlerts.isNotEmpty ? fallbackAlerts.first : null)); final activeGroupRide = ref.watch(activeUserGroupRideProvider); + final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; return Column( mainAxisSize: MainAxisSize.min, children: [ - if (!hasLocPermission) _buildPermissionBanner(context, ref), - const NavigationManeuverCard(), - if (closestAlert != null) _buildRoadAlertBanner(context, closestAlert, ref), - if (activeGroupRide != null) _buildGroupRideBanner(context, activeGroupRide), + if (!hasLocPermission) _buildPermissionBanner(context, ref, isLandscape: isLandscape), + if (!isLandscape) const NavigationManeuverCard(), + if (closestAlert != null) _buildRoadAlertBanner(context, closestAlert, ref, isLandscape: isLandscape), + if (activeGroupRide != null) _buildGroupRideBanner(context, activeGroupRide, isLandscape: isLandscape), ], ); } - Widget _buildPermissionBanner(BuildContext context, WidgetRef ref) { + Widget _buildPermissionBanner(BuildContext context, WidgetRef ref, {bool isLandscape = false}) { return Container( width: double.infinity, color: AppColors.darkAmber, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: EdgeInsets.symmetric(horizontal: 16, vertical: isLandscape ? 4 : 8), child: Row( children: [ const Icon(Icons.location_off, color: Colors.white, size: 20), @@ -81,11 +84,89 @@ class DashboardAlertRegion extends ConsumerWidget { ); } - Widget _buildRoadAlertBanner(BuildContext context, RoadAlert alert, WidgetRef ref) { + Widget _buildRoadAlertBanner( + BuildContext context, + RoadAlert alert, + WidgetRef ref, { + bool isLandscape = false, + }) { 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; + if (isLandscape) { + return Container( + height: 38, + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: alert.category.color.withValues(alpha: isDark ? 0.22 : 0.14), + border: Border(bottom: BorderSide(color: alert.category.color, width: 1.5)), + ), + child: Row( + children: [ + Icon(alert.category.icon, color: alert.category.color, size: 20), + const SizedBox(width: 8), + Text( + alert.category.displayName.toUpperCase(), + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + color: alert.category.color, + fontWeight: FontWeight.bold, + fontSize: 12, + letterSpacing: 0.6, + ), + ), + if (isSwarm) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: Colors.blueAccent.withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.blueAccent.withValues(alpha: 0.5), width: 0.8), + ), + child: const Text( + 'SWARM', + style: TextStyle( + fontSize: 8, + fontWeight: FontWeight.w900, + letterSpacing: 0.4, + color: Colors.lightBlueAccent, + ), + ), + ), + ], + const SizedBox(width: 8), + Text('•', style: TextStyle(color: subtitleColor, fontSize: 11)), + const SizedBox(width: 8), + Expanded( + child: Text( + _formatAlertSubtitle(alert), + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + fontFamily: AppTypography.fontBody, + color: subtitleColor, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + icon: Icon(Icons.close, color: subtitleColor, size: 16), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + tooltip: 'Dismiss alert', + onPressed: () { + ref.read(roadAlertServiceProvider).dismissAlert(alert.id); + }, + ), + ], + ), + ); + } + return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -205,12 +286,12 @@ class DashboardAlertRegion extends ConsumerWidget { ); } - Widget _buildGroupRideBanner(BuildContext context, GroupRide ride) { + Widget _buildGroupRideBanner(BuildContext context, GroupRide ride, {bool isLandscape = false}) { return InkWell( onTap: () => context.push('/group-rides/${ride.id}/live'), child: Container( width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: EdgeInsets.symmetric(horizontal: 16, vertical: isLandscape ? 4 : 8), decoration: BoxDecoration( color: AppColors.cyberCyan.withValues(alpha: 0.15), border: const Border( diff --git a/lib/features/dashboard/widgets/evolution_portrait_dashboard.dart b/lib/features/dashboard/widgets/evolution_portrait_dashboard.dart index 8463752..03e6369 100644 --- a/lib/features/dashboard/widgets/evolution_portrait_dashboard.dart +++ b/lib/features/dashboard/widgets/evolution_portrait_dashboard.dart @@ -85,24 +85,35 @@ class EvolutionPortraitDashboard extends ConsumerWidget { final totalG = math.sqrt(accelG * accelG + lateralG * lateralG); final gpsAcc = telemetry?.gpsAccuracy; + final isStale = (telemetry?.gpsAgeMs != null && telemetry!.gpsAgeMs! > 4000) || + (telemetry?.gpsTimestamp != null && + (telemetry?.timestamp ?? DateTime.now()) + .difference(telemetry!.gpsTimestamp!) + .inMilliseconds > + 4000) || + (telemetry?.latitude != null && !(telemetry!.isGpsFresh)); final IconData gpsIcon; final Color gpsColor; final String gpsText; - if (gpsAcc != null) { + if (gpsAcc != null && !isStale) { if (gpsAcc <= 15.0) { gpsIcon = Icons.gps_fixed; gpsColor = const Color(0xFF00E5FF); - gpsText = 'GPS 3D FIX'; + gpsText = 'GPS LOCKED'; } else if (gpsAcc <= 35.0) { gpsIcon = Icons.gps_fixed; gpsColor = Colors.amberAccent; - gpsText = 'GPS 2D FIX'; + gpsText = 'GPS FAIR'; } else { gpsIcon = Icons.gps_not_fixed; gpsColor = Colors.orangeAccent; gpsText = 'GPS WEAK'; } + } else if (gpsAcc != null && isStale) { + gpsIcon = Icons.gps_not_fixed; + gpsColor = Colors.amberAccent; + gpsText = 'GPS STALE'; } else { gpsIcon = Icons.gps_off; gpsColor = Colors.white38; diff --git a/lib/features/dashboard/widgets/gps_status_indicator.dart b/lib/features/dashboard/widgets/gps_status_indicator.dart index 4e20cb4..47f8101 100644 --- a/lib/features/dashboard/widgets/gps_status_indicator.dart +++ b/lib/features/dashboard/widgets/gps_status_indicator.dart @@ -18,9 +18,22 @@ class GPSStatusIndicator extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final (gpsAcc, isFresh) = ref.watch( + final (gpsAcc, isStale) = ref.watch( telemetryStreamProvider.select( - (s) => (s.value?.gpsAccuracy, s.value?.isGpsFresh ?? false), + (s) { + final sample = s.value; + if (sample == null || sample.gpsAccuracy == null) { + return (null, false); + } + final stale = (sample.gpsAgeMs != null && sample.gpsAgeMs! > 4000) || + (sample.gpsTimestamp != null && + sample.timestamp + .difference(sample.gpsTimestamp!) + .inMilliseconds > + 4000) || + (sample.latitude != null && !sample.isGpsFresh); + return (sample.gpsAccuracy, stale); + }, ), ); @@ -30,20 +43,24 @@ class GPSStatusIndicator extends ConsumerWidget { final Color color; final String label; - if (gpsAcc != null && isFresh) { + if (gpsAcc != null && !isStale) { if (gpsAcc <= 15.0) { icon = Icons.gps_fixed; color = AppColors.telemetryGreen; - label = 'GPS 3D FIX'; + label = 'GPS LOCKED'; } else if (gpsAcc <= 35.0) { icon = Icons.gps_fixed; color = AppColors.warningAmber; - label = 'GPS 2D FIX'; + label = 'GPS FAIR'; } else { icon = Icons.gps_not_fixed; color = Colors.orangeAccent; label = 'GPS WEAK'; } + } else if (gpsAcc != null && isStale) { + icon = Icons.gps_not_fixed; + color = AppColors.warningAmber; + label = 'GPS STALE'; } else { icon = Icons.gps_off; color = theme.colorScheme.onSurfaceVariant; diff --git a/lib/features/dashboard/widgets/navigation_maneuver_card.dart b/lib/features/dashboard/widgets/navigation_maneuver_card.dart index a1b2c9c..69c5ffa 100644 --- a/lib/features/dashboard/widgets/navigation_maneuver_card.dart +++ b/lib/features/dashboard/widgets/navigation_maneuver_card.dart @@ -10,7 +10,9 @@ import '../../../services/navigation/navigation_session_service.dart'; /// maneuver direction, next road, countdown distance in feet/miles, ETA, and /// quick mute/end controls. class NavigationManeuverCard extends ConsumerWidget { - const NavigationManeuverCard({super.key}); + final bool compact; + + const NavigationManeuverCard({super.key, this.compact = false}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -23,6 +25,78 @@ class NavigationManeuverCard extends ConsumerWidget { final nextManeuver = navState.nextManeuver; final colors = context.motorColors; + if (compact) { + return Semantics( + label: 'Navigation: ${currentManeuver.instruction}. ${navState.formattedDistanceToNext} to turn.', + child: Container( + key: const ValueKey('navigation_maneuver_card'), + height: 38, + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFF0F172A), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: colors.cyberCyan.withValues(alpha: 0.6), + width: 1.2, + ), + ), + child: Row( + children: [ + Icon( + currentManeuver.type.icon, + size: 20, + color: colors.cyberCyan, + ), + const SizedBox(width: 6), + Text( + navState.formattedDistanceToNext, + style: const TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 13, + fontWeight: FontWeight.w900, + color: Colors.white, + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + currentManeuver.streetName ?? currentManeuver.instruction, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 11, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + Text( + navState.formattedEta, + style: TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 10.5, + fontWeight: FontWeight.bold, + color: colors.cyberCyan, + ), + ), + const SizedBox(width: 4), + IconButton( + constraints: const BoxConstraints(minWidth: 26, minHeight: 26), + padding: EdgeInsets.zero, + icon: const Icon(Icons.close, color: Colors.white70, size: 16), + tooltip: 'Exit Navigation', + onPressed: () { + ref.read(navigationSessionProvider.notifier).stopNavigation(); + }, + ), + ], + ), + ), + ); + } + return Semantics( label: 'Navigation: ${currentManeuver.instruction}. ${navState.formattedDistanceToNext} to turn. Remaining: ${navState.formattedRemainingDistance}, ETA ${navState.formattedEta}.', child: Card( diff --git a/lib/features/dashboard/widgets/responsive_dashboard_layout.dart b/lib/features/dashboard/widgets/responsive_dashboard_layout.dart index 5454e06..9450e1f 100644 --- a/lib/features/dashboard/widgets/responsive_dashboard_layout.dart +++ b/lib/features/dashboard/widgets/responsive_dashboard_layout.dart @@ -14,6 +14,7 @@ import 'gps_status_indicator.dart'; import 'heading_indicator.dart'; import 'lean_angle_indicator.dart'; import 'metric_tile.dart'; +import 'navigation_maneuver_card.dart'; import 'performance_summary.dart'; import 'ride_action_button.dart'; import 'ride_status_chip.dart'; @@ -38,10 +39,16 @@ class ResponsiveDashboardLayout extends ConsumerWidget { final session = ref.watch(rideStateMachineProvider); final snapshot = context.responsive; + final double leftPad = snapshot.isLandscape ? 12.0 : (snapshot.isShortViewport ? 10.0 : 16.0); + final double rightPad = snapshot.isLandscape ? 20.0 : (snapshot.isShortViewport ? 10.0 : 16.0); + final double vertPad = snapshot.isShortViewport ? 6.0 : 12.0; + return Padding( - padding: EdgeInsets.symmetric( - horizontal: snapshot.isShortViewport ? 10.0 : 16.0, - vertical: snapshot.isShortViewport ? 6.0 : 12.0, + padding: EdgeInsets.only( + left: leftPad, + right: rightPad, + top: vertPad, + bottom: vertPad, ), child: snapshot.isLandscape ? (snapshot.isShortViewport @@ -400,42 +407,52 @@ class ResponsiveDashboardLayout extends ConsumerWidget { // Default: Sport Cockpit Horizontal return Row( children: [ - // Left Wing: G-Force + Trip Stats + // Left Column: G-Force + Trip Statistics Expanded( - flex: 28, + flex: 29, child: Column( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ const GForceIndicator(compact: true), - const SizedBox(height: 4), - FittedBox( - fit: BoxFit.scaleDown, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - MetricTile( - label: 'TRIP', - value: session.tripDistanceMiles.toStringAsFixed(1), - unit: 'MI', - compact: true, - ), - const SizedBox(width: 8), - MetricTile( - label: 'TOP', - value: session.topSpeedMph.toStringAsFixed(0), - unit: 'MPH', - compact: true, - ), - ], + HybridCard( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + MetricTile( + label: 'TRIP', + value: session.tripDistanceMiles.toStringAsFixed(1), + unit: 'MI', + compact: true, + ), + const SizedBox(width: 8), + MetricTile( + label: 'TOP', + value: session.topSpeedMph.toStringAsFixed(0), + unit: 'MPH', + compact: true, + ), + const SizedBox(width: 8), + MetricTile( + label: 'AVG', + value: session.averageSpeedMph.toStringAsFixed(0), + unit: 'MPH', + compact: true, + ), + ], + ), ), ), ], ), ), + const SizedBox(width: 8), - // Center: Speedometer & Controls + // Center Column: Primary Riding Controls & Speedometer Expanded( - flex: 44, + flex: 42, child: Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -446,7 +463,7 @@ class ResponsiveDashboardLayout extends ConsumerWidget { children: [ DashboardLayoutSwitcher(), SizedBox(width: 4), - GPSStatusIndicator(showLabel: false), + GPSStatusIndicator(), SizedBox(width: 4), HeadingIndicator(compact: true), SizedBox(width: 4), @@ -454,36 +471,46 @@ class ResponsiveDashboardLayout extends ConsumerWidget { ], ), ), + const NavigationManeuverCard(compact: true), const Center(child: SpeedReadout(size: SpeedReadoutSize.compact)), const RideActionButton(compact: true), - const SizedBox(height: 6), + const SizedBox(height: 4), const PerformanceLaunchShortcut(compact: true), ], ), ), + const SizedBox(width: 8), - // Right Wing: Lean Angle + Avg Speed + // Right Column: Lean-Angle Telemetry + Dynamics Expanded( - flex: 28, + flex: 29, child: Column( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ const LeanAngleIndicator(compact: true), - const SizedBox(height: 4), - FittedBox( - fit: BoxFit.scaleDown, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - MetricTile( - label: 'AVG', - value: session.averageSpeedMph.toStringAsFixed(0), - unit: 'MPH', - compact: true, - ), - const SizedBox(width: 8), - const PerformanceSummary(compact: true), - ], + HybridCard( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + MetricTile( + label: 'MOVING', + value: '${(session.movingTimeSeconds / 60).floor()}m', + compact: true, + ), + const SizedBox(width: 8), + MetricTile( + label: 'ELEV', + value: (session.elevationGain * 3.28084).toStringAsFixed(0), + unit: 'FT', + compact: true, + ), + const SizedBox(width: 8), + const PerformanceSummary(compact: true), + ], + ), ), ), ], @@ -648,6 +675,7 @@ class ResponsiveDashboardLayout extends ConsumerWidget { ), ], ), + const NavigationManeuverCard(compact: true), Center( child: Transform.translate( offset: Offset(0, -20), diff --git a/lib/features/map/widgets/road_alert_diagnostics_sheet.dart b/lib/features/map/widgets/road_alert_diagnostics_sheet.dart index bdcfc21..f5e1ac5 100644 --- a/lib/features/map/widgets/road_alert_diagnostics_sheet.dart +++ b/lib/features/map/widgets/road_alert_diagnostics_sheet.dart @@ -121,14 +121,17 @@ class _RoadAlertDiagnosticsSheetState children: [ Row( children: [ - Text( - 'ROAD ALERT DIAGNOSTICS', - style: TextStyle( - fontFamily: 'Inter', - fontSize: 14, - fontWeight: FontWeight.w800, - letterSpacing: 0.8, - color: theme.colorScheme.onSurface, + Expanded( + child: Text( + 'ROAD ALERT DIAGNOSTICS', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w800, + letterSpacing: 0.8, + color: theme.colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 8), @@ -141,6 +144,7 @@ class _RoadAlertDiagnosticsSheetState fontSize: 11, color: theme.colorScheme.onSurfaceVariant, ), + overflow: TextOverflow.ellipsis, ), ], ), @@ -422,6 +426,9 @@ class _RoadAlertDiagnosticsSheetState final lastSuccessStr = diag.lastSuccessfulFetchTime != null ? DateFormat('HH:mm:ss').format(diag.lastSuccessfulFetchTime!) : 'Never'; + final gpsFreshnessDesc = '${diag.locationFreshnessState.toUpperCase()} ' + '(${diag.locationAccuracyMeters != null ? '±${diag.locationAccuracyMeters!.toStringAsFixed(1)}m' : 'no fix'}' + '${diag.lastLocationAgeSeconds != null ? ', ${diag.lastLocationAgeSeconds!.toStringAsFixed(1)}s old' : ''})'; return Container( padding: const EdgeInsets.all(12), @@ -438,9 +445,35 @@ class _RoadAlertDiagnosticsSheetState theme, ), const Divider(height: 14, thickness: 0.6), + _buildInfoRow('GPS Quality', gpsFreshnessDesc, theme), + const Divider(height: 14, thickness: 0.6), + _buildInfoRow( + 'Corridor Filter Mode', + diag.activeRouteRelevance + ? 'Active Corridor (2.0 mi)' + : 'Heading Cone (±65°)', + theme, + ), + if (diag.lastDashboardAlertPresented != null) ...[ + const Divider(height: 14, thickness: 0.6), + _buildInfoRow( + 'Last Dashboard Alert', + diag.lastDashboardAlertPresented!, + theme, + ), + ], + if (diag.lastAudioWarningIssued != null) ...[ + const Divider(height: 14, thickness: 0.6), + _buildInfoRow( + 'Last Audio Warning', + diag.lastAudioWarningIssued!, + theme, + ), + ], + const Divider(height: 14, thickness: 0.6), _buildInfoRow('Last Request Attempt', lastFetchStr, theme), const Divider(height: 14, thickness: 0.6), - _buildInfoRow('Last Successful Live Response', lastSuccessStr, theme), + _buildInfoRow('Last Successful Response', lastSuccessStr, theme), ], ), ); @@ -457,6 +490,7 @@ class _RoadAlertDiagnosticsSheetState color: theme.colorScheme.onSurfaceVariant, ), ), + const SizedBox(width: 8), Flexible( child: Text( value, @@ -474,44 +508,80 @@ class _RoadAlertDiagnosticsSheetState Widget _buildStageMetricsGrid( RoadAlertDiagnostics diag, dynamic colors, ThemeData theme) { - return Row( - children: [ - Expanded( - child: _buildMetricTile( + return LayoutBuilder( + builder: (context, constraints) { + final isNarrow = constraints.maxWidth < 500; + final tiles = [ + _buildMetricTile( label: 'Raw Upstream', value: diag.rawReportsCount.toString(), color: colors.cyberCyan, icon: Icons.cloud_download_outlined, ), - ), - const SizedBox(width: 8), - Expanded( - child: _buildMetricTile( + _buildMetricTile( label: 'Normalized', value: diag.normalizedAcceptedCount.toString(), color: Colors.lightBlueAccent, icon: Icons.check_circle_outline, ), - ), - const SizedBox(width: 8), - Expanded( - child: _buildMetricTile( - label: 'On Map HUD', + _buildMetricTile( + label: 'Tactical (Map)', value: diag.displayedOnMapCount.toString(), color: colors.launchGreen, icon: Icons.map_outlined, ), - ), - const SizedBox(width: 8), - Expanded( - child: _buildMetricTile( - label: 'Audio Warning', + _buildMetricTile( + label: 'Dashboard (2mi)', + value: diag.dashboardEligibleCount.toString(), + color: colors.brandCyan, + icon: Icons.dashboard_outlined, + ), + _buildMetricTile( + label: 'Audio (400m)', value: diag.audioCandidateCount.toString(), color: colors.warningAmber, icon: Icons.volume_up_outlined, ), - ), - ], + ]; + + if (isNarrow) { + return Column( + children: [ + Row( + children: [ + Expanded(child: tiles[0]), + const SizedBox(width: 6), + Expanded(child: tiles[1]), + const SizedBox(width: 6), + Expanded(child: tiles[2]), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + Expanded(child: tiles[3]), + const SizedBox(width: 6), + Expanded(child: tiles[4]), + ], + ), + ], + ); + } + + return Row( + children: [ + Expanded(child: tiles[0]), + const SizedBox(width: 6), + Expanded(child: tiles[1]), + const SizedBox(width: 6), + Expanded(child: tiles[2]), + const SizedBox(width: 6), + Expanded(child: tiles[3]), + const SizedBox(width: 6), + Expanded(child: tiles[4]), + ], + ); + }, ); } @@ -522,7 +592,7 @@ class _RoadAlertDiagnosticsSheetState required IconData icon, }) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8), decoration: BoxDecoration( color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), @@ -536,7 +606,7 @@ class _RoadAlertDiagnosticsSheetState value, style: TextStyle( fontFamily: 'JetBrains Mono', - fontSize: 16, + fontSize: 15, fontWeight: FontWeight.bold, color: color, ), @@ -548,7 +618,7 @@ class _RoadAlertDiagnosticsSheetState maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - fontSize: 9.5, + fontSize: 9.0, fontWeight: FontWeight.w500, ), ), @@ -574,16 +644,19 @@ class _RoadAlertDiagnosticsSheetState Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - 'FILTERED & REJECTED BREAKDOWN', - style: TextStyle( - fontFamily: 'Inter', - fontSize: 10.5, - fontWeight: FontWeight.w700, - letterSpacing: 0.6, - color: colors.warningAmber, + Expanded( + child: Text( + 'FILTERED & REJECTED BREAKDOWN', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 10.5, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + color: colors.warningAmber, + ), ), ), + const SizedBox(width: 8), Text( 'Total: ${diag.totalRejectedCount}', style: const TextStyle( @@ -595,7 +668,10 @@ class _RoadAlertDiagnosticsSheetState ], ), const SizedBox(height: 10), - if (reasons.isEmpty) + if (reasons.isEmpty && + diag.distanceRejectedCount == 0 && + diag.directionRejectedCount == 0 && + diag.staleGpsRejectedCount == 0) Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Text( @@ -611,6 +687,9 @@ class _RoadAlertDiagnosticsSheetState _buildReasonRow('Stale or Expired TTL', reasons['stale_or_expired'] ?? 0, colors), _buildReasonRow('Category Filter Disabled', reasons['category_disabled'] ?? 0, colors), _buildReasonRow('Out of Tactical Horizon (>25km)', reasons['out_of_tactical_range'] ?? 0, colors), + _buildReasonRow('Distance Exceeds Dashboard (>2.0mi)', diag.distanceRejectedCount, colors), + _buildReasonRow('Behind Rider / Off Route', diag.directionRejectedCount, colors), + _buildReasonRow('Stale or Inaccurate GPS', diag.staleGpsRejectedCount, colors), _buildReasonRow('Multi-Source Deduplicated', reasons['deduplicated'] ?? 0, colors), _buildReasonRow('Jurisdiction Compliance Suppressed', reasons['compliance_suppressed'] ?? 0, colors), _buildReasonRow('Manually Dismissed by Rider', reasons['dismissed'] ?? 0, colors), @@ -626,10 +705,14 @@ class _RoadAlertDiagnosticsSheetState child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - title, - style: const TextStyle(fontSize: 11), + Expanded( + child: Text( + title, + style: const TextStyle(fontSize: 11), + overflow: TextOverflow.ellipsis, + ), ), + const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration( diff --git a/lib/services/alerts/road_alert_diagnostics.dart b/lib/services/alerts/road_alert_diagnostics.dart index 06cd7f1..1e7e1c9 100644 --- a/lib/services/alerts/road_alert_diagnostics.dart +++ b/lib/services/alerts/road_alert_diagnostics.dart @@ -2,7 +2,7 @@ import 'road_alert_models.dart'; /// Comprehensive diagnostics telemetry for the road alert pipeline. /// Tracks upstream provider status, ingestion rates, stage-by-stage rejection reasons, -/// and map/audio dispatch metrics without leaking credentials or sensitive user data. +/// and map/dashboard/audio dispatch metrics without leaking credentials or sensitive user data. class RoadAlertDiagnostics { final RoadAlertProviderStatus providerStatus; final String? statusMessage; @@ -15,7 +15,17 @@ class RoadAlertDiagnostics { final int normalizedAcceptedCount; final Map rejectedReasons; final int displayedOnMapCount; + final int dashboardEligibleCount; final int audioCandidateCount; + final bool activeRouteRelevance; + final String locationFreshnessState; // 'fresh', 'stale', 'unavailable' + final double? lastLocationAgeSeconds; + final double? locationAccuracyMeters; + final int directionRejectedCount; + final int distanceRejectedCount; + final int staleGpsRejectedCount; + final String? lastDashboardAlertPresented; + final String? lastAudioWarningIssued; final String? lastError; const RoadAlertDiagnostics({ @@ -30,7 +40,17 @@ class RoadAlertDiagnostics { this.normalizedAcceptedCount = 0, this.rejectedReasons = const {}, this.displayedOnMapCount = 0, + this.dashboardEligibleCount = 0, this.audioCandidateCount = 0, + this.activeRouteRelevance = false, + this.locationFreshnessState = 'unknown', + this.lastLocationAgeSeconds, + this.locationAccuracyMeters, + this.directionRejectedCount = 0, + this.distanceRejectedCount = 0, + this.staleGpsRejectedCount = 0, + this.lastDashboardAlertPresented, + this.lastAudioWarningIssued, this.lastError, }); @@ -58,7 +78,17 @@ class RoadAlertDiagnostics { int? normalizedAcceptedCount, Map? rejectedReasons, int? displayedOnMapCount, + int? dashboardEligibleCount, int? audioCandidateCount, + bool? activeRouteRelevance, + String? locationFreshnessState, + double? lastLocationAgeSeconds, + double? locationAccuracyMeters, + int? directionRejectedCount, + int? distanceRejectedCount, + int? staleGpsRejectedCount, + String? lastDashboardAlertPresented, + String? lastAudioWarningIssued, String? lastError, }) { return RoadAlertDiagnostics( @@ -76,7 +106,26 @@ class RoadAlertDiagnostics { normalizedAcceptedCount ?? this.normalizedAcceptedCount, rejectedReasons: rejectedReasons ?? this.rejectedReasons, displayedOnMapCount: displayedOnMapCount ?? this.displayedOnMapCount, + dashboardEligibleCount: + dashboardEligibleCount ?? this.dashboardEligibleCount, audioCandidateCount: audioCandidateCount ?? this.audioCandidateCount, + activeRouteRelevance: activeRouteRelevance ?? this.activeRouteRelevance, + locationFreshnessState: + locationFreshnessState ?? this.locationFreshnessState, + lastLocationAgeSeconds: + lastLocationAgeSeconds ?? this.lastLocationAgeSeconds, + locationAccuracyMeters: + locationAccuracyMeters ?? this.locationAccuracyMeters, + directionRejectedCount: + directionRejectedCount ?? this.directionRejectedCount, + distanceRejectedCount: + distanceRejectedCount ?? this.distanceRejectedCount, + staleGpsRejectedCount: + staleGpsRejectedCount ?? this.staleGpsRejectedCount, + lastDashboardAlertPresented: + lastDashboardAlertPresented ?? this.lastDashboardAlertPresented, + lastAudioWarningIssued: + lastAudioWarningIssued ?? this.lastAudioWarningIssued, lastError: lastError ?? this.lastError, ); } diff --git a/lib/services/alerts/road_alert_presentation_policy.dart b/lib/services/alerts/road_alert_presentation_policy.dart new file mode 100644 index 0000000..50addc2 --- /dev/null +++ b/lib/services/alerts/road_alert_presentation_policy.dart @@ -0,0 +1,354 @@ +import 'package:geolocator/geolocator.dart'; +import '../navigation/navigation_session_service.dart'; +import 'road_alert_models.dart'; +import 'road_alert_service.dart'; + +/// Rejection reason codes for Road Alert evaluation. +enum AlertRejectionReason { + none, + expired, + dismissed, + categoryDisabled, + staleOrMissingGps, + outOfTacticalRange, + distanceExceedsDashboardThreshold, + behindRiderHeading, + notOnActiveRoute, + uncertainHeadingStationary, + complianceSuppressed, + deduplicated, +} + +/// Structured evaluation decision for a road alert presentation candidate. +class RoadAlertEvaluation { + final bool isTacticalMapEligible; + final bool isDashboardEligible; + final bool isAudioEligible; + final AlertRejectionReason dashboardRejectionReason; + final double? distanceFromUserMeters; + final bool isAhead; + final String formattedDistanceText; + + const RoadAlertEvaluation({ + required this.isTacticalMapEligible, + required this.isDashboardEligible, + required this.isAudioEligible, + this.dashboardRejectionReason = AlertRejectionReason.none, + this.distanceFromUserMeters, + this.isAhead = false, + this.formattedDistanceText = '', + }); +} + +/// Independent policy engine for road alert presentation across three decoupled scopes: +/// Scope A: Tactical Map Discovery (default 25 km / ~15.5 mi) +/// Scope B: Dashboard Banner (default 3218.7 m / ~2.0 mi, direction and route corridor aware) +/// Scope C: Audible Warning (default 400 m, speed/lean aware, non-repetitive) +abstract final class RoadAlertPresentationPolicy { + /// Default threshold for immediate dashboard warnings (2.0 miles). + static const double defaultDashboardRadiusMeters = 3218.688; + + /// Default threshold for immediate audible warnings (400 meters). + static const double defaultAudioRadiusMeters = 400.0; + + /// Maximum GPS horizontal accuracy in meters before coordinates are deemed too uncertain for proximity warnings. + static const double maxWarningGpsAccuracyMeters = 65.0; + + /// Maximum GPS fix age in seconds before coordinates are deemed stale for proximity warnings. + static const int maxWarningGpsAgeSeconds = 10; + + /// Evaluates whether current GPS position satisfies the freshness and accuracy contract. + static bool isLocationFreshAndAccurate({ + required Position? position, + DateTime? now, + }) { + if (position == null) return false; + final currentTime = now ?? DateTime.now(); + final ageMs = currentTime.difference(position.timestamp).inMilliseconds; + if (ageMs > maxWarningGpsAgeSeconds * 1000 || ageMs < -2000) return false; + if (position.accuracy > maxWarningGpsAccuracyMeters) return false; + return true; + } + + /// Calculates angular bearing delta (0° to 180°) between rider heading and bearing to alert. + static double calculateBearingDelta({ + required double riderHeading, + required double userLat, + required double userLng, + required double alertLat, + required double alertLng, + }) { + final bearingToAlert = Geolocator.bearingBetween( + userLat, + userLng, + alertLat, + alertLng, + ); + double delta = (bearingToAlert - riderHeading).abs(); + if (delta > 180.0) delta = 360.0 - delta; + return delta; + } + + /// Evaluates whether an alert is along and ahead of the rider's travel direction or active route. + static ({bool isAhead, bool onRoute}) evaluateDirectionAndRoute({ + required Position userPosition, + required RoadAlert alert, + required NavigationSessionState? navSession, + double dashboardRadiusMeters = defaultDashboardRadiusMeters, + }) { + final hasActiveRoute = navSession != null && + navSession.isNavigating && + navSession.calculationResult != null && + navSession.calculationResult!.geometry.length >= 2; + + if (hasActiveRoute) { + final geom = navSession.calculationResult!.geometry; + final onRouteAhead = RouteCorridorAlertFilter.isAlertAheadOnRoute( + userLat: userPosition.latitude, + userLng: userPosition.longitude, + alertLat: alert.latitude, + alertLng: alert.longitude, + geometry: geom, + maxAheadMeters: dashboardRadiusMeters, + maxCrossTrackMeters: 80.0, + ); + return (isAhead: onRouteAhead, onRoute: onRouteAhead); + } + + // When no active route is present: evaluate bearing relative to motion heading + final isMoving = userPosition.speed >= 2.0; // ~4.5 MPH motion threshold + if (!isMoving) { + // When stationary or creeping, heading is uncertain + return (isAhead: false, onRoute: false); + } + + final delta = calculateBearingDelta( + riderHeading: userPosition.heading, + userLat: userPosition.latitude, + userLng: userPosition.longitude, + alertLat: alert.latitude, + alertLng: alert.longitude, + ); + + // Alert is ahead within a 130° frontal arc (+/- 65°) + final isAhead = delta <= 65.0; + return (isAhead: isAhead, onRoute: false); + } + + /// Evaluates whether an alert should be presented on the Dashboard Banner. + static RoadAlertEvaluation evaluateForDashboard({ + required RoadAlert alert, + required Position? userPosition, + required NavigationSessionState? navSession, + required Set dismissedIds, + required Set enabledCategories, + double tacticalRadiusMeters = 25000.0, + double dashboardRadiusMeters = defaultDashboardRadiusMeters, + DateTime? now, + }) { + final currentTime = now ?? DateTime.now(); + + // 1. Basic lifecycle checks + final alertKey = '${alert.providerId}_${alert.id}'; + if (dismissedIds.contains(alert.id) || dismissedIds.contains(alertKey)) { + return const RoadAlertEvaluation( + isTacticalMapEligible: false, + isDashboardEligible: false, + isAudioEligible: false, + dashboardRejectionReason: AlertRejectionReason.dismissed, + ); + } + + if (alert.expiresAt != null && alert.expiresAt!.isBefore(currentTime)) { + return const RoadAlertEvaluation( + isTacticalMapEligible: false, + isDashboardEligible: false, + isAudioEligible: false, + dashboardRejectionReason: AlertRejectionReason.expired, + ); + } + + if (!enabledCategories.contains(alert.category)) { + return const RoadAlertEvaluation( + isTacticalMapEligible: false, + isDashboardEligible: false, + isAudioEligible: false, + dashboardRejectionReason: AlertRejectionReason.categoryDisabled, + ); + } + + if (userPosition == null) { + return const RoadAlertEvaluation( + isTacticalMapEligible: true, + isDashboardEligible: false, + isAudioEligible: false, + dashboardRejectionReason: AlertRejectionReason.staleOrMissingGps, + ); + } + + final dist = Geolocator.distanceBetween( + userPosition.latitude, + userPosition.longitude, + alert.latitude, + alert.longitude, + ); + + final isTacticalEligible = dist <= tacticalRadiusMeters; + + // 2. Freshness & accuracy check + final isGpsFresh = isLocationFreshAndAccurate(position: userPosition, now: currentTime); + if (!isGpsFresh) { + return RoadAlertEvaluation( + isTacticalMapEligible: isTacticalEligible, + isDashboardEligible: false, + isAudioEligible: false, + distanceFromUserMeters: dist, + dashboardRejectionReason: AlertRejectionReason.staleOrMissingGps, + formattedDistanceText: formatDistance(dist, isAhead: false), + ); + } + + // 3. Distance threshold check for tactical horizon (25 km) and dashboard (default 2 miles) + if (dist > tacticalRadiusMeters) { + return RoadAlertEvaluation( + isTacticalMapEligible: false, + isDashboardEligible: false, + isAudioEligible: false, + distanceFromUserMeters: dist, + dashboardRejectionReason: AlertRejectionReason.outOfTacticalRange, + formattedDistanceText: formatDistance(dist, isAhead: false), + ); + } + + if (dist > dashboardRadiusMeters) { + return RoadAlertEvaluation( + isTacticalMapEligible: isTacticalEligible, + isDashboardEligible: false, + isAudioEligible: false, + distanceFromUserMeters: dist, + dashboardRejectionReason: AlertRejectionReason.distanceExceedsDashboardThreshold, + formattedDistanceText: formatDistance(dist, isAhead: false), + ); + } + + // 4. Direction & Route relevance + final dirResult = evaluateDirectionAndRoute( + userPosition: userPosition, + alert: alert, + navSession: navSession, + dashboardRadiusMeters: dashboardRadiusMeters, + ); + + final hasActiveRoute = navSession != null && + navSession.isNavigating && + navSession.calculationResult != null && + navSession.calculationResult!.geometry.length >= 2; + + if (hasActiveRoute) { + if (!dirResult.onRoute) { + // When actively navigating a route, suppress alerts not along the route corridor + return RoadAlertEvaluation( + isTacticalMapEligible: isTacticalEligible, + isDashboardEligible: false, + isAudioEligible: false, + distanceFromUserMeters: dist, + isAhead: false, + dashboardRejectionReason: AlertRejectionReason.notOnActiveRoute, + formattedDistanceText: formatDistance(dist, isAhead: false), + ); + } + } else { + final isMoving = userPosition.speed >= 2.0; + if (isMoving) { + if (!dirResult.isAhead) { + // Exclude alerts behind rider + return RoadAlertEvaluation( + isTacticalMapEligible: isTacticalEligible, + isDashboardEligible: false, + isAudioEligible: false, + distanceFromUserMeters: dist, + isAhead: false, + dashboardRejectionReason: AlertRejectionReason.behindRiderHeading, + formattedDistanceText: formatDistance(dist, isAhead: false), + ); + } + } else { + // Stationary or creeping: only display if in very close proximity (<= 800m / 0.5 mi) + if (dist > 804.7) { + return RoadAlertEvaluation( + isTacticalMapEligible: isTacticalEligible, + isDashboardEligible: false, + isAudioEligible: false, + distanceFromUserMeters: dist, + isAhead: false, + dashboardRejectionReason: AlertRejectionReason.uncertainHeadingStationary, + formattedDistanceText: formatDistance(dist, isAhead: false), + ); + } + } + } + + final distanceStr = formatDistance(dist, isAhead: dirResult.isAhead); + + return RoadAlertEvaluation( + isTacticalMapEligible: isTacticalEligible, + isDashboardEligible: true, + isAudioEligible: dist <= defaultAudioRadiusMeters && dirResult.isAhead, + distanceFromUserMeters: dist, + isAhead: dirResult.isAhead, + formattedDistanceText: distanceStr, + ); + } + + /// Evaluates an alert specifically for audible warning candidacy (default 400m / 0.25 mi threshold). + static RoadAlertEvaluation evaluateForAudio({ + required RoadAlert alert, + required Position? userPosition, + required NavigationSessionState? navSession, + required Set dismissedIds, + required Set enabledCategories, + double audioRadiusMeters = defaultAudioRadiusMeters, + DateTime? now, + }) { + final eval = evaluateForDashboard( + alert: alert, + userPosition: userPosition, + navSession: navSession, + dismissedIds: dismissedIds, + enabledCategories: enabledCategories, + dashboardRadiusMeters: audioRadiusMeters, + now: now, + ); + final isAudio = eval.isDashboardEligible && + eval.distanceFromUserMeters != null && + eval.distanceFromUserMeters! <= audioRadiusMeters && + eval.isAhead; + return RoadAlertEvaluation( + isTacticalMapEligible: eval.isTacticalMapEligible, + isDashboardEligible: eval.isDashboardEligible, + isAudioEligible: isAudio, + distanceFromUserMeters: eval.distanceFromUserMeters, + isAhead: eval.isAhead, + dashboardRejectionReason: eval.dashboardRejectionReason, + formattedDistanceText: eval.formattedDistanceText, + ); + } + + /// Formats human-readable distance truthfully. + /// Never appends "Ahead" unless the alert is reliably verified to be in front of the rider. + static String formatDistance(double? meters, {required bool isAhead}) { + if (meters == null) return ''; + if (meters < 15.0) { + return 'Nearby'; + } + final miles = meters / 1609.344; + final suffix = isAhead ? 'Ahead' : 'Nearby'; + + if (miles < 0.1) { + final feet = ((meters * 3.28084) / 10).round() * 10; + return '${feet > 0 ? feet : 50} ft $suffix'; + } else { + return '${miles.toStringAsFixed(1)} mi $suffix'; + } + } +} diff --git a/lib/services/alerts/road_alert_providers.dart b/lib/services/alerts/road_alert_providers.dart index 01a9ffc..0fabc15 100644 --- a/lib/services/alerts/road_alert_providers.dart +++ b/lib/services/alerts/road_alert_providers.dart @@ -673,6 +673,22 @@ final tacticalAlertRadiusMetersProvider = return TacticalAlertRadiusMetersNotifier(); }); +/// Dashboard alert presentation threshold in meters (default 2 miles / ~3218.7 meters). +/// Decoupled from tactical map horizon (25 km) and audio warning proximity (400 m). +class DashboardAlertRadiusMetersNotifier extends StateNotifier { + static const double defaultRadius = 3218.688; // 2.0 miles + DashboardAlertRadiusMetersNotifier() : super(defaultRadius); + + void setRadius(double radiusMeters) { + state = radiusMeters; + } +} + +final dashboardAlertRadiusMetersProvider = + StateNotifierProvider((ref) { + return DashboardAlertRadiusMetersNotifier(); +}); + /// Live pipeline diagnostics state notifier class RoadAlertDiagnosticsNotifier extends StateNotifier { RoadAlertDiagnosticsNotifier() : super(const RoadAlertDiagnostics()); diff --git a/lib/services/alerts/road_alert_service.dart b/lib/services/alerts/road_alert_service.dart index 9875943..ea5bb2e 100644 --- a/lib/services/alerts/road_alert_service.dart +++ b/lib/services/alerts/road_alert_service.dart @@ -14,6 +14,7 @@ import 'road_alert_models.dart'; import 'road_alert_providers.dart'; import 'road_alert_compliance.dart'; import 'multi_source_alert_deduplicator.dart'; +import 'road_alert_presentation_policy.dart'; import '../field_test/field_test_logger.dart'; import '../../models/field_test_event.dart'; @@ -31,10 +32,19 @@ final activeAlertsStreamProvider = StreamProvider>((ref) { return ref.watch(roadAlertServiceProvider).alertsStream; }); +final dashboardAlertsStreamProvider = StreamProvider>((ref) { + return ref.watch(roadAlertServiceProvider).dashboardAlertsStream; +}); + +final dashboardBannerAlertProvider = Provider((ref) { + return ref.watch(roadAlertServiceProvider).closestDashboardAlert; +}); + class RoadAlertService { final Ref _ref; final FlutterTts _tts; final _alertsController = BehaviorSubject>.seeded([]); + final _dashboardAlertsController = BehaviorSubject>.seeded([]); StreamSubscription? _locationSub; StreamSubscription? _leanSub; @@ -45,6 +55,7 @@ class RoadAlertService { Position? _lastObservedPosition; bool _isFetching = false; // Request serialization lock DateTime? _lastSpeechTime; // Audio cooldown timer + String? _lastAudioWarningIssued; double _currentLeanAngle = 0.0; DateTime? _lastSteepLeanTime; @@ -57,6 +68,10 @@ class RoadAlertService { Stream> get alertsStream => _alertsController.stream; List get cachedAlerts => List.unmodifiable(_cachedAlerts); + Stream> get dashboardAlertsStream => _dashboardAlertsController.stream; + List get cachedDashboardAlerts => List.unmodifiable(_dashboardAlertsController.value); + RoadAlert? get closestDashboardAlert => + _dashboardAlertsController.value.isNotEmpty ? _dashboardAlertsController.value.first : null; double get currentLeanAngle => _currentLeanAngle; bool get isCornering => _currentLeanAngle > 15.0 || @@ -210,9 +225,10 @@ class RoadAlertService { _lastObservedPosition = position; final isEnabled = _ref.read(roadAlertsEnabledProvider); if (!isEnabled) { - if (_cachedAlerts.isNotEmpty) { + if (_cachedAlerts.isNotEmpty || _dashboardAlertsController.value.isNotEmpty) { _cachedAlerts = []; _alertsController.add([]); + _dashboardAlertsController.add([]); } return; } @@ -354,12 +370,83 @@ class RoadAlertService { return (a.distanceFromUser ?? 0).compareTo(b.distanceFromUser ?? 0); }); + // Scope A: Tactical Map Discovery (full horizon up to 25 km) _cachedAlerts = compliantList; _alertsController.add(compliantList); - // Approach Audio Warning: evaluated against proximity threshold (audioRadiusMeters / 400m) + // Scope B: Dashboard Banner Evaluation (up to 2.0 mi, route/direction & GPS freshness verified) + final double dashboardRadiusMeters = _ref.read(dashboardAlertRadiusMetersProvider); + final List dashboardEligibleAlerts = []; + int directionRejectedCount = 0; + int distanceRejectedCount = 0; + int staleGpsRejectedCount = 0; + + for (var alert in compliantList) { + final evaluation = RoadAlertPresentationPolicy.evaluateForDashboard( + alert: alert, + userPosition: position, + navSession: navSession, + dismissedIds: _dismissedAlertIds, + enabledCategories: enabledCategories, + tacticalRadiusMeters: tacticalRadiusMeters, + dashboardRadiusMeters: dashboardRadiusMeters, + now: now, + ); + + if (evaluation.isDashboardEligible) { + dashboardEligibleAlerts.add(alert.copyWith( + distanceFromUser: evaluation.distanceFromUserMeters, + )); + } else { + switch (evaluation.dashboardRejectionReason) { + case AlertRejectionReason.distanceExceedsDashboardThreshold: + distanceRejectedCount++; + break; + case AlertRejectionReason.behindRiderHeading: + case AlertRejectionReason.notOnActiveRoute: + case AlertRejectionReason.uncertainHeadingStationary: + directionRejectedCount++; + break; + case AlertRejectionReason.staleOrMissingGps: + staleGpsRejectedCount++; + break; + default: + break; + } + } + } + + // Sort dashboard alerts: on-route first, then closest distance + dashboardEligibleAlerts.sort((a, b) { + if (hasActiveRoute) { + final geom = navSession.calculationResult!.geometry; + final aOnRoute = RouteCorridorAlertFilter.isAlertAheadOnRoute( + userLat: position.latitude, + userLng: position.longitude, + alertLat: a.latitude, + alertLng: a.longitude, + geometry: geom, + maxAheadMeters: dashboardRadiusMeters, + ); + final bOnRoute = RouteCorridorAlertFilter.isAlertAheadOnRoute( + userLat: position.latitude, + userLng: position.longitude, + alertLat: b.latitude, + alertLng: b.longitude, + geometry: geom, + maxAheadMeters: dashboardRadiusMeters, + ); + if (aOnRoute && !bOnRoute) return -1; + if (!aOnRoute && bOnRoute) return 1; + } + return (a.distanceFromUser ?? 0).compareTo(b.distanceFromUser ?? 0); + }); + + _dashboardAlertsController.add(dashboardEligibleAlerts); + + // Scope C: Approach Audio Warning (evaluated from dashboard-eligible candidates against 400m threshold) final List audioCandidates = []; - for (var updatedAlert in compliantList) { + for (var updatedAlert in dashboardEligibleAlerts) { final key = '${updatedAlert.providerId}_${updatedAlert.id}'; final dist = updatedAlert.distanceFromUser ?? 999999.0; final approachThreshold = math.min(audioRadiusMeters, 400.0); @@ -390,6 +477,7 @@ class RoadAlertService { if (_lastSpeechTime == null || speechTime.difference(_lastSpeechTime!).inSeconds >= 4) { _lastSpeechTime = speechTime; _announcedAlertIds.add(key); + _lastAudioWarningIssued = '${updatedAlert.category.displayName} • ${RoadAlertPresentationPolicy.formatDistance(dist, isAhead: true)}'; _announceAlertAudio(updatedAlert); } } @@ -416,6 +504,21 @@ class RoadAlertService { '${position.longitude.abs().toStringAsFixed(2)}° $lngDir ' '(${(tacticalRadiusMeters / 1000).toStringAsFixed(1)} km radius)'; + final isGpsFresh = RoadAlertPresentationPolicy.isLocationFreshAndAccurate( + position: position, + now: now, + ); + final String locationState = isGpsFresh + ? 'fresh' + : (_lastObservedPosition != null ? 'stale' : 'unavailable'); + final double? locAgeSec = _lastObservedPosition != null + ? now.difference(_lastObservedPosition!.timestamp).inMilliseconds / 1000.0 + : null; + + final String? lastDashboardText = dashboardEligibleAlerts.isNotEmpty + ? '${dashboardEligibleAlerts.first.category.displayName} • ${RoadAlertPresentationPolicy.formatDistance(dashboardEligibleAlerts.first.distanceFromUser, isAhead: true)}' + : null; + _ref.read(roadAlertDiagnosticsProvider.notifier).updateDiagnostics( RoadAlertDiagnostics( providerStatus: zylaProvider.lastStatus != RoadAlertProviderStatus.notConfigured @@ -432,7 +535,17 @@ class RoadAlertService { normalizedAcceptedCount: _cachedAlerts.length + staleCount + disabledCategoryCount + outOfRangeCount, rejectedReasons: rejectedReasons, displayedOnMapCount: compliantList.length, + dashboardEligibleCount: dashboardEligibleAlerts.length, audioCandidateCount: audioCandidates.length, + activeRouteRelevance: hasActiveRoute, + locationFreshnessState: locationState, + lastLocationAgeSeconds: locAgeSec, + locationAccuracyMeters: position.accuracy, + directionRejectedCount: directionRejectedCount, + distanceRejectedCount: distanceRejectedCount, + staleGpsRejectedCount: staleGpsRejectedCount, + lastDashboardAlertPresented: lastDashboardText, + lastAudioWarningIssued: _lastAudioWarningIssued, lastError: zylaProvider.lastError, ), ); @@ -689,6 +802,7 @@ class RoadAlertService { void clearAllAlerts() { _cachedAlerts = []; _alertsController.add([]); + _dashboardAlertsController.add([]); if (kDebugMode) { debugPrint('[RoadAlerts] Cleared all cached alerts'); } @@ -754,6 +868,7 @@ class RoadAlertService { _locationSub?.cancel(); _leanSub?.cancel(); _alertsController.close(); + _dashboardAlertsController.close(); try { _tts.stop().catchError((_) => null); } catch (_) {} @@ -818,7 +933,7 @@ class RouteCorridorAlertFilter { required double alertLng, required List geometry, double maxCrossTrackMeters = 80.0, - double maxAheadMeters = 1200.0, + double maxAheadMeters = 3218.688, }) { if (geometry.length < 2) return false; diff --git a/test/canonical_dashboard_responsive_test.dart b/test/canonical_dashboard_responsive_test.dart index 0ac1700..1a8c55d 100644 --- a/test/canonical_dashboard_responsive_test.dart +++ b/test/canonical_dashboard_responsive_test.dart @@ -237,7 +237,7 @@ void main() { expect(find.text('68'), findsOneWidget); expect(find.text('W'), findsOneWidget); expect(find.text('270°'), findsOneWidget); - expect(find.text('GPS 3D FIX'), findsOneWidget); + expect(find.text('GPS LOCKED'), findsOneWidget); expect(find.text('25°'), findsOneWidget); expect(find.text('L: 0°'), findsOneWidget); expect(find.text('R: 25°'), findsOneWidget); diff --git a/test/dashboard_landscape_responsive_test.dart b/test/dashboard_landscape_responsive_test.dart new file mode 100644 index 0000000..a50e1ed --- /dev/null +++ b/test/dashboard_landscape_responsive_test.dart @@ -0,0 +1,170 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:motorcycle_dash/core/theme/theme.dart'; +import 'package:motorcycle_dash/features/dashboard/dashboard_screen.dart'; +import 'package:motorcycle_dash/features/dashboard/widgets/g_force_indicator.dart'; +import 'package:motorcycle_dash/features/dashboard/widgets/gps_status_indicator.dart'; +import 'package:motorcycle_dash/features/dashboard/widgets/heading_indicator.dart'; +import 'package:motorcycle_dash/features/dashboard/widgets/lean_angle_indicator.dart'; +import 'package:motorcycle_dash/features/dashboard/widgets/performance_summary.dart'; +import 'package:motorcycle_dash/features/dashboard/widgets/responsive_dashboard_layout.dart'; +import 'package:motorcycle_dash/features/dashboard/widgets/ride_action_button.dart'; +import 'package:motorcycle_dash/features/dashboard/widgets/ride_status_chip.dart'; +import 'package:motorcycle_dash/features/dashboard/widgets/speed_readout.dart'; +import 'package:motorcycle_dash/features/records/records_service.dart'; +import 'package:motorcycle_dash/features/rides/ride_state_machine.dart'; +import 'package:motorcycle_dash/models/telemetry_sample.dart'; +import 'package:motorcycle_dash/services/alerts/road_alert_models.dart'; +import 'package:motorcycle_dash/services/alerts/road_alert_service.dart'; +import 'package:motorcycle_dash/services/location_service.dart'; +import 'package:motorcycle_dash/services/telemetry_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late SharedPreferences prefs; + late StreamController telemetryController; + late StreamController> alertsController; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + telemetryController = StreamController.broadcast(); + alertsController = StreamController>.broadcast(); + }); + + tearDown(() async { + await telemetryController.close(); + await alertsController.close(); + }); + + Widget buildTestDashboard({ + RideSession? initialSession, + List? initialAlerts, + }) { + return ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + telemetryStreamProvider.overrideWith((ref) => telemetryController.stream), + activeAlertsStreamProvider.overrideWith((ref) => alertsController.stream), + dashboardAlertsStreamProvider.overrideWith((ref) => alertsController.stream), + locationPermissionGrantedProvider.overrideWith((ref) => true), + ], + child: MaterialApp( + theme: AppTheme.darkTheme, + home: const DashboardScreen(), + ), + ); + } + + void configureViewport(WidgetTester tester, double width, double height) { + tester.view.physicalSize = Size(width, height); + tester.view.devicePixelRatio = 1.0; + } + + group('Landscape Dashboard Redesign Matrix Tests', () { + testWidgets('Renders adaptive 3-column composition on 840x390 landscape phone', (tester) async { + configureViewport(tester, 840, 390); + addTearDown(() => tester.view.resetPhysicalSize()); + + await tester.pumpWidget(buildTestDashboard()); + await tester.pump(); + + // Zero RenderFlex overflow + expect(tester.takeException(), isNull); + + // Verify Left Column: G-Force indicator & statistics tiles + expect(find.byType(GForceIndicator), findsOneWidget); + expect(find.text('TRIP'), findsOneWidget); + expect(find.text('TOP'), findsOneWidget); + expect(find.text('AVG'), findsOneWidget); + + // Verify Center Column: Speedometer, Status row, Controls + expect(find.byType(SpeedReadout), findsOneWidget); + expect(find.byType(GPSStatusIndicator), findsOneWidget); + expect(find.byType(HeadingIndicator), findsOneWidget); + expect(find.byType(RideStatusChip), findsOneWidget); + expect(find.byType(RideActionButton), findsOneWidget); + expect(find.byType(PerformanceLaunchShortcut), findsOneWidget); + + // Verify Right Column: Lean-angle gauge & dynamics + expect(find.byType(LeanAngleIndicator), findsOneWidget); + expect(find.text('MOVING'), findsOneWidget); + expect(find.text('ELEV'), findsOneWidget); + expect(find.byType(PerformanceSummary), findsOneWidget); + + // Verify horizontal order: GForce (left) < Speed (center) < LeanAngle (right) + final gForceCenter = tester.getCenter(find.byType(GForceIndicator)); + final speedCenter = tester.getCenter(find.byType(SpeedReadout)); + final leanCenter = tester.getCenter(find.byType(LeanAngleIndicator)); + + expect(gForceCenter.dx, lessThan(speedCenter.dx)); + expect(speedCenter.dx, lessThan(leanCenter.dx)); + }); + + testWidgets('Landscape alert banner is compact (<= 40dp height) and non-disruptive', (tester) async { + configureViewport(tester, 840, 390); + addTearDown(() => tester.view.resetPhysicalSize()); + + await tester.pumpWidget(buildTestDashboard()); + await tester.pump(); + + final now = DateTime.now(); + final alert = RoadAlert( + id: 'police_ahead_1', + providerId: 'waze_zyla', + category: RoadAlertCategory.police, + latitude: 34.05, + longitude: -84.40, + distanceFromUser: 450, + roadName: 'GA 400 Southbound', + description: 'Police Speed Trap', + reportedAt: now, + lastUpdated: now, + source: 'Waze', + ); + + alertsController.add([alert]); + await tester.pump(const Duration(milliseconds: 50)); + + expect(tester.takeException(), isNull); + expect(find.text('POLICE'), findsOneWidget); + expect(find.textContaining('0.3 mi Ahead'), findsOneWidget); + + // Verify banner height is constrained to <= 40dp + final bannerFinder = find.byWidgetPredicate( + (widget) => widget is Container && widget.constraints?.maxHeight == 38 || + (widget is Container && widget.decoration is BoxDecoration && (widget.decoration as BoxDecoration).border?.bottom != null), + ); + expect(bannerFinder, findsWidgets); + + // Speedometer remains visible and unobstructed + expect(find.byType(SpeedReadout), findsOneWidget); + expect(tester.getCenter(find.byType(SpeedReadout)).dy, greaterThan(60)); + }); + + testWidgets('Preserves clear margin on right edge (>= 16dp) in landscape for volume overlay', (tester) async { + configureViewport(tester, 840, 390); + addTearDown(() => tester.view.resetPhysicalSize()); + + await tester.pumpWidget(buildTestDashboard()); + await tester.pump(); + + // Find the ResponsiveDashboardLayout Padding widget + final paddingFinder = find.ancestor( + of: find.byType(ResponsiveDashboardLayout), + matching: find.byType(Padding), + ); + expect(paddingFinder, findsWidgets); + + final paddingWidget = tester.firstWidget(find.descendant( + of: find.byType(ResponsiveDashboardLayout), + matching: find.byType(Padding), + )); + final edgeInsets = paddingWidget.padding as EdgeInsets; + expect(edgeInsets.right, greaterThanOrEqualTo(16.0)); + }); + }); +} diff --git a/test/evolution_dashboard_truthful_status_test.dart b/test/evolution_dashboard_truthful_status_test.dart index f81330a..d3b8ced 100644 --- a/test/evolution_dashboard_truthful_status_test.dart +++ b/test/evolution_dashboard_truthful_status_test.dart @@ -48,7 +48,7 @@ void main() { ); } - testWidgets('displays GPS 3D FIX when accuracy <= 15m and no fake placeholders', (tester) async { + testWidgets('displays GPS LOCKED when accuracy <= 15m and no fake placeholders', (tester) async { final sample = TelemetrySample( timestamp: DateTime.now(), gpsAccuracy: 8.5, @@ -58,7 +58,7 @@ void main() { await tester.pump(); // Truthful GPS - expect(find.text('GPS 3D FIX'), findsOneWidget); + expect(find.text('GPS LOCKED'), findsOneWidget); // Placeholders removed expect(find.text('FIXED'), findsNothing); @@ -75,7 +75,7 @@ void main() { expect(find.text('RECORDING'), findsNothing); }); - testWidgets('displays GPS 2D FIX when accuracy is 25m', (tester) async { + testWidgets('displays GPS FAIR when accuracy is 25m', (tester) async { final sample = TelemetrySample( timestamp: DateTime.now(), gpsAccuracy: 25.0, @@ -84,7 +84,7 @@ void main() { await tester.pumpWidget(buildDashboard(telemetry: sample)); await tester.pump(); - expect(find.text('GPS 2D FIX'), findsOneWidget); + expect(find.text('GPS FAIR'), findsOneWidget); expect(find.text('HEADSET'), findsNothing); expect(find.text('92%'), findsNothing); }); @@ -102,6 +102,19 @@ void main() { expect(find.text('HEADSET'), findsNothing); }); + testWidgets('displays GPS STALE when accuracy exists but fix is older than staleness bound', (tester) async { + final sample = TelemetrySample( + timestamp: DateTime.now(), + gpsAccuracy: 8.5, + gpsAgeMs: 5000, + ); + + await tester.pumpWidget(buildDashboard(telemetry: sample)); + await tester.pump(); + + expect(find.text('GPS STALE'), findsOneWidget); + }); + testWidgets('displays NO GPS when telemetry is null', (tester) async { await tester.pumpWidget(buildDashboard(telemetry: null)); await tester.pump(); diff --git a/test/rid3c2_truthful_recording_ui_test.dart b/test/rid3c2_truthful_recording_ui_test.dart index c65c128..d063544 100644 --- a/test/rid3c2_truthful_recording_ui_test.dart +++ b/test/rid3c2_truthful_recording_ui_test.dart @@ -268,7 +268,7 @@ void main() { heading: 90.0, ), ); - expect(find.text('GPS 3D FIX'), findsOneWidget, + expect(find.text('GPS LOCKED'), findsOneWidget, reason: 'premise: the location stack really is healthy'); expectStatusLabel('STANDBY'); expectNoRecordingClaim(); diff --git a/test/road_alert_diagnostics_responsive_test.dart b/test/road_alert_diagnostics_responsive_test.dart new file mode 100644 index 0000000..974e071 --- /dev/null +++ b/test/road_alert_diagnostics_responsive_test.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:motorcycle_dash/core/theme/theme.dart'; +import 'package:motorcycle_dash/features/map/widgets/road_alert_diagnostics_sheet.dart'; +import 'package:motorcycle_dash/services/alerts/road_alert_models.dart'; +import 'package:motorcycle_dash/services/alerts/road_alert_providers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Widget buildTestDiagnosticsSheet({ + required RoadAlertDiagnostics diagnostics, + }) { + return ProviderScope( + overrides: [ + roadAlertDiagnosticsProvider.overrideWith( + (ref) => RoadAlertDiagnosticsNotifier()..updateDiagnostics(diagnostics), + ), + ], + child: MaterialApp( + theme: AppTheme.darkTheme, + home: Scaffold( + body: Center( + child: Builder( + builder: (context) => ElevatedButton( + key: const ValueKey('open_diagnostics_btn'), + onPressed: () => RoadAlertDiagnosticsSheet.show(context), + child: const Text('Open Diagnostics'), + ), + ), + ), + ), + ), + ); + } + + void configureViewport(WidgetTester tester, double width, double height) { + tester.view.physicalSize = Size(width, height); + tester.view.devicePixelRatio = 1.0; + } + + group('Road Alert Diagnostics Model & Sheet Responsive Tests', () { + final now = DateTime(2026, 10, 13, 14, 30, 0); + + final testDiagnostics = RoadAlertDiagnostics( + providerStatus: RoadAlertProviderStatus.connected, + isLive: true, + lastFetchTime: now, + lastSuccessfulFetchTime: now, + lastRequestedArea: '34.05° N, 84.40° W (25.0 km radius)', + rawReportsCount: 24, + normalizedAcceptedCount: 18, + displayedOnMapCount: 14, + dashboardEligibleCount: 3, + audioCandidateCount: 1, + activeRouteRelevance: true, + locationFreshnessState: 'fresh', + lastLocationAgeSeconds: 0.8, + locationAccuracyMeters: 8.5, + directionRejectedCount: 4, + distanceRejectedCount: 7, + staleGpsRejectedCount: 0, + lastDashboardAlertPresented: 'Police • 0.4 mi Ahead', + lastAudioWarningIssued: 'Police • 250 ft Ahead', + rejectedReasons: { + 'stale_or_expired': 2, + 'category_disabled': 1, + 'out_of_tactical_range': 3, + }, + ); + + test('RoadAlertDiagnostics correctly models all decoupled pipeline and GPS fields', () { + expect(testDiagnostics.dashboardEligibleCount, equals(3)); + expect(testDiagnostics.audioCandidateCount, equals(1)); + expect(testDiagnostics.activeRouteRelevance, isTrue); + expect(testDiagnostics.locationFreshnessState, equals('fresh')); + expect(testDiagnostics.lastLocationAgeSeconds, equals(0.8)); + expect(testDiagnostics.locationAccuracyMeters, equals(8.5)); + expect(testDiagnostics.directionRejectedCount, equals(4)); + expect(testDiagnostics.distanceRejectedCount, equals(7)); + expect(testDiagnostics.staleGpsRejectedCount, equals(0)); + expect(testDiagnostics.lastDashboardAlertPresented, equals('Police • 0.4 mi Ahead')); + expect(testDiagnostics.lastAudioWarningIssued, equals('Police • 250 ft Ahead')); + }); + + testWidgets('Renders cleanly on 400x800 portrait phone with zero overflow', (tester) async { + configureViewport(tester, 400, 800); + addTearDown(() => tester.view.resetPhysicalSize()); + + await tester.pumpWidget(buildTestDiagnosticsSheet(diagnostics: testDiagnostics)); + await tester.tap(find.byKey(const ValueKey('open_diagnostics_btn'))); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('ROAD ALERT DIAGNOSTICS'), findsOneWidget); + expect(find.text('PIPELINE STAGE METRICS'), findsOneWidget); + + // Verify stage metrics grid tiles + expect(find.text('Raw Upstream'), findsOneWidget); + expect(find.text('Normalized'), findsOneWidget); + expect(find.text('Tactical (Map)'), findsOneWidget); + expect(find.text('Dashboard (2mi)'), findsOneWidget); + expect(find.text('Audio (400m)'), findsOneWidget); + + // Verify metadata rows + expect(find.text('GPS Quality'), findsOneWidget); + expect(find.text('Corridor Filter Mode'), findsOneWidget); + expect(find.text('Last Dashboard Alert'), findsOneWidget); + expect(find.text('Last Audio Warning'), findsOneWidget); + expect(find.text('Distance Exceeds Dashboard (>2.0mi)'), findsOneWidget); + expect(find.text('Behind Rider / Off Route'), findsOneWidget); + }); + + testWidgets('Renders cleanly on 840x390 landscape phone with zero overflow', (tester) async { + configureViewport(tester, 840, 390); + addTearDown(() => tester.view.resetPhysicalSize()); + + await tester.pumpWidget(buildTestDiagnosticsSheet(diagnostics: testDiagnostics)); + await tester.tap(find.byKey(const ValueKey('open_diagnostics_btn'))); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('ROAD ALERT DIAGNOSTICS'), findsOneWidget); + expect(find.text('Dashboard (2mi)'), findsOneWidget); + expect(find.text('Audio (400m)'), findsOneWidget); + }); + }); +} diff --git a/test/road_alert_presentation_policy_test.dart b/test/road_alert_presentation_policy_test.dart new file mode 100644 index 0000000..ae78deb --- /dev/null +++ b/test/road_alert_presentation_policy_test.dart @@ -0,0 +1,271 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:motorcycle_dash/services/alerts/road_alert_models.dart'; +import 'package:motorcycle_dash/services/alerts/road_alert_presentation_policy.dart'; +import 'package:motorcycle_dash/services/map/map_provider_interface.dart'; +import 'package:motorcycle_dash/services/navigation/navigation_session_service.dart'; +import 'package:motorcycle_dash/services/routes/route_provider.dart'; + +void main() { + group('RoadAlertPresentationPolicy Unit Tests', () { + final now = DateTime(2026, 10, 13, 12, 0, 0); + + Position createPosition({ + double lat = 34.0500, + double lng = -84.4000, + double accuracy = 10.0, + double speed = 15.0, // m/s (~33.5 MPH) + double heading = 0.0, // North + DateTime? timestamp, + }) { + return Position( + latitude: lat, + longitude: lng, + timestamp: timestamp ?? now, + accuracy: accuracy, + altitude: 300.0, + altitudeAccuracy: 5.0, + heading: heading, + headingAccuracy: 5.0, + speed: speed, + speedAccuracy: 0.5, + ); + } + + RoadAlert createAlert({ + String id = 'alert_1', + RoadAlertCategory category = RoadAlertCategory.police, + double lat = 34.0600, + double lng = -84.4000, + double? distance, + DateTime? expiresAt, + }) { + return RoadAlert( + id: id, + providerId: 'waze_zyla', + category: category, + latitude: lat, + longitude: lng, + description: 'Speed Trap', + roadName: 'Main Highway', + reportedAt: now.subtract(const Duration(minutes: 5)), + expiresAt: expiresAt ?? now.add(const Duration(minutes: 25)), + lastUpdated: now.subtract(const Duration(minutes: 1)), + distanceFromUser: distance, + source: 'Waze', + sourceType: RoadAlertSourceType.providerReported, + ); + } + + test('isLocationFreshAndAccurate validates freshness and accuracy thresholds', () { + final fresh = createPosition(timestamp: now.subtract(const Duration(seconds: 4)), accuracy: 15.0); + expect(RoadAlertPresentationPolicy.isLocationFreshAndAccurate(position: fresh, now: now), isTrue); + + final stale = createPosition(timestamp: now.subtract(const Duration(seconds: 15)), accuracy: 15.0); + expect(RoadAlertPresentationPolicy.isLocationFreshAndAccurate(position: stale, now: now), isFalse); + + final inaccurate = createPosition(timestamp: now, accuracy: 80.0); + expect(RoadAlertPresentationPolicy.isLocationFreshAndAccurate(position: inaccurate, now: now), isFalse); + + expect(RoadAlertPresentationPolicy.isLocationFreshAndAccurate(position: null, now: now), isFalse); + }); + + test('calculateBearingDelta computes angular delta correctly', () { + final deltaNorth = RoadAlertPresentationPolicy.calculateBearingDelta( + riderHeading: 0.0, + userLat: 34.0500, + userLng: -84.4000, + alertLat: 34.0600, + alertLng: -84.4000, + ); + expect(deltaNorth, lessThan(1.0)); + + final deltaOpposite = RoadAlertPresentationPolicy.calculateBearingDelta( + riderHeading: 180.0, + userLat: 34.0500, + userLng: -84.4000, + alertLat: 34.0600, + alertLng: -84.4000, + ); + expect((deltaOpposite - 180.0).abs(), lessThan(1.0)); + }); + + test('evaluateDirectionAndRoute handles stationary vs moving rider without route', () { + final alertNorth = createAlert(lat: 34.0600, lng: -84.4000); + + // Stationary (speed = 0.5 m/s < 2.0 m/s): cannot claim Ahead + final stationaryPos = createPosition(speed: 0.5, heading: 0.0); + final stationaryResult = RoadAlertPresentationPolicy.evaluateDirectionAndRoute( + userPosition: stationaryPos, + alert: alertNorth, + navSession: null, + ); + expect(stationaryResult.isAhead, isFalse); + + // Moving North (heading = 0.0, speed = 15 m/s): alert North is Ahead + final movingNorthPos = createPosition(speed: 15.0, heading: 0.0); + final movingNorthResult = RoadAlertPresentationPolicy.evaluateDirectionAndRoute( + userPosition: movingNorthPos, + alert: alertNorth, + navSession: null, + ); + expect(movingNorthResult.isAhead, isTrue); + + // Moving South (heading = 180.0): alert North is behind rider + final movingSouthPos = createPosition(speed: 15.0, heading: 180.0); + final movingSouthResult = RoadAlertPresentationPolicy.evaluateDirectionAndRoute( + userPosition: movingSouthPos, + alert: alertNorth, + navSession: null, + ); + expect(movingSouthResult.isAhead, isFalse); + }); + + test('evaluateDirectionAndRoute respects active route corridor', () { + final userPos = createPosition(lat: 34.0500, lng: -84.4000, heading: 0.0, speed: 15.0); + + final routeGeometry = [ + AppLatLng(34.0500, -84.4000), + AppLatLng(34.0600, -84.4000), + AppLatLng(34.0700, -84.4000), + AppLatLng(34.0800, -84.4000), + ]; + + final navSession = NavigationSessionState( + status: NavigationStatus.navigating, + calculationResult: RouteCalculationResult( + straightLineDistanceMeters: 3330.0, + geometry: routeGeometry, + isStraightLine: false, + providerName: 'test', + ), + ); + + // Alert 1: Directly on route corridor ahead at 34.0650 + final onRouteAlert = createAlert(id: 'on_route', lat: 34.0650, lng: -84.4000); + final onRouteResult = RoadAlertPresentationPolicy.evaluateDirectionAndRoute( + userPosition: userPos, + alert: onRouteAlert, + navSession: navSession, + ); + expect(onRouteResult.isAhead, isTrue); + expect(onRouteResult.onRoute, isTrue); + + // Alert 2: 1 km off the route corridor to the East (-84.3890) + final offRouteAlert = createAlert(id: 'off_route', lat: 34.0650, lng: -84.3890); + final offRouteResult = RoadAlertPresentationPolicy.evaluateDirectionAndRoute( + userPosition: userPos, + alert: offRouteAlert, + navSession: navSession, + ); + expect(offRouteResult.isAhead, isFalse); + expect(offRouteResult.onRoute, isFalse); + + // Alert 3: Along the route corridor line but behind the user (South at 34.0400) + final behindRouteAlert = createAlert(id: 'behind_route', lat: 34.0400, lng: -84.4000); + final behindRouteResult = RoadAlertPresentationPolicy.evaluateDirectionAndRoute( + userPosition: userPos, + alert: behindRouteAlert, + navSession: navSession, + ); + expect(behindRouteResult.isAhead, isFalse); + }); + + test('evaluateForDashboard enforces decoupled tactical (25 km) vs dashboard (2.0 mi) thresholds', () { + final userPos = createPosition(lat: 34.0500, lng: -84.4000, heading: 0.0, speed: 15.0); + final enabledCategories = Set.from(RoadAlertCategory.values); + + // Alert A: 1200 meters (~0.75 mi) North ahead -> Tactical & Dashboard eligible + final closeAlert = createAlert(id: 'close', lat: 34.0608, lng: -84.4000); + final closeEval = RoadAlertPresentationPolicy.evaluateForDashboard( + alert: closeAlert, + userPosition: userPos, + navSession: null, + dismissedIds: {}, + enabledCategories: enabledCategories, + now: now, + ); + expect(closeEval.isTacticalMapEligible, isTrue); + expect(closeEval.isDashboardEligible, isTrue); + expect(closeEval.dashboardRejectionReason, AlertRejectionReason.none); + + // Alert B: 6000 meters (~3.7 mi) North ahead -> Tactical eligible (<= 25 km), Dashboard REJECTED (> 2.0 mi) + final midAlert = createAlert(id: 'mid', lat: 34.1040, lng: -84.4000); + final midEval = RoadAlertPresentationPolicy.evaluateForDashboard( + alert: midAlert, + userPosition: userPos, + navSession: null, + dismissedIds: {}, + enabledCategories: enabledCategories, + now: now, + ); + expect(midEval.isTacticalMapEligible, isTrue); + expect(midEval.isDashboardEligible, isFalse); + expect(midEval.dashboardRejectionReason, AlertRejectionReason.distanceExceedsDashboardThreshold); + + // Alert C: 30,000 meters (~18.6 mi) -> Both Tactical and Dashboard REJECTED (> 25 km) + final farAlert = createAlert(id: 'far', lat: 34.3200, lng: -84.4000); + final farEval = RoadAlertPresentationPolicy.evaluateForDashboard( + alert: farAlert, + userPosition: userPos, + navSession: null, + dismissedIds: {}, + enabledCategories: enabledCategories, + now: now, + ); + expect(farEval.isTacticalMapEligible, isFalse); + expect(farEval.isDashboardEligible, isFalse); + expect(farEval.dashboardRejectionReason, AlertRejectionReason.outOfTacticalRange); + }); + + test('evaluateForDashboard rejects stale GPS fixes with staleOrMissingGps', () { + final stalePos = createPosition( + lat: 34.0500, + lng: -84.4000, + timestamp: now.subtract(const Duration(seconds: 20)), + ); + final alert = createAlert(lat: 34.0600, lng: -84.4000); + + final eval = RoadAlertPresentationPolicy.evaluateForDashboard( + alert: alert, + userPosition: stalePos, + navSession: null, + dismissedIds: {}, + enabledCategories: Set.from(RoadAlertCategory.values), + now: now, + ); + expect(eval.isDashboardEligible, isFalse); + expect(eval.dashboardRejectionReason, AlertRejectionReason.staleOrMissingGps); + }); + + test('evaluateForAudio identifies candidate within 400m and ahead', () { + final userPos = createPosition(lat: 34.0500, lng: -84.4000, heading: 0.0, speed: 15.0); + + // Alert 250 meters ahead + final nearAlert = createAlert(id: 'near', lat: 34.0522, lng: -84.4000); + final nearAudio = RoadAlertPresentationPolicy.evaluateForAudio( + alert: nearAlert, + userPosition: userPos, + navSession: null, + dismissedIds: {}, + enabledCategories: Set.from(RoadAlertCategory.values), + audioRadiusMeters: 400.0, + now: now, + ); + expect(nearAudio.isAudioEligible, isTrue); + + // Alert 1200 meters ahead (dashboard eligible, but outside 400m audio window) + final farAlert = createAlert(id: 'far_audio', lat: 34.0608, lng: -84.4000); + final farAudio = RoadAlertPresentationPolicy.evaluateForAudio( + alert: farAlert, + userPosition: userPos, + navSession: null, + dismissedIds: {}, + enabledCategories: Set.from(RoadAlertCategory.values), + audioRadiusMeters: 400.0, + now: now, + ); + expect(farAudio.isAudioEligible, isFalse); + }); + }); +} diff --git a/test/truthful_heading_location_test.dart b/test/truthful_heading_location_test.dart index 9d40dbe..5bf450d 100644 --- a/test/truthful_heading_location_test.dart +++ b/test/truthful_heading_location_test.dart @@ -480,7 +480,7 @@ void main() { ); } - // 1. Fresh fix with accuracy = 5.0 -> GPS 3D FIX + // 1. Fresh fix with accuracy = 5.0 -> GPS LOCKED await tester.pumpWidget(buildGpsWidget(stream: testStreamController.stream)); testStreamController.add(TelemetrySample( timestamp: now, @@ -491,9 +491,9 @@ void main() { gpsAgeMs: 50, )); await tester.pumpAndSettle(); - expect(find.text('GPS 3D FIX'), findsOneWidget); + expect(find.text('GPS LOCKED'), findsOneWidget); - // 2. Stale fix with accuracy = 5.0 (> 4000ms) -> NO GPS + // 2. Stale fix with accuracy = 5.0 (> 4000ms) -> GPS STALE testStreamController.add(TelemetrySample( timestamp: now.add(const Duration(seconds: 5)), latitude: 34.0, @@ -503,8 +503,8 @@ void main() { gpsAgeMs: 5000, // STALE )); await tester.pumpAndSettle(); - expect(find.text('NO GPS'), findsOneWidget); - expect(find.byIcon(Icons.gps_off), findsOneWidget); + expect(find.text('GPS STALE'), findsOneWidget); + expect(find.byIcon(Icons.gps_not_fixed), findsOneWidget); }); }); }