import 'package:geolocator/geolocator.dart'; import '../navigation/navigation_session_service.dart'; import 'road_alert_models.dart'; import 'road_alert_service.dart'; /// Rejection reason codes for Road Alert evaluation. enum AlertRejectionReason { none, expired, dismissed, categoryDisabled, staleOrMissingGps, outOfTacticalRange, distanceExceedsDashboardThreshold, behindRiderHeading, notOnActiveRoute, uncertainHeadingStationary, complianceSuppressed, deduplicated, } /// Structured evaluation decision for a road alert presentation candidate. class RoadAlertEvaluation { final bool isTacticalMapEligible; final bool isDashboardEligible; final bool isAudioEligible; final AlertRejectionReason dashboardRejectionReason; final double? distanceFromUserMeters; final bool isAhead; final String formattedDistanceText; const RoadAlertEvaluation({ required this.isTacticalMapEligible, required this.isDashboardEligible, required this.isAudioEligible, this.dashboardRejectionReason = AlertRejectionReason.none, this.distanceFromUserMeters, this.isAhead = false, this.formattedDistanceText = '', }); } /// Independent policy engine for road alert presentation across three decoupled scopes: /// Scope A: Tactical Map Discovery (default 25 km / ~15.5 mi) /// Scope B: Dashboard Banner (default 3218.7 m / ~2.0 mi, direction and route corridor aware) /// Scope C: Audible Warning (default 400 m, speed/lean aware, non-repetitive) abstract final class RoadAlertPresentationPolicy { /// Default threshold for immediate dashboard warnings (2.0 miles). static const double defaultDashboardRadiusMeters = 3218.688; /// Default threshold for immediate audible warnings (400 meters). static const double defaultAudioRadiusMeters = 400.0; /// Maximum GPS horizontal accuracy in meters before coordinates are deemed too uncertain for proximity warnings. static const double maxWarningGpsAccuracyMeters = 65.0; /// Maximum GPS fix age in seconds before coordinates are deemed stale for proximity warnings. static const int maxWarningGpsAgeSeconds = 10; /// Evaluates whether current GPS position satisfies the freshness and accuracy contract. static bool isLocationFreshAndAccurate({ required Position? position, DateTime? now, }) { if (position == null) return false; final currentTime = now ?? DateTime.now(); final ageMs = currentTime.difference(position.timestamp).inMilliseconds; if (ageMs > maxWarningGpsAgeSeconds * 1000 || ageMs < -2000) return false; if (position.accuracy > maxWarningGpsAccuracyMeters) return false; return true; } /// Calculates angular bearing delta (0° to 180°) between rider heading and bearing to alert. static double calculateBearingDelta({ required double riderHeading, required double userLat, required double userLng, required double alertLat, required double alertLng, }) { final bearingToAlert = Geolocator.bearingBetween( userLat, userLng, alertLat, alertLng, ); double delta = (bearingToAlert - riderHeading).abs(); if (delta > 180.0) delta = 360.0 - delta; return delta; } /// Evaluates whether an alert is along and ahead of the rider's travel direction or active route. static ({bool isAhead, bool onRoute}) evaluateDirectionAndRoute({ required Position userPosition, required RoadAlert alert, required NavigationSessionState? navSession, double dashboardRadiusMeters = defaultDashboardRadiusMeters, }) { final hasActiveRoute = navSession != null && navSession.isNavigating && navSession.calculationResult != null && navSession.calculationResult!.geometry.length >= 2; if (hasActiveRoute) { final geom = navSession.calculationResult!.geometry; final onRouteAhead = RouteCorridorAlertFilter.isAlertAheadOnRoute( userLat: userPosition.latitude, userLng: userPosition.longitude, alertLat: alert.latitude, alertLng: alert.longitude, geometry: geom, maxAheadMeters: dashboardRadiusMeters, maxCrossTrackMeters: 80.0, ); return (isAhead: onRouteAhead, onRoute: onRouteAhead); } // When no active route is present: evaluate bearing relative to motion heading final isMoving = userPosition.speed >= 2.0; // ~4.5 MPH motion threshold if (!isMoving) { // When stationary or creeping, heading is uncertain return (isAhead: false, onRoute: false); } final delta = calculateBearingDelta( riderHeading: userPosition.heading, userLat: userPosition.latitude, userLng: userPosition.longitude, alertLat: alert.latitude, alertLng: alert.longitude, ); // Alert is ahead within a 130° frontal arc (+/- 65°) final isAhead = delta <= 65.0; return (isAhead: isAhead, onRoute: false); } /// Evaluates whether an alert should be presented on the Dashboard Banner. static RoadAlertEvaluation evaluateForDashboard({ required RoadAlert alert, required Position? userPosition, required NavigationSessionState? navSession, required Set dismissedIds, required Set enabledCategories, double tacticalRadiusMeters = 25000.0, double dashboardRadiusMeters = defaultDashboardRadiusMeters, DateTime? now, }) { final currentTime = now ?? DateTime.now(); // 1. Basic lifecycle checks final alertKey = '${alert.providerId}_${alert.id}'; if (dismissedIds.contains(alert.id) || dismissedIds.contains(alertKey)) { return const RoadAlertEvaluation( isTacticalMapEligible: false, isDashboardEligible: false, isAudioEligible: false, dashboardRejectionReason: AlertRejectionReason.dismissed, ); } if (alert.expiresAt != null && alert.expiresAt!.isBefore(currentTime)) { return const RoadAlertEvaluation( isTacticalMapEligible: false, isDashboardEligible: false, isAudioEligible: false, dashboardRejectionReason: AlertRejectionReason.expired, ); } if (!enabledCategories.contains(alert.category)) { return const RoadAlertEvaluation( isTacticalMapEligible: false, isDashboardEligible: false, isAudioEligible: false, dashboardRejectionReason: AlertRejectionReason.categoryDisabled, ); } if (userPosition == null) { return const RoadAlertEvaluation( isTacticalMapEligible: true, isDashboardEligible: false, isAudioEligible: false, dashboardRejectionReason: AlertRejectionReason.staleOrMissingGps, ); } final dist = Geolocator.distanceBetween( userPosition.latitude, userPosition.longitude, alert.latitude, alert.longitude, ); final isTacticalEligible = dist <= tacticalRadiusMeters; // 2. Freshness & accuracy check final isGpsFresh = isLocationFreshAndAccurate(position: userPosition, now: currentTime); if (!isGpsFresh) { return RoadAlertEvaluation( isTacticalMapEligible: isTacticalEligible, isDashboardEligible: false, isAudioEligible: false, distanceFromUserMeters: dist, dashboardRejectionReason: AlertRejectionReason.staleOrMissingGps, formattedDistanceText: formatDistance(dist, isAhead: false), ); } // 3. Distance threshold check for tactical horizon (25 km) and dashboard (default 2 miles) if (dist > tacticalRadiusMeters) { return RoadAlertEvaluation( isTacticalMapEligible: false, isDashboardEligible: false, isAudioEligible: false, distanceFromUserMeters: dist, dashboardRejectionReason: AlertRejectionReason.outOfTacticalRange, formattedDistanceText: formatDistance(dist, isAhead: false), ); } if (dist > dashboardRadiusMeters) { return RoadAlertEvaluation( isTacticalMapEligible: isTacticalEligible, isDashboardEligible: false, isAudioEligible: false, distanceFromUserMeters: dist, dashboardRejectionReason: AlertRejectionReason.distanceExceedsDashboardThreshold, formattedDistanceText: formatDistance(dist, isAhead: false), ); } // 4. Direction & Route relevance final dirResult = evaluateDirectionAndRoute( userPosition: userPosition, alert: alert, navSession: navSession, dashboardRadiusMeters: dashboardRadiusMeters, ); final hasActiveRoute = navSession != null && navSession.isNavigating && navSession.calculationResult != null && navSession.calculationResult!.geometry.length >= 2; if (hasActiveRoute) { if (!dirResult.onRoute) { // When actively navigating a route, suppress alerts not along the route corridor return RoadAlertEvaluation( isTacticalMapEligible: isTacticalEligible, isDashboardEligible: false, isAudioEligible: false, distanceFromUserMeters: dist, isAhead: false, dashboardRejectionReason: AlertRejectionReason.notOnActiveRoute, formattedDistanceText: formatDistance(dist, isAhead: false), ); } } else { final isMoving = userPosition.speed >= 2.0; if (isMoving) { if (!dirResult.isAhead) { // Exclude alerts behind rider return RoadAlertEvaluation( isTacticalMapEligible: isTacticalEligible, isDashboardEligible: false, isAudioEligible: false, distanceFromUserMeters: dist, isAhead: false, dashboardRejectionReason: AlertRejectionReason.behindRiderHeading, formattedDistanceText: formatDistance(dist, isAhead: false), ); } } else { // Stationary or creeping: only display if in very close proximity (<= 800m / 0.5 mi) if (dist > 804.7) { return RoadAlertEvaluation( isTacticalMapEligible: isTacticalEligible, isDashboardEligible: false, isAudioEligible: false, distanceFromUserMeters: dist, isAhead: false, dashboardRejectionReason: AlertRejectionReason.uncertainHeadingStationary, formattedDistanceText: formatDistance(dist, isAhead: false), ); } } } final distanceStr = formatDistance(dist, isAhead: dirResult.isAhead); return RoadAlertEvaluation( isTacticalMapEligible: isTacticalEligible, isDashboardEligible: true, isAudioEligible: dist <= defaultAudioRadiusMeters && dirResult.isAhead, distanceFromUserMeters: dist, isAhead: dirResult.isAhead, formattedDistanceText: distanceStr, ); } /// Evaluates an alert specifically for audible warning candidacy (default 400m / 0.25 mi threshold). static RoadAlertEvaluation evaluateForAudio({ required RoadAlert alert, required Position? userPosition, required NavigationSessionState? navSession, required Set dismissedIds, required Set enabledCategories, double audioRadiusMeters = defaultAudioRadiusMeters, DateTime? now, }) { final eval = evaluateForDashboard( alert: alert, userPosition: userPosition, navSession: navSession, dismissedIds: dismissedIds, enabledCategories: enabledCategories, dashboardRadiusMeters: audioRadiusMeters, now: now, ); final isAudio = eval.isDashboardEligible && eval.distanceFromUserMeters != null && eval.distanceFromUserMeters! <= audioRadiusMeters && eval.isAhead; return RoadAlertEvaluation( isTacticalMapEligible: eval.isTacticalMapEligible, isDashboardEligible: eval.isDashboardEligible, isAudioEligible: isAudio, distanceFromUserMeters: eval.distanceFromUserMeters, isAhead: eval.isAhead, dashboardRejectionReason: eval.dashboardRejectionReason, formattedDistanceText: eval.formattedDistanceText, ); } /// Formats human-readable distance truthfully. /// Never appends "Ahead" unless the alert is reliably verified to be in front of the rider. static String formatDistance(double? meters, {required bool isAhead}) { if (meters == null) return ''; if (meters < 15.0) { return 'Nearby'; } final miles = meters / 1609.344; final suffix = isAhead ? 'Ahead' : 'Nearby'; if (miles < 0.1) { final feet = ((meters * 3.28084) / 10).round() * 10; return '${feet > 0 ? feet : 50} ft $suffix'; } else { return '${miles.toStringAsFixed(1)} mi $suffix'; } } }