355 lines
12 KiB
Dart
355 lines
12 KiB
Dart
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';
|
|
}
|
|
}
|
|
}
|