fix(ui): landscape dashboard 3-column layout, decoupled alert presentation policy, truthful GPS contract, and diagnostics HUD

This commit is contained in:
Developer 2026-10-13 18:11:47 -04:00
parent ad99da10ba
commit 493192078f
17 changed files with 1541 additions and 130 deletions

View File

@ -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(

View File

@ -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;

View File

@ -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;

View File

@ -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(

View File

@ -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),

View File

@ -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(

View File

@ -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<String, int> 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<String, int>? 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,
);
}

View File

@ -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<String> dismissedIds,
required Set<RoadAlertCategory> 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<String> dismissedIds,
required Set<RoadAlertCategory> 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';
}
}
}

View File

@ -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<double> {
static const double defaultRadius = 3218.688; // 2.0 miles
DashboardAlertRadiusMetersNotifier() : super(defaultRadius);
void setRadius(double radiusMeters) {
state = radiusMeters;
}
}
final dashboardAlertRadiusMetersProvider =
StateNotifierProvider<DashboardAlertRadiusMetersNotifier, double>((ref) {
return DashboardAlertRadiusMetersNotifier();
});
/// Live pipeline diagnostics state notifier
class RoadAlertDiagnosticsNotifier extends StateNotifier<RoadAlertDiagnostics> {
RoadAlertDiagnosticsNotifier() : super(const RoadAlertDiagnostics());

View File

@ -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<List<RoadAlert>>((ref) {
return ref.watch(roadAlertServiceProvider).alertsStream;
});
final dashboardAlertsStreamProvider = StreamProvider<List<RoadAlert>>((ref) {
return ref.watch(roadAlertServiceProvider).dashboardAlertsStream;
});
final dashboardBannerAlertProvider = Provider<RoadAlert?>((ref) {
return ref.watch(roadAlertServiceProvider).closestDashboardAlert;
});
class RoadAlertService {
final Ref _ref;
final FlutterTts _tts;
final _alertsController = BehaviorSubject<List<RoadAlert>>.seeded([]);
final _dashboardAlertsController = BehaviorSubject<List<RoadAlert>>.seeded([]);
StreamSubscription<Position>? _locationSub;
StreamSubscription<double>? _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<List<RoadAlert>> get alertsStream => _alertsController.stream;
List<RoadAlert> get cachedAlerts => List.unmodifiable(_cachedAlerts);
Stream<List<RoadAlert>> get dashboardAlertsStream => _dashboardAlertsController.stream;
List<RoadAlert> 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<RoadAlert> 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<RoadAlert> 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<AppLatLng> geometry,
double maxCrossTrackMeters = 80.0,
double maxAheadMeters = 1200.0,
double maxAheadMeters = 3218.688,
}) {
if (geometry.length < 2) return false;

View File

@ -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);

View File

@ -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<TelemetrySample> telemetryController;
late StreamController<List<RoadAlert>> alertsController;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
telemetryController = StreamController<TelemetrySample>.broadcast();
alertsController = StreamController<List<RoadAlert>>.broadcast();
});
tearDown(() async {
await telemetryController.close();
await alertsController.close();
});
Widget buildTestDashboard({
RideSession? initialSession,
List<RoadAlert>? 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<Padding>(find.descendant(
of: find.byType(ResponsiveDashboardLayout),
matching: find.byType(Padding),
));
final edgeInsets = paddingWidget.padding as EdgeInsets;
expect(edgeInsets.right, greaterThanOrEqualTo(16.0));
});
});
}

View File

@ -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();

View File

@ -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();

View File

@ -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);
});
});
}

View File

@ -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<RoadAlertCategory>.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);
});
});
}

View File

@ -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);
});
});
}