fix(ui): landscape dashboard 3-column layout, decoupled alert presentation policy, truthful GPS contract, and diagnostics HUD
This commit is contained in:
parent
ad99da10ba
commit
493192078f
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue