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 hasLocPermissionAsync = ref.watch(locationPermissionGrantedProvider);
|
||||||
final hasLocPermission = hasLocPermissionAsync.value ?? true;
|
final hasLocPermission = hasLocPermissionAsync.value ?? true;
|
||||||
|
|
||||||
final alertsAsync = ref.watch(activeAlertsStreamProvider);
|
final dashboardAlerts = ref.watch(dashboardAlertsStreamProvider).value;
|
||||||
final activeAlerts = alertsAsync.value ?? [];
|
final fallbackAlerts = ref.watch(activeAlertsStreamProvider).value;
|
||||||
final closestAlert = activeAlerts.isNotEmpty ? activeAlerts.first : null;
|
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 activeGroupRide = ref.watch(activeUserGroupRideProvider);
|
||||||
|
final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (!hasLocPermission) _buildPermissionBanner(context, ref),
|
if (!hasLocPermission) _buildPermissionBanner(context, ref, isLandscape: isLandscape),
|
||||||
const NavigationManeuverCard(),
|
if (!isLandscape) const NavigationManeuverCard(),
|
||||||
if (closestAlert != null) _buildRoadAlertBanner(context, closestAlert, ref),
|
if (closestAlert != null) _buildRoadAlertBanner(context, closestAlert, ref, isLandscape: isLandscape),
|
||||||
if (activeGroupRide != null) _buildGroupRideBanner(context, activeGroupRide),
|
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(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
color: AppColors.darkAmber,
|
color: AppColors.darkAmber,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: isLandscape ? 4 : 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.location_off, color: Colors.white, size: 20),
|
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 isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
final isSwarm = alert.providerId == 'swarm_alerts' || alert.providerId == 'swarm';
|
final isSwarm = alert.providerId == 'swarm_alerts' || alert.providerId == 'swarm';
|
||||||
final subtitleColor = isDark ? Colors.white70 : Theme.of(context).colorScheme.onSurfaceVariant;
|
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(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
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(
|
return InkWell(
|
||||||
onTap: () => context.push('/group-rides/${ride.id}/live'),
|
onTap: () => context.push('/group-rides/${ride.id}/live'),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: isLandscape ? 4 : 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.cyberCyan.withValues(alpha: 0.15),
|
color: AppColors.cyberCyan.withValues(alpha: 0.15),
|
||||||
border: const Border(
|
border: const Border(
|
||||||
|
|
|
||||||
|
|
@ -85,24 +85,35 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
|
||||||
final totalG = math.sqrt(accelG * accelG + lateralG * lateralG);
|
final totalG = math.sqrt(accelG * accelG + lateralG * lateralG);
|
||||||
|
|
||||||
final gpsAcc = telemetry?.gpsAccuracy;
|
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 IconData gpsIcon;
|
||||||
final Color gpsColor;
|
final Color gpsColor;
|
||||||
final String gpsText;
|
final String gpsText;
|
||||||
|
|
||||||
if (gpsAcc != null) {
|
if (gpsAcc != null && !isStale) {
|
||||||
if (gpsAcc <= 15.0) {
|
if (gpsAcc <= 15.0) {
|
||||||
gpsIcon = Icons.gps_fixed;
|
gpsIcon = Icons.gps_fixed;
|
||||||
gpsColor = const Color(0xFF00E5FF);
|
gpsColor = const Color(0xFF00E5FF);
|
||||||
gpsText = 'GPS 3D FIX';
|
gpsText = 'GPS LOCKED';
|
||||||
} else if (gpsAcc <= 35.0) {
|
} else if (gpsAcc <= 35.0) {
|
||||||
gpsIcon = Icons.gps_fixed;
|
gpsIcon = Icons.gps_fixed;
|
||||||
gpsColor = Colors.amberAccent;
|
gpsColor = Colors.amberAccent;
|
||||||
gpsText = 'GPS 2D FIX';
|
gpsText = 'GPS FAIR';
|
||||||
} else {
|
} else {
|
||||||
gpsIcon = Icons.gps_not_fixed;
|
gpsIcon = Icons.gps_not_fixed;
|
||||||
gpsColor = Colors.orangeAccent;
|
gpsColor = Colors.orangeAccent;
|
||||||
gpsText = 'GPS WEAK';
|
gpsText = 'GPS WEAK';
|
||||||
}
|
}
|
||||||
|
} else if (gpsAcc != null && isStale) {
|
||||||
|
gpsIcon = Icons.gps_not_fixed;
|
||||||
|
gpsColor = Colors.amberAccent;
|
||||||
|
gpsText = 'GPS STALE';
|
||||||
} else {
|
} else {
|
||||||
gpsIcon = Icons.gps_off;
|
gpsIcon = Icons.gps_off;
|
||||||
gpsColor = Colors.white38;
|
gpsColor = Colors.white38;
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,22 @@ class GPSStatusIndicator extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final (gpsAcc, isFresh) = ref.watch(
|
final (gpsAcc, isStale) = ref.watch(
|
||||||
telemetryStreamProvider.select(
|
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 Color color;
|
||||||
final String label;
|
final String label;
|
||||||
|
|
||||||
if (gpsAcc != null && isFresh) {
|
if (gpsAcc != null && !isStale) {
|
||||||
if (gpsAcc <= 15.0) {
|
if (gpsAcc <= 15.0) {
|
||||||
icon = Icons.gps_fixed;
|
icon = Icons.gps_fixed;
|
||||||
color = AppColors.telemetryGreen;
|
color = AppColors.telemetryGreen;
|
||||||
label = 'GPS 3D FIX';
|
label = 'GPS LOCKED';
|
||||||
} else if (gpsAcc <= 35.0) {
|
} else if (gpsAcc <= 35.0) {
|
||||||
icon = Icons.gps_fixed;
|
icon = Icons.gps_fixed;
|
||||||
color = AppColors.warningAmber;
|
color = AppColors.warningAmber;
|
||||||
label = 'GPS 2D FIX';
|
label = 'GPS FAIR';
|
||||||
} else {
|
} else {
|
||||||
icon = Icons.gps_not_fixed;
|
icon = Icons.gps_not_fixed;
|
||||||
color = Colors.orangeAccent;
|
color = Colors.orangeAccent;
|
||||||
label = 'GPS WEAK';
|
label = 'GPS WEAK';
|
||||||
}
|
}
|
||||||
|
} else if (gpsAcc != null && isStale) {
|
||||||
|
icon = Icons.gps_not_fixed;
|
||||||
|
color = AppColors.warningAmber;
|
||||||
|
label = 'GPS STALE';
|
||||||
} else {
|
} else {
|
||||||
icon = Icons.gps_off;
|
icon = Icons.gps_off;
|
||||||
color = theme.colorScheme.onSurfaceVariant;
|
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
|
/// maneuver direction, next road, countdown distance in feet/miles, ETA, and
|
||||||
/// quick mute/end controls.
|
/// quick mute/end controls.
|
||||||
class NavigationManeuverCard extends ConsumerWidget {
|
class NavigationManeuverCard extends ConsumerWidget {
|
||||||
const NavigationManeuverCard({super.key});
|
final bool compact;
|
||||||
|
|
||||||
|
const NavigationManeuverCard({super.key, this.compact = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
|
@ -23,6 +25,78 @@ class NavigationManeuverCard extends ConsumerWidget {
|
||||||
final nextManeuver = navState.nextManeuver;
|
final nextManeuver = navState.nextManeuver;
|
||||||
final colors = context.motorColors;
|
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(
|
return Semantics(
|
||||||
label: 'Navigation: ${currentManeuver.instruction}. ${navState.formattedDistanceToNext} to turn. Remaining: ${navState.formattedRemainingDistance}, ETA ${navState.formattedEta}.',
|
label: 'Navigation: ${currentManeuver.instruction}. ${navState.formattedDistanceToNext} to turn. Remaining: ${navState.formattedRemainingDistance}, ETA ${navState.formattedEta}.',
|
||||||
child: Card(
|
child: Card(
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import 'gps_status_indicator.dart';
|
||||||
import 'heading_indicator.dart';
|
import 'heading_indicator.dart';
|
||||||
import 'lean_angle_indicator.dart';
|
import 'lean_angle_indicator.dart';
|
||||||
import 'metric_tile.dart';
|
import 'metric_tile.dart';
|
||||||
|
import 'navigation_maneuver_card.dart';
|
||||||
import 'performance_summary.dart';
|
import 'performance_summary.dart';
|
||||||
import 'ride_action_button.dart';
|
import 'ride_action_button.dart';
|
||||||
import 'ride_status_chip.dart';
|
import 'ride_status_chip.dart';
|
||||||
|
|
@ -38,10 +39,16 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
|
||||||
final session = ref.watch(rideStateMachineProvider);
|
final session = ref.watch(rideStateMachineProvider);
|
||||||
final snapshot = context.responsive;
|
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(
|
return Padding(
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.only(
|
||||||
horizontal: snapshot.isShortViewport ? 10.0 : 16.0,
|
left: leftPad,
|
||||||
vertical: snapshot.isShortViewport ? 6.0 : 12.0,
|
right: rightPad,
|
||||||
|
top: vertPad,
|
||||||
|
bottom: vertPad,
|
||||||
),
|
),
|
||||||
child: snapshot.isLandscape
|
child: snapshot.isLandscape
|
||||||
? (snapshot.isShortViewport
|
? (snapshot.isShortViewport
|
||||||
|
|
@ -400,42 +407,52 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
|
||||||
// Default: Sport Cockpit Horizontal
|
// Default: Sport Cockpit Horizontal
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
// Left Wing: G-Force + Trip Stats
|
// Left Column: G-Force + Trip Statistics
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 28,
|
flex: 29,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
const GForceIndicator(compact: true),
|
const GForceIndicator(compact: true),
|
||||||
const SizedBox(height: 4),
|
HybridCard(
|
||||||
FittedBox(
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||||
fit: BoxFit.scaleDown,
|
child: FittedBox(
|
||||||
child: Row(
|
fit: BoxFit.scaleDown,
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Row(
|
||||||
children: [
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
MetricTile(
|
children: [
|
||||||
label: 'TRIP',
|
MetricTile(
|
||||||
value: session.tripDistanceMiles.toStringAsFixed(1),
|
label: 'TRIP',
|
||||||
unit: 'MI',
|
value: session.tripDistanceMiles.toStringAsFixed(1),
|
||||||
compact: true,
|
unit: 'MI',
|
||||||
),
|
compact: true,
|
||||||
const SizedBox(width: 8),
|
),
|
||||||
MetricTile(
|
const SizedBox(width: 8),
|
||||||
label: 'TOP',
|
MetricTile(
|
||||||
value: session.topSpeedMph.toStringAsFixed(0),
|
label: 'TOP',
|
||||||
unit: 'MPH',
|
value: session.topSpeedMph.toStringAsFixed(0),
|
||||||
compact: true,
|
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(
|
Expanded(
|
||||||
flex: 44,
|
flex: 42,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -446,7 +463,7 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
|
||||||
children: [
|
children: [
|
||||||
DashboardLayoutSwitcher(),
|
DashboardLayoutSwitcher(),
|
||||||
SizedBox(width: 4),
|
SizedBox(width: 4),
|
||||||
GPSStatusIndicator(showLabel: false),
|
GPSStatusIndicator(),
|
||||||
SizedBox(width: 4),
|
SizedBox(width: 4),
|
||||||
HeadingIndicator(compact: true),
|
HeadingIndicator(compact: true),
|
||||||
SizedBox(width: 4),
|
SizedBox(width: 4),
|
||||||
|
|
@ -454,36 +471,46 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const NavigationManeuverCard(compact: true),
|
||||||
const Center(child: SpeedReadout(size: SpeedReadoutSize.compact)),
|
const Center(child: SpeedReadout(size: SpeedReadoutSize.compact)),
|
||||||
const RideActionButton(compact: true),
|
const RideActionButton(compact: true),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 4),
|
||||||
const PerformanceLaunchShortcut(compact: true),
|
const PerformanceLaunchShortcut(compact: true),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
|
||||||
// Right Wing: Lean Angle + Avg Speed
|
// Right Column: Lean-Angle Telemetry + Dynamics
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 28,
|
flex: 29,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
const LeanAngleIndicator(compact: true),
|
const LeanAngleIndicator(compact: true),
|
||||||
const SizedBox(height: 4),
|
HybridCard(
|
||||||
FittedBox(
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||||
fit: BoxFit.scaleDown,
|
child: FittedBox(
|
||||||
child: Row(
|
fit: BoxFit.scaleDown,
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Row(
|
||||||
children: [
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
MetricTile(
|
children: [
|
||||||
label: 'AVG',
|
MetricTile(
|
||||||
value: session.averageSpeedMph.toStringAsFixed(0),
|
label: 'MOVING',
|
||||||
unit: 'MPH',
|
value: '${(session.movingTimeSeconds / 60).floor()}m',
|
||||||
compact: true,
|
compact: true,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const PerformanceSummary(compact: true),
|
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(
|
Center(
|
||||||
child: Transform.translate(
|
child: Transform.translate(
|
||||||
offset: Offset(0, -20),
|
offset: Offset(0, -20),
|
||||||
|
|
|
||||||
|
|
@ -121,14 +121,17 @@ class _RoadAlertDiagnosticsSheetState
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Expanded(
|
||||||
'ROAD ALERT DIAGNOSTICS',
|
child: Text(
|
||||||
style: TextStyle(
|
'ROAD ALERT DIAGNOSTICS',
|
||||||
fontFamily: 'Inter',
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontFamily: 'Inter',
|
||||||
fontWeight: FontWeight.w800,
|
fontSize: 14,
|
||||||
letterSpacing: 0.8,
|
fontWeight: FontWeight.w800,
|
||||||
color: theme.colorScheme.onSurface,
|
letterSpacing: 0.8,
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
@ -141,6 +144,7 @@ class _RoadAlertDiagnosticsSheetState
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -422,6 +426,9 @@ class _RoadAlertDiagnosticsSheetState
|
||||||
final lastSuccessStr = diag.lastSuccessfulFetchTime != null
|
final lastSuccessStr = diag.lastSuccessfulFetchTime != null
|
||||||
? DateFormat('HH:mm:ss').format(diag.lastSuccessfulFetchTime!)
|
? DateFormat('HH:mm:ss').format(diag.lastSuccessfulFetchTime!)
|
||||||
: 'Never';
|
: '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(
|
return Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
|
|
@ -438,9 +445,35 @@ class _RoadAlertDiagnosticsSheetState
|
||||||
theme,
|
theme,
|
||||||
),
|
),
|
||||||
const Divider(height: 14, thickness: 0.6),
|
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),
|
_buildInfoRow('Last Request Attempt', lastFetchStr, theme),
|
||||||
const Divider(height: 14, thickness: 0.6),
|
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,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
value,
|
value,
|
||||||
|
|
@ -474,44 +508,80 @@ class _RoadAlertDiagnosticsSheetState
|
||||||
|
|
||||||
Widget _buildStageMetricsGrid(
|
Widget _buildStageMetricsGrid(
|
||||||
RoadAlertDiagnostics diag, dynamic colors, ThemeData theme) {
|
RoadAlertDiagnostics diag, dynamic colors, ThemeData theme) {
|
||||||
return Row(
|
return LayoutBuilder(
|
||||||
children: [
|
builder: (context, constraints) {
|
||||||
Expanded(
|
final isNarrow = constraints.maxWidth < 500;
|
||||||
child: _buildMetricTile(
|
final tiles = [
|
||||||
|
_buildMetricTile(
|
||||||
label: 'Raw Upstream',
|
label: 'Raw Upstream',
|
||||||
value: diag.rawReportsCount.toString(),
|
value: diag.rawReportsCount.toString(),
|
||||||
color: colors.cyberCyan,
|
color: colors.cyberCyan,
|
||||||
icon: Icons.cloud_download_outlined,
|
icon: Icons.cloud_download_outlined,
|
||||||
),
|
),
|
||||||
),
|
_buildMetricTile(
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: _buildMetricTile(
|
|
||||||
label: 'Normalized',
|
label: 'Normalized',
|
||||||
value: diag.normalizedAcceptedCount.toString(),
|
value: diag.normalizedAcceptedCount.toString(),
|
||||||
color: Colors.lightBlueAccent,
|
color: Colors.lightBlueAccent,
|
||||||
icon: Icons.check_circle_outline,
|
icon: Icons.check_circle_outline,
|
||||||
),
|
),
|
||||||
),
|
_buildMetricTile(
|
||||||
const SizedBox(width: 8),
|
label: 'Tactical (Map)',
|
||||||
Expanded(
|
|
||||||
child: _buildMetricTile(
|
|
||||||
label: 'On Map HUD',
|
|
||||||
value: diag.displayedOnMapCount.toString(),
|
value: diag.displayedOnMapCount.toString(),
|
||||||
color: colors.launchGreen,
|
color: colors.launchGreen,
|
||||||
icon: Icons.map_outlined,
|
icon: Icons.map_outlined,
|
||||||
),
|
),
|
||||||
),
|
_buildMetricTile(
|
||||||
const SizedBox(width: 8),
|
label: 'Dashboard (2mi)',
|
||||||
Expanded(
|
value: diag.dashboardEligibleCount.toString(),
|
||||||
child: _buildMetricTile(
|
color: colors.brandCyan,
|
||||||
label: 'Audio Warning',
|
icon: Icons.dashboard_outlined,
|
||||||
|
),
|
||||||
|
_buildMetricTile(
|
||||||
|
label: 'Audio (400m)',
|
||||||
value: diag.audioCandidateCount.toString(),
|
value: diag.audioCandidateCount.toString(),
|
||||||
color: colors.warningAmber,
|
color: colors.warningAmber,
|
||||||
icon: Icons.volume_up_outlined,
|
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,
|
required IconData icon,
|
||||||
}) {
|
}) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color.withValues(alpha: 0.1),
|
color: color.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
|
@ -536,7 +606,7 @@ class _RoadAlertDiagnosticsSheetState
|
||||||
value,
|
value,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'JetBrains Mono',
|
fontFamily: 'JetBrains Mono',
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: color,
|
color: color,
|
||||||
),
|
),
|
||||||
|
|
@ -548,7 +618,7 @@ class _RoadAlertDiagnosticsSheetState
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 9.5,
|
fontSize: 9.0,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -574,16 +644,19 @@ class _RoadAlertDiagnosticsSheetState
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Expanded(
|
||||||
'FILTERED & REJECTED BREAKDOWN',
|
child: Text(
|
||||||
style: TextStyle(
|
'FILTERED & REJECTED BREAKDOWN',
|
||||||
fontFamily: 'Inter',
|
style: TextStyle(
|
||||||
fontSize: 10.5,
|
fontFamily: 'Inter',
|
||||||
fontWeight: FontWeight.w700,
|
fontSize: 10.5,
|
||||||
letterSpacing: 0.6,
|
fontWeight: FontWeight.w700,
|
||||||
color: colors.warningAmber,
|
letterSpacing: 0.6,
|
||||||
|
color: colors.warningAmber,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'Total: ${diag.totalRejectedCount}',
|
'Total: ${diag.totalRejectedCount}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
|
|
@ -595,7 +668,10 @@ class _RoadAlertDiagnosticsSheetState
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
if (reasons.isEmpty)
|
if (reasons.isEmpty &&
|
||||||
|
diag.distanceRejectedCount == 0 &&
|
||||||
|
diag.directionRejectedCount == 0 &&
|
||||||
|
diag.staleGpsRejectedCount == 0)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|
@ -611,6 +687,9 @@ class _RoadAlertDiagnosticsSheetState
|
||||||
_buildReasonRow('Stale or Expired TTL', reasons['stale_or_expired'] ?? 0, colors),
|
_buildReasonRow('Stale or Expired TTL', reasons['stale_or_expired'] ?? 0, colors),
|
||||||
_buildReasonRow('Category Filter Disabled', reasons['category_disabled'] ?? 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('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('Multi-Source Deduplicated', reasons['deduplicated'] ?? 0, colors),
|
||||||
_buildReasonRow('Jurisdiction Compliance Suppressed', reasons['compliance_suppressed'] ?? 0, colors),
|
_buildReasonRow('Jurisdiction Compliance Suppressed', reasons['compliance_suppressed'] ?? 0, colors),
|
||||||
_buildReasonRow('Manually Dismissed by Rider', reasons['dismissed'] ?? 0, colors),
|
_buildReasonRow('Manually Dismissed by Rider', reasons['dismissed'] ?? 0, colors),
|
||||||
|
|
@ -626,10 +705,14 @@ class _RoadAlertDiagnosticsSheetState
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Expanded(
|
||||||
title,
|
child: Text(
|
||||||
style: const TextStyle(fontSize: 11),
|
title,
|
||||||
|
style: const TextStyle(fontSize: 11),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import 'road_alert_models.dart';
|
||||||
|
|
||||||
/// Comprehensive diagnostics telemetry for the road alert pipeline.
|
/// Comprehensive diagnostics telemetry for the road alert pipeline.
|
||||||
/// Tracks upstream provider status, ingestion rates, stage-by-stage rejection reasons,
|
/// 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 {
|
class RoadAlertDiagnostics {
|
||||||
final RoadAlertProviderStatus providerStatus;
|
final RoadAlertProviderStatus providerStatus;
|
||||||
final String? statusMessage;
|
final String? statusMessage;
|
||||||
|
|
@ -15,7 +15,17 @@ class RoadAlertDiagnostics {
|
||||||
final int normalizedAcceptedCount;
|
final int normalizedAcceptedCount;
|
||||||
final Map<String, int> rejectedReasons;
|
final Map<String, int> rejectedReasons;
|
||||||
final int displayedOnMapCount;
|
final int displayedOnMapCount;
|
||||||
|
final int dashboardEligibleCount;
|
||||||
final int audioCandidateCount;
|
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;
|
final String? lastError;
|
||||||
|
|
||||||
const RoadAlertDiagnostics({
|
const RoadAlertDiagnostics({
|
||||||
|
|
@ -30,7 +40,17 @@ class RoadAlertDiagnostics {
|
||||||
this.normalizedAcceptedCount = 0,
|
this.normalizedAcceptedCount = 0,
|
||||||
this.rejectedReasons = const {},
|
this.rejectedReasons = const {},
|
||||||
this.displayedOnMapCount = 0,
|
this.displayedOnMapCount = 0,
|
||||||
|
this.dashboardEligibleCount = 0,
|
||||||
this.audioCandidateCount = 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,
|
this.lastError,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -58,7 +78,17 @@ class RoadAlertDiagnostics {
|
||||||
int? normalizedAcceptedCount,
|
int? normalizedAcceptedCount,
|
||||||
Map<String, int>? rejectedReasons,
|
Map<String, int>? rejectedReasons,
|
||||||
int? displayedOnMapCount,
|
int? displayedOnMapCount,
|
||||||
|
int? dashboardEligibleCount,
|
||||||
int? audioCandidateCount,
|
int? audioCandidateCount,
|
||||||
|
bool? activeRouteRelevance,
|
||||||
|
String? locationFreshnessState,
|
||||||
|
double? lastLocationAgeSeconds,
|
||||||
|
double? locationAccuracyMeters,
|
||||||
|
int? directionRejectedCount,
|
||||||
|
int? distanceRejectedCount,
|
||||||
|
int? staleGpsRejectedCount,
|
||||||
|
String? lastDashboardAlertPresented,
|
||||||
|
String? lastAudioWarningIssued,
|
||||||
String? lastError,
|
String? lastError,
|
||||||
}) {
|
}) {
|
||||||
return RoadAlertDiagnostics(
|
return RoadAlertDiagnostics(
|
||||||
|
|
@ -76,7 +106,26 @@ class RoadAlertDiagnostics {
|
||||||
normalizedAcceptedCount ?? this.normalizedAcceptedCount,
|
normalizedAcceptedCount ?? this.normalizedAcceptedCount,
|
||||||
rejectedReasons: rejectedReasons ?? this.rejectedReasons,
|
rejectedReasons: rejectedReasons ?? this.rejectedReasons,
|
||||||
displayedOnMapCount: displayedOnMapCount ?? this.displayedOnMapCount,
|
displayedOnMapCount: displayedOnMapCount ?? this.displayedOnMapCount,
|
||||||
|
dashboardEligibleCount:
|
||||||
|
dashboardEligibleCount ?? this.dashboardEligibleCount,
|
||||||
audioCandidateCount: audioCandidateCount ?? this.audioCandidateCount,
|
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,
|
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();
|
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
|
/// Live pipeline diagnostics state notifier
|
||||||
class RoadAlertDiagnosticsNotifier extends StateNotifier<RoadAlertDiagnostics> {
|
class RoadAlertDiagnosticsNotifier extends StateNotifier<RoadAlertDiagnostics> {
|
||||||
RoadAlertDiagnosticsNotifier() : super(const RoadAlertDiagnostics());
|
RoadAlertDiagnosticsNotifier() : super(const RoadAlertDiagnostics());
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import 'road_alert_models.dart';
|
||||||
import 'road_alert_providers.dart';
|
import 'road_alert_providers.dart';
|
||||||
import 'road_alert_compliance.dart';
|
import 'road_alert_compliance.dart';
|
||||||
import 'multi_source_alert_deduplicator.dart';
|
import 'multi_source_alert_deduplicator.dart';
|
||||||
|
import 'road_alert_presentation_policy.dart';
|
||||||
|
|
||||||
import '../field_test/field_test_logger.dart';
|
import '../field_test/field_test_logger.dart';
|
||||||
import '../../models/field_test_event.dart';
|
import '../../models/field_test_event.dart';
|
||||||
|
|
@ -31,10 +32,19 @@ final activeAlertsStreamProvider = StreamProvider<List<RoadAlert>>((ref) {
|
||||||
return ref.watch(roadAlertServiceProvider).alertsStream;
|
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 {
|
class RoadAlertService {
|
||||||
final Ref _ref;
|
final Ref _ref;
|
||||||
final FlutterTts _tts;
|
final FlutterTts _tts;
|
||||||
final _alertsController = BehaviorSubject<List<RoadAlert>>.seeded([]);
|
final _alertsController = BehaviorSubject<List<RoadAlert>>.seeded([]);
|
||||||
|
final _dashboardAlertsController = BehaviorSubject<List<RoadAlert>>.seeded([]);
|
||||||
|
|
||||||
StreamSubscription<Position>? _locationSub;
|
StreamSubscription<Position>? _locationSub;
|
||||||
StreamSubscription<double>? _leanSub;
|
StreamSubscription<double>? _leanSub;
|
||||||
|
|
@ -45,6 +55,7 @@ class RoadAlertService {
|
||||||
Position? _lastObservedPosition;
|
Position? _lastObservedPosition;
|
||||||
bool _isFetching = false; // Request serialization lock
|
bool _isFetching = false; // Request serialization lock
|
||||||
DateTime? _lastSpeechTime; // Audio cooldown timer
|
DateTime? _lastSpeechTime; // Audio cooldown timer
|
||||||
|
String? _lastAudioWarningIssued;
|
||||||
|
|
||||||
double _currentLeanAngle = 0.0;
|
double _currentLeanAngle = 0.0;
|
||||||
DateTime? _lastSteepLeanTime;
|
DateTime? _lastSteepLeanTime;
|
||||||
|
|
@ -57,6 +68,10 @@ class RoadAlertService {
|
||||||
|
|
||||||
Stream<List<RoadAlert>> get alertsStream => _alertsController.stream;
|
Stream<List<RoadAlert>> get alertsStream => _alertsController.stream;
|
||||||
List<RoadAlert> get cachedAlerts => List.unmodifiable(_cachedAlerts);
|
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;
|
double get currentLeanAngle => _currentLeanAngle;
|
||||||
bool get isCornering =>
|
bool get isCornering =>
|
||||||
_currentLeanAngle > 15.0 ||
|
_currentLeanAngle > 15.0 ||
|
||||||
|
|
@ -210,9 +225,10 @@ class RoadAlertService {
|
||||||
_lastObservedPosition = position;
|
_lastObservedPosition = position;
|
||||||
final isEnabled = _ref.read(roadAlertsEnabledProvider);
|
final isEnabled = _ref.read(roadAlertsEnabledProvider);
|
||||||
if (!isEnabled) {
|
if (!isEnabled) {
|
||||||
if (_cachedAlerts.isNotEmpty) {
|
if (_cachedAlerts.isNotEmpty || _dashboardAlertsController.value.isNotEmpty) {
|
||||||
_cachedAlerts = [];
|
_cachedAlerts = [];
|
||||||
_alertsController.add([]);
|
_alertsController.add([]);
|
||||||
|
_dashboardAlertsController.add([]);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -354,12 +370,83 @@ class RoadAlertService {
|
||||||
return (a.distanceFromUser ?? 0).compareTo(b.distanceFromUser ?? 0);
|
return (a.distanceFromUser ?? 0).compareTo(b.distanceFromUser ?? 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Scope A: Tactical Map Discovery (full horizon up to 25 km)
|
||||||
_cachedAlerts = compliantList;
|
_cachedAlerts = compliantList;
|
||||||
_alertsController.add(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 = [];
|
final List<RoadAlert> audioCandidates = [];
|
||||||
for (var updatedAlert in compliantList) {
|
for (var updatedAlert in dashboardEligibleAlerts) {
|
||||||
final key = '${updatedAlert.providerId}_${updatedAlert.id}';
|
final key = '${updatedAlert.providerId}_${updatedAlert.id}';
|
||||||
final dist = updatedAlert.distanceFromUser ?? 999999.0;
|
final dist = updatedAlert.distanceFromUser ?? 999999.0;
|
||||||
final approachThreshold = math.min(audioRadiusMeters, 400.0);
|
final approachThreshold = math.min(audioRadiusMeters, 400.0);
|
||||||
|
|
@ -390,6 +477,7 @@ class RoadAlertService {
|
||||||
if (_lastSpeechTime == null || speechTime.difference(_lastSpeechTime!).inSeconds >= 4) {
|
if (_lastSpeechTime == null || speechTime.difference(_lastSpeechTime!).inSeconds >= 4) {
|
||||||
_lastSpeechTime = speechTime;
|
_lastSpeechTime = speechTime;
|
||||||
_announcedAlertIds.add(key);
|
_announcedAlertIds.add(key);
|
||||||
|
_lastAudioWarningIssued = '${updatedAlert.category.displayName} • ${RoadAlertPresentationPolicy.formatDistance(dist, isAhead: true)}';
|
||||||
_announceAlertAudio(updatedAlert);
|
_announceAlertAudio(updatedAlert);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -416,6 +504,21 @@ class RoadAlertService {
|
||||||
'${position.longitude.abs().toStringAsFixed(2)}° $lngDir '
|
'${position.longitude.abs().toStringAsFixed(2)}° $lngDir '
|
||||||
'(${(tacticalRadiusMeters / 1000).toStringAsFixed(1)} km radius)';
|
'(${(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(
|
_ref.read(roadAlertDiagnosticsProvider.notifier).updateDiagnostics(
|
||||||
RoadAlertDiagnostics(
|
RoadAlertDiagnostics(
|
||||||
providerStatus: zylaProvider.lastStatus != RoadAlertProviderStatus.notConfigured
|
providerStatus: zylaProvider.lastStatus != RoadAlertProviderStatus.notConfigured
|
||||||
|
|
@ -432,7 +535,17 @@ class RoadAlertService {
|
||||||
normalizedAcceptedCount: _cachedAlerts.length + staleCount + disabledCategoryCount + outOfRangeCount,
|
normalizedAcceptedCount: _cachedAlerts.length + staleCount + disabledCategoryCount + outOfRangeCount,
|
||||||
rejectedReasons: rejectedReasons,
|
rejectedReasons: rejectedReasons,
|
||||||
displayedOnMapCount: compliantList.length,
|
displayedOnMapCount: compliantList.length,
|
||||||
|
dashboardEligibleCount: dashboardEligibleAlerts.length,
|
||||||
audioCandidateCount: audioCandidates.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,
|
lastError: zylaProvider.lastError,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -689,6 +802,7 @@ class RoadAlertService {
|
||||||
void clearAllAlerts() {
|
void clearAllAlerts() {
|
||||||
_cachedAlerts = [];
|
_cachedAlerts = [];
|
||||||
_alertsController.add([]);
|
_alertsController.add([]);
|
||||||
|
_dashboardAlertsController.add([]);
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[RoadAlerts] Cleared all cached alerts');
|
debugPrint('[RoadAlerts] Cleared all cached alerts');
|
||||||
}
|
}
|
||||||
|
|
@ -754,6 +868,7 @@ class RoadAlertService {
|
||||||
_locationSub?.cancel();
|
_locationSub?.cancel();
|
||||||
_leanSub?.cancel();
|
_leanSub?.cancel();
|
||||||
_alertsController.close();
|
_alertsController.close();
|
||||||
|
_dashboardAlertsController.close();
|
||||||
try {
|
try {
|
||||||
_tts.stop().catchError((_) => null);
|
_tts.stop().catchError((_) => null);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
@ -818,7 +933,7 @@ class RouteCorridorAlertFilter {
|
||||||
required double alertLng,
|
required double alertLng,
|
||||||
required List<AppLatLng> geometry,
|
required List<AppLatLng> geometry,
|
||||||
double maxCrossTrackMeters = 80.0,
|
double maxCrossTrackMeters = 80.0,
|
||||||
double maxAheadMeters = 1200.0,
|
double maxAheadMeters = 3218.688,
|
||||||
}) {
|
}) {
|
||||||
if (geometry.length < 2) return false;
|
if (geometry.length < 2) return false;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -237,7 +237,7 @@ void main() {
|
||||||
expect(find.text('68'), findsOneWidget);
|
expect(find.text('68'), findsOneWidget);
|
||||||
expect(find.text('W'), findsOneWidget);
|
expect(find.text('W'), findsOneWidget);
|
||||||
expect(find.text('270°'), 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('25°'), findsOneWidget);
|
||||||
expect(find.text('L: 0°'), findsOneWidget);
|
expect(find.text('L: 0°'), findsOneWidget);
|
||||||
expect(find.text('R: 25°'), 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(
|
final sample = TelemetrySample(
|
||||||
timestamp: DateTime.now(),
|
timestamp: DateTime.now(),
|
||||||
gpsAccuracy: 8.5,
|
gpsAccuracy: 8.5,
|
||||||
|
|
@ -58,7 +58,7 @@ void main() {
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Truthful GPS
|
// Truthful GPS
|
||||||
expect(find.text('GPS 3D FIX'), findsOneWidget);
|
expect(find.text('GPS LOCKED'), findsOneWidget);
|
||||||
|
|
||||||
// Placeholders removed
|
// Placeholders removed
|
||||||
expect(find.text('FIXED'), findsNothing);
|
expect(find.text('FIXED'), findsNothing);
|
||||||
|
|
@ -75,7 +75,7 @@ void main() {
|
||||||
expect(find.text('RECORDING'), findsNothing);
|
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(
|
final sample = TelemetrySample(
|
||||||
timestamp: DateTime.now(),
|
timestamp: DateTime.now(),
|
||||||
gpsAccuracy: 25.0,
|
gpsAccuracy: 25.0,
|
||||||
|
|
@ -84,7 +84,7 @@ void main() {
|
||||||
await tester.pumpWidget(buildDashboard(telemetry: sample));
|
await tester.pumpWidget(buildDashboard(telemetry: sample));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.text('GPS 2D FIX'), findsOneWidget);
|
expect(find.text('GPS FAIR'), findsOneWidget);
|
||||||
expect(find.text('HEADSET'), findsNothing);
|
expect(find.text('HEADSET'), findsNothing);
|
||||||
expect(find.text('92%'), findsNothing);
|
expect(find.text('92%'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
@ -102,6 +102,19 @@ void main() {
|
||||||
expect(find.text('HEADSET'), findsNothing);
|
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 {
|
testWidgets('displays NO GPS when telemetry is null', (tester) async {
|
||||||
await tester.pumpWidget(buildDashboard(telemetry: null));
|
await tester.pumpWidget(buildDashboard(telemetry: null));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
|
||||||
|
|
@ -268,7 +268,7 @@ void main() {
|
||||||
heading: 90.0,
|
heading: 90.0,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(find.text('GPS 3D FIX'), findsOneWidget,
|
expect(find.text('GPS LOCKED'), findsOneWidget,
|
||||||
reason: 'premise: the location stack really is healthy');
|
reason: 'premise: the location stack really is healthy');
|
||||||
expectStatusLabel('STANDBY');
|
expectStatusLabel('STANDBY');
|
||||||
expectNoRecordingClaim();
|
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));
|
await tester.pumpWidget(buildGpsWidget(stream: testStreamController.stream));
|
||||||
testStreamController.add(TelemetrySample(
|
testStreamController.add(TelemetrySample(
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
|
|
@ -491,9 +491,9 @@ void main() {
|
||||||
gpsAgeMs: 50,
|
gpsAgeMs: 50,
|
||||||
));
|
));
|
||||||
await tester.pumpAndSettle();
|
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(
|
testStreamController.add(TelemetrySample(
|
||||||
timestamp: now.add(const Duration(seconds: 5)),
|
timestamp: now.add(const Duration(seconds: 5)),
|
||||||
latitude: 34.0,
|
latitude: 34.0,
|
||||||
|
|
@ -503,8 +503,8 @@ void main() {
|
||||||
gpsAgeMs: 5000, // STALE
|
gpsAgeMs: 5000, // STALE
|
||||||
));
|
));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('NO GPS'), findsOneWidget);
|
expect(find.text('GPS STALE'), findsOneWidget);
|
||||||
expect(find.byIcon(Icons.gps_off), findsOneWidget);
|
expect(find.byIcon(Icons.gps_not_fixed), findsOneWidget);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue