MotorSignal-App/lib/services/alerts/road_alert_presentation_pol...

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';
}
}
}