import 'dart:async'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_tts/flutter_tts.dart'; import 'package:geolocator/geolocator.dart'; import 'package:rxdart/rxdart.dart'; import '../location_service.dart'; import '../sensor_fusion_service.dart'; import '../map/map_provider_interface.dart'; import '../navigation/navigation_session_service.dart'; import 'road_alert_models.dart'; import 'road_alert_providers.dart'; import 'road_alert_compliance.dart'; import 'multi_source_alert_deduplicator.dart'; import 'road_alert_presentation_policy.dart'; import '../field_test/field_test_logger.dart'; import '../../models/field_test_event.dart'; final leanStreamOverrideProvider = Provider?>((ref) => null); final roadAlertServiceProvider = Provider((ref) { final customLeanStream = ref.watch(leanStreamOverrideProvider); final service = RoadAlertService(ref, leanStream: customLeanStream); ref.onDispose(() => service.dispose()); return service; }); final activeAlertsStreamProvider = StreamProvider>((ref) { return ref.watch(roadAlertServiceProvider).alertsStream; }); final dashboardAlertsStreamProvider = StreamProvider>((ref) { return ref.watch(roadAlertServiceProvider).dashboardAlertsStream; }); final dashboardBannerAlertProvider = Provider((ref) { return ref.watch(roadAlertServiceProvider).closestDashboardAlert; }); class RoadAlertService { final Ref _ref; final FlutterTts _tts; final _alertsController = BehaviorSubject>.seeded([]); final _dashboardAlertsController = BehaviorSubject>.seeded([]); StreamSubscription? _locationSub; StreamSubscription? _leanSub; DateTime? _lastFetchTime; DateTime? _lastSuccessfulFetchTime; Position? _lastFetchPosition; Position? _lastObservedPosition; bool _isFetching = false; // Request serialization lock DateTime? _lastSpeechTime; // Audio cooldown timer String? _lastAudioWarningIssued; double _currentLeanAngle = 0.0; DateTime? _lastSteepLeanTime; RoadAlert? _deferredAudioAlert; List _cachedAlerts = []; final Set _announcedAlertIds = {}; final Set _dismissedAlertIds = {}; Timer? _cleanupTimer; Stream> get alertsStream => _alertsController.stream; List get cachedAlerts => List.unmodifiable(_cachedAlerts); Stream> get dashboardAlertsStream => _dashboardAlertsController.stream; List get cachedDashboardAlerts => List.unmodifiable(_dashboardAlertsController.value); RoadAlert? get closestDashboardAlert => _dashboardAlertsController.value.isNotEmpty ? _dashboardAlertsController.value.first : null; double get currentLeanAngle => _currentLeanAngle; bool get isCornering => _currentLeanAngle > 15.0 || (_lastSteepLeanTime != null && DateTime.now().difference(_lastSteepLeanTime!).inMilliseconds < 1500); RoadAlert? get deferredAudioAlert => _deferredAudioAlert; RoadAlertService(this._ref, {FlutterTts? tts, Stream? leanStream}) : _tts = tts ?? FlutterTts() { _initTts(); _startListening(leanStream: leanStream); _cleanupTimer = Timer.periodic(const Duration(seconds: 2), (_) { _pruneExpiredAlerts(); }); } void _initTts() { try { _tts.setLanguage("en-US").catchError((_) => null); _tts.setSpeechRate(0.5).catchError((_) => null); _tts.setVolume(1.0).catchError((_) => null); } catch (e) { if (kDebugMode) { debugPrint('[RoadAlerts] TTS init warning: $e'); } } } void _startListening({Stream? leanStream}) { if (kDebugMode) { debugPrint('[RoadAlerts] Service initialized'); } _locationSub = _ref.read(locationServiceProvider).getFilteredPositionStream().listen((position) { _processPosition(position); }); // Proactive initial bootstrap from last known position try { final initialPos = _ref.read(locationServiceProvider).lastValidPosition; if (initialPos != null) { _processPosition(initialPos); } else { Geolocator.getLastKnownPosition().then((pos) { if (pos != null && _lastObservedPosition == null) { _processPosition(pos); } }).catchError((_) {}); } } catch (_) {} final stream = leanStream ?? (() { try { return _ref.read(sensorFusionServiceProvider).leanAngleStream; } catch (e) { if (kDebugMode) { debugPrint('[RoadAlerts] Sensor fusion not available: $e'); } return null; } })(); if (stream != null) { _leanSub = stream.listen( (lean) { _processLean(lean); }, onError: (err) { if (kDebugMode) { debugPrint('[RoadAlerts] Lean stream error: $err'); } }, ); } } void _processLean(double lean) { _currentLeanAngle = lean.abs(); final now = DateTime.now(); if (_currentLeanAngle > 15.0) { _lastSteepLeanTime = now; } else if (_currentLeanAngle < 10.0) { final isUpright = _lastSteepLeanTime == null || now.difference(_lastSteepLeanTime!).inMilliseconds >= 1500; if (isUpright && _deferredAudioAlert != null) { final alert = _deferredAudioAlert!; _deferredAudioAlert = null; final pos = _lastObservedPosition; if (pos != null) { final dist = Geolocator.distanceBetween( pos.latitude, pos.longitude, alert.latitude, alert.longitude, ); final isAhead = _isAlertAheadOfRider(pos, alert); if (dist <= 400.0 && isAhead) { _lastSpeechTime = now; _announceAlertAudio(alert); } } } } } /// Public refresh method: allows MapScreen, routes, or user diagnostics /// to force a live or localized alert refresh. Future refreshAlerts({ double? overrideLat, double? overrideLng, double? overrideRadius, bool force = false, }) async { Position? pos = _lastObservedPosition; if (overrideLat != null && overrideLng != null) { pos = Position( latitude: overrideLat, longitude: overrideLng, timestamp: DateTime.now(), accuracy: 5.0, altitude: 0.0, altitudeAccuracy: 0.0, heading: 0.0, headingAccuracy: 0.0, speed: 0.0, speedAccuracy: 0.0, ); } else if (pos == null) { try { pos = _ref.read(locationServiceProvider).lastValidPosition; } catch (_) {} if (pos == null) { try { pos = await Geolocator.getLastKnownPosition(); } catch (_) {} } } if (pos != null) { await _processPosition(pos, force: force, overrideRadius: overrideRadius); } } Future _processPosition( Position position, { bool force = false, double? overrideRadius, }) async { _lastObservedPosition = position; final isEnabled = _ref.read(roadAlertsEnabledProvider); if (!isEnabled) { if (_cachedAlerts.isNotEmpty || _dashboardAlertsController.value.isNotEmpty) { _cachedAlerts = []; _alertsController.add([]); _dashboardAlertsController.add([]); } return; } final now = DateTime.now(); final double tacticalRadiusMeters = overrideRadius ?? _ref.read(tacticalAlertRadiusMetersProvider) ?? 25000.0; final audioRadiusMeters = _ref.read(alertRadiusMetersProvider); final audioEnabled = _ref.read(audioAlertsEnabledProvider); final enabledCategories = _ref.read(enabledCategoriesProvider); final tomtomApiKey = _ref.read(tomtomApiKeyProvider); final communityApiKey = _ref.read(communityApiKeyProvider); final hereApiKey = _ref.read(hereApiKeyProvider); final wazeApiKey = _ref.read(wazeApiKeyProvider); final complianceEnabled = _ref.read(jurisdictionComplianceEnabledProvider); final leanInterlockEnabled = _ref.read(leanInterlockEnabledProvider); // Request serialization check: prevent overlapping queries bool shouldFetch = false; if (force) { shouldFetch = true; } else if (!_isFetching) { if (_lastFetchTime == null || now.difference(_lastFetchTime!).inSeconds >= 45) { shouldFetch = true; } else if (_lastFetchPosition != null) { final dist = Geolocator.distanceBetween( _lastFetchPosition!.latitude, _lastFetchPosition!.longitude, position.latitude, position.longitude, ); if (dist > 500.0) { shouldFetch = true; } } } if (shouldFetch && !_isFetching) { _isFetching = true; _lastFetchTime = now; _lastFetchPosition = position; try { await _fetchAlertsFromProviders( position.latitude, position.longitude, tacticalRadiusMeters, tomtomApiKey: tomtomApiKey, communityApiKey: communityApiKey, hereApiKey: hereApiKey, wazeApiKey: wazeApiKey, ); } finally { _isFetching = false; } } int staleCount = 0; int disabledCategoryCount = 0; int outOfRangeCount = 0; int dismissedCount = 0; // Process cached alerts & filter out expired ones final List inRangeAlerts = []; for (var alert in _cachedAlerts) { final key = '${alert.providerId}_${alert.id}'; if (_dismissedAlertIds.contains(alert.id) || _dismissedAlertIds.contains(key)) { dismissedCount++; continue; } if (alert.expiresAt != null && alert.expiresAt!.isBefore(now)) { staleCount++; continue; } if (!enabledCategories.contains(alert.category)) { disabledCategoryCount++; continue; } final dist = Geolocator.distanceBetween( position.latitude, position.longitude, alert.latitude, alert.longitude, ); if (dist <= tacticalRadiusMeters) { inRangeAlerts.add(alert.copyWith(distanceFromUser: dist)); } else { outOfRangeCount++; } } // Deduplicate across heterogeneous sources (Swarm, Zyla, GA511, OSM, etc.) final deduplicatedAlerts = MultiSourceAlertDeduplicator.deduplicate( inRangeAlerts, dismissedIds: _dismissedAlertIds, ); final deduplicatedDroppedCount = inRangeAlerts.length - deduplicatedAlerts.length; // Apply jurisdictional regulatory compliance (US standard, France danger zone, Germany/Switzerland prohibited) final compliantList = List.from( RoadAlertCompliance.applyCompliance( deduplicatedAlerts, userLat: position.latitude, userLng: position.longitude, complianceEnabled: complianceEnabled, ), ); final complianceDroppedCount = deduplicatedAlerts.length - compliantList.length; final navSession = _ref.read(navigationSessionProvider); final hasActiveRoute = navSession.isNavigating && navSession.calculationResult != null && navSession.calculationResult!.geometry.length >= 2; compliantList.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, ); final bOnRoute = RouteCorridorAlertFilter.isAlertAheadOnRoute( userLat: position.latitude, userLng: position.longitude, alertLat: b.latitude, alertLng: b.longitude, geometry: geom, ); if (aOnRoute && !bOnRoute) return -1; if (!aOnRoute && bOnRoute) return 1; } return (a.distanceFromUser ?? 0).compareTo(b.distanceFromUser ?? 0); }); // Scope A: Tactical Map Discovery (full horizon up to 25 km) _cachedAlerts = compliantList; _alertsController.add(compliantList); // Scope B: Dashboard Banner Evaluation (up to 2.0 mi, route/direction & GPS freshness verified) final double dashboardRadiusMeters = _ref.read(dashboardAlertRadiusMetersProvider); final List 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 audioCandidates = []; for (var updatedAlert in dashboardEligibleAlerts) { final key = '${updatedAlert.providerId}_${updatedAlert.id}'; final dist = updatedAlert.distanceFromUser ?? 999999.0; final approachThreshold = math.min(audioRadiusMeters, 400.0); if (dist <= approachThreshold && _isAlertAheadOfRider(position, updatedAlert)) { audioCandidates.add(updatedAlert); if (audioEnabled && !_announcedAlertIds.contains(key)) { final isCornering = _currentLeanAngle > 15.0 || (_lastSteepLeanTime != null && now.difference(_lastSteepLeanTime!).inMilliseconds < 1500); if (leanInterlockEnabled && isCornering) { _deferredAudioAlert = updatedAlert; _announcedAlertIds.add(key); try { _ref.read(fieldTestLoggerProvider).log( eventType: FieldTestEventType.roadAlertReceived, severity: EventSeverity.info, message: 'Alert audio deferred: cornering at ${_currentLeanAngle.toStringAsFixed(1)}° lean', metadata: { 'category': updatedAlert.category.displayName, 'leanAngle': _currentLeanAngle, 'distance': dist, }, ); } catch (_) {} } else { final speechTime = DateTime.now(); if (_lastSpeechTime == null || speechTime.difference(_lastSpeechTime!).inSeconds >= 4) { _lastSpeechTime = speechTime; _announcedAlertIds.add(key); _lastAudioWarningIssued = '${updatedAlert.category.displayName} • ${RoadAlertPresentationPolicy.formatDistance(dist, isAhead: true)}'; _announceAlertAudio(updatedAlert); } } } } } // Publish live pipeline diagnostics telemetry final zylaProvider = _ref.read(zylaProviderInstance); final zylaStatus = _ref.read(zylaStatusProvider); final rejectedReasons = { if (staleCount > 0) 'stale_or_expired': staleCount, if (disabledCategoryCount > 0) 'category_disabled': disabledCategoryCount, if (outOfRangeCount > 0) 'out_of_tactical_range': outOfRangeCount, if (deduplicatedDroppedCount > 0) 'deduplicated': deduplicatedDroppedCount, if (complianceDroppedCount > 0) 'compliance_suppressed': complianceDroppedCount, if (dismissedCount > 0) 'dismissed': dismissedCount, }; final latDir = position.latitude >= 0 ? 'N' : 'S'; final lngDir = position.longitude >= 0 ? 'E' : 'W'; final areaStr = '${position.latitude.abs().toStringAsFixed(2)}° $latDir, ' '${position.longitude.abs().toStringAsFixed(2)}° $lngDir ' '(${(tacticalRadiusMeters / 1000).toStringAsFixed(1)} km radius)'; final isGpsFresh = RoadAlertPresentationPolicy.isLocationFreshAndAccurate( position: position, now: now, ); final String locationState = isGpsFresh ? 'fresh' : (_lastObservedPosition != null ? 'stale' : 'unavailable'); final double? locAgeSec = _lastObservedPosition != null ? now.difference(_lastObservedPosition!.timestamp).inMilliseconds / 1000.0 : null; final String? lastDashboardText = dashboardEligibleAlerts.isNotEmpty ? '${dashboardEligibleAlerts.first.category.displayName} • ${RoadAlertPresentationPolicy.formatDistance(dashboardEligibleAlerts.first.distanceFromUser, isAhead: true)}' : null; _ref.read(roadAlertDiagnosticsProvider.notifier).updateDiagnostics( RoadAlertDiagnostics( providerStatus: zylaProvider.lastStatus != RoadAlertProviderStatus.notConfigured ? zylaProvider.lastStatus : zylaStatus, statusMessage: zylaProvider.lastError ?? (zylaStatus == RoadAlertProviderStatus.connected ? 'Operational' : zylaStatus.name), lastFetchTime: _lastFetchTime ?? now, lastSuccessfulFetchTime: _lastSuccessfulFetchTime, lastRequestedArea: areaStr, lastRequestedRadiusMeters: tacticalRadiusMeters, isLive: zylaProvider.lastWasLive, rawReportsCount: zylaProvider.lastRawCount, normalizedAcceptedCount: _cachedAlerts.length + staleCount + disabledCategoryCount + outOfRangeCount, rejectedReasons: rejectedReasons, displayedOnMapCount: compliantList.length, dashboardEligibleCount: dashboardEligibleAlerts.length, audioCandidateCount: audioCandidates.length, activeRouteRelevance: hasActiveRoute, locationFreshnessState: locationState, lastLocationAgeSeconds: locAgeSec, locationAccuracyMeters: position.accuracy, directionRejectedCount: directionRejectedCount, distanceRejectedCount: distanceRejectedCount, staleGpsRejectedCount: staleGpsRejectedCount, lastDashboardAlertPresented: lastDashboardText, lastAudioWarningIssued: _lastAudioWarningIssued, lastError: zylaProvider.lastError, ), ); } bool _isAlertAheadOfRider(Position userPos, RoadAlert alert) { if (userPos.speed < 2.0) return true; final riderHeading = userPos.heading; final bearingToAlert = Geolocator.bearingBetween( userPos.latitude, userPos.longitude, alert.latitude, alert.longitude, ); double delta = (bearingToAlert - riderHeading).abs(); if (delta > 180) delta = 360 - delta; return delta <= 100.0; } Future _fetchAlertsFromProviders( double lat, double lng, double radiusMeters, { required String tomtomApiKey, required String communityApiKey, required String hereApiKey, required String wazeApiKey, }) async { final providers = _ref.read(activeRoadAlertProviders); final List newAlerts = []; if (kDebugMode) { debugPrint('[RoadAlerts] Fetching alerts from ${providers.length} providers...'); } for (var provider in providers) { try { String? key; if (provider.id == 'tomtom') key = tomtomApiKey; if (provider.id == 'community_traffic') key = communityApiKey; if (provider.id == 'here_safety_cameras') key = hereApiKey; if (provider.id == 'waze') key = wazeApiKey; final fetched = await provider.getAlerts( latitude: lat, longitude: lng, radiusMeters: radiusMeters, apiKey: key, ); newAlerts.addAll(fetched); try { for (var a in fetched) { _ref.read(fieldTestLoggerProvider).log( eventType: FieldTestEventType.roadAlertReceived, severity: EventSeverity.info, message: '[${provider.id}] ${a.category.displayName}: ${a.description}', metadata: { 'providerId': provider.id, 'category': a.category.displayName, 'distanceMeters': a.distanceFromUser, }, ); } } catch (_) {} } catch (e) { if (kDebugMode) { debugPrint('[RoadAlerts] Provider ${provider.id} error: $e'); } try { _ref.read(fieldTestLoggerProvider).log( eventType: FieldTestEventType.networkError, severity: EventSeverity.warning, message: 'Road alert provider ${provider.id} failure: $e', ); } catch (_) {} } } _cachedAlerts = newAlerts; if (providers.isNotEmpty) { _lastSuccessfulFetchTime = DateTime.now(); } if (kDebugMode) { debugPrint('[RoadAlerts] Active cached alerts count: ${_cachedAlerts.length}'); } } void _announceAlertAudio(RoadAlert alert) { final audioMode = _ref.read(roadAlertAudioModeProvider); if (audioMode == RoadAlertAudioMode.tacticalTone) { _playTacticalTone(alert); return; } String phrase; switch (alert.category) { case RoadAlertCategory.police: phrase = "Police report ahead."; break; case RoadAlertCategory.speedCamera: phrase = "Speed camera reported ahead."; break; case RoadAlertCategory.accident: phrase = "Accident reported ahead."; break; case RoadAlertCategory.hazard: phrase = "Road hazard ahead."; break; case RoadAlertCategory.roadClosure: phrase = "Road closure ahead."; break; case RoadAlertCategory.construction: phrase = "Construction zone ahead."; break; case RoadAlertCategory.traffic: phrase = "Traffic delay ahead."; break; case RoadAlertCategory.pothole: phrase = "Severe pothole ahead."; break; case RoadAlertCategory.debris: phrase = "Road debris ahead."; break; case RoadAlertCategory.disabledVehicle: phrase = "Disabled vehicle reported ahead."; break; case RoadAlertCategory.weatherHazard: phrase = "Weather hazard reported ahead."; break; case RoadAlertCategory.other: phrase = "Traffic alert ahead."; break; } if (kDebugMode) { debugPrint('[RoadAlerts] Announcing audio alert: $phrase'); } try { _tts.speak(phrase).catchError((e) { if (kDebugMode) { debugPrint('[RoadAlerts] TTS speak error: $e'); } return null; }); _ref.read(fieldTestLoggerProvider).log( eventType: FieldTestEventType.ttsAnnounced, severity: EventSeverity.info, message: 'TTS Announced: "$phrase"', ); } catch (e) { if (kDebugMode) { debugPrint('[RoadAlerts] TTS speak error: $e'); } _ref.read(fieldTestLoggerProvider).log( eventType: FieldTestEventType.audioFailure, severity: EventSeverity.warning, message: 'TTS error speaking "$phrase": $e', ); } } void _playTacticalTone(RoadAlert alert) { if (kDebugMode) { debugPrint('[RoadAlerts] Playing tactical tone chirp for ${alert.category.displayName}'); } try { SystemSound.play(SystemSoundType.alert); if (alert.category == RoadAlertCategory.police || alert.severity == RoadAlertSeverity.high || alert.severity == RoadAlertSeverity.critical) { Future.delayed(const Duration(milliseconds: 140), () { SystemSound.play(SystemSoundType.alert); }); } _ref.read(fieldTestLoggerProvider).log( eventType: FieldTestEventType.ttsAnnounced, severity: EventSeverity.info, message: 'Tactical tone chirp played for ${alert.category.displayName}', metadata: { 'category': alert.category.displayName, 'severity': alert.severity.name, 'distance': alert.distanceFromUser, }, ); } catch (e) { if (kDebugMode) { debugPrint('[RoadAlerts] Tactical tone error: $e'); } } } void triggerTestAlert({RoadAlertCategory category = RoadAlertCategory.police}) { final now = DateTime.now(); final pos = _lastFetchPosition; // 150 meters ahead of current location final lat = pos != null ? pos.latitude + 0.0012 : 34.0620; final lng = pos != null ? pos.longitude + 0.0008 : -85.2330; final testAlert = RoadAlert( id: 'test_alert_${now.millisecondsSinceEpoch}', providerId: 'diagnostic_test', category: category, latitude: lat, longitude: lng, description: 'TEST ALERT: ${category.displayName} reported ahead (Diagnostic Verification)', roadName: 'Test Way / Simulation', reportedAt: now, expiresAt: now.add(const Duration(seconds: 12)), lastUpdated: now, distanceFromUser: 150.0, source: 'Internal Pipeline Test', sourceType: RoadAlertSourceType.providerReported, ); if (kDebugMode) { debugPrint('[RoadAlerts] [TEST ALERT] Injected: ${testAlert.description}'); } _cachedAlerts = [testAlert, ..._cachedAlerts.where((a) => !a.id.startsWith('test_alert_'))]; _alertsController.add(_cachedAlerts); final phrase = "TEST ALERT: ${category.displayName} reported ahead."; try { _tts.speak(phrase).catchError((_) => null); _ref.read(fieldTestLoggerProvider).log( eventType: FieldTestEventType.ttsAnnounced, severity: EventSeverity.info, message: 'TTS Test Alert: "$phrase"', ); } catch (e) { if (kDebugMode) { debugPrint('[RoadAlerts] TTS speak error for test alert: $e'); } } } /// Dismisses a specific alert by ID and prevents it from re-appearing void dismissAlert(String alertId) { _dismissedAlertIds.add(alertId); _cachedAlerts = _cachedAlerts.where((a) => a.id != alertId && '${a.providerId}_${a.id}' != alertId).toList(); _alertsController.add(_cachedAlerts); if (kDebugMode) { debugPrint('[RoadAlerts] Manually dismissed alert $alertId'); } } /// Clears all cached alerts immediately void clearAllAlerts() { _cachedAlerts = []; _alertsController.add([]); _dashboardAlertsController.add([]); if (kDebugMode) { debugPrint('[RoadAlerts] Cleared all cached alerts'); } } /// Periodically prunes expired or dismissed alerts even when stationary void _pruneExpiredAlerts() { if (_cachedAlerts.isEmpty) return; final now = DateTime.now(); final initialCount = _cachedAlerts.length; _cachedAlerts = _cachedAlerts.where((alert) { final key = '${alert.providerId}_${alert.id}'; if (_dismissedAlertIds.contains(alert.id) || _dismissedAlertIds.contains(key)) { return false; } if (alert.expiresAt != null && alert.expiresAt!.isBefore(now)) { return false; } return true; }).toList(); if (_cachedAlerts.length != initialCount) { if (kDebugMode) { debugPrint('[RoadAlerts] Pruned expired/dismissed alerts (remaining: ${_cachedAlerts.length})'); } _alertsController.add(_cachedAlerts); } } void simulateAlert(RoadAlert alert) { if (!_ref.read(roadAlertsEnabledProvider)) return; final now = DateTime.now(); final key = '${alert.providerId}_${alert.id}'; if ((alert.expiresAt != null && alert.expiresAt!.isBefore(now)) || _dismissedAlertIds.contains(alert.id) || _dismissedAlertIds.contains(key)) { return; } _cachedAlerts = [ alert, ..._cachedAlerts.where( (existing) => existing.id != alert.id || existing.providerId != alert.providerId, ), ]; _alertsController.add(_cachedAlerts); if (_ref.read(audioAlertsEnabledProvider)) { final leanInterlock = _ref.read(leanInterlockEnabledProvider); if (leanInterlock && isCornering) { _deferredAudioAlert = alert; } else { _announceAlertAudio(alert); } } } void dispose() { if (kDebugMode) { debugPrint('[RoadAlerts] Disposing service resources'); } _cleanupTimer?.cancel(); _locationSub?.cancel(); _leanSub?.cancel(); _alertsController.close(); _dashboardAlertsController.close(); try { _tts.stop().catchError((_) => null); } catch (_) {} } } /// Helper to evaluate route corridor proximity and ahead-of-rider geometry. class RouteCorridorAlertFilter { /// Computes minimum cross-track perpendicular distance in meters from point to route polyline. static double minDistanceToPolyline( double alertLat, double alertLng, List geometry, ) { if (geometry.isEmpty) return double.infinity; if (geometry.length == 1) { return Geolocator.distanceBetween( alertLat, alertLng, geometry.first.latitude, geometry.first.longitude, ); } double minDistance = double.infinity; final cosLat = math.cos(alertLat * math.pi / 180.0); for (int i = 0; i < geometry.length - 1; i++) { final p1 = geometry[i]; final p2 = geometry[i + 1]; final dx = (p2.longitude - p1.longitude) * 111320.0 * cosLat; final dy = (p2.latitude - p1.latitude) * 111320.0; final segLengthSq = dx * dx + dy * dy; final ax = (alertLng - p1.longitude) * 111320.0 * cosLat; final ay = (alertLat - p1.latitude) * 111320.0; if (segLengthSq == 0.0) { final dist = math.sqrt(ax * ax + ay * ay); if (dist < minDistance) minDistance = dist; continue; } final t = ((ax * dx + ay * dy) / segLengthSq).clamp(0.0, 1.0); final projX = t * dx; final projY = t * dy; final dist = math.sqrt((ax - projX) * (ax - projX) + (ay - projY) * (ay - projY)); if (dist < minDistance) { minDistance = dist; } } return minDistance; } /// Evaluates whether an alert is along and ahead of the user's active route corridor. static bool isAlertAheadOnRoute({ required double userLat, required double userLng, required double alertLat, required double alertLng, required List geometry, double maxCrossTrackMeters = 80.0, double maxAheadMeters = 3218.688, }) { if (geometry.length < 2) return false; final crossTrack = minDistanceToPolyline(alertLat, alertLng, geometry); if (crossTrack > maxCrossTrackMeters) return false; int userSeg = 0; double minUserDist = double.infinity; int alertSeg = 0; double minAlertDist = double.infinity; for (int i = 0; i < geometry.length; i++) { final du = Geolocator.distanceBetween(userLat, userLng, geometry[i].latitude, geometry[i].longitude); if (du < minUserDist) { minUserDist = du; userSeg = i; } final da = Geolocator.distanceBetween(alertLat, alertLng, geometry[i].latitude, geometry[i].longitude); if (da < minAlertDist) { minAlertDist = da; alertSeg = i; } } if (alertSeg < userSeg) return false; final directDist = Geolocator.distanceBetween(userLat, userLng, alertLat, alertLng); return directDist <= maxAheadMeters; } }