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 '../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; }); class RoadAlertService { final Ref _ref; final FlutterTts _tts; final _alertsController = BehaviorSubject>.seeded([]); StreamSubscription? _locationSub; StreamSubscription? _leanSub; DateTime? _lastFetchTime; Position? _lastFetchPosition; Position? _lastObservedPosition; bool _isFetching = false; // Request serialization lock DateTime? _lastSpeechTime; // Audio cooldown timer double _currentLeanAngle = 0.0; DateTime? _lastSteepLeanTime; RoadAlert? _deferredAudioAlert; List _cachedAlerts = []; final Set _announcedAlertIds = {}; final Set _dismissedAlertIds = {}; Timer? _cleanupTimer; Stream> get alertsStream => _alertsController.stream; 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); }); 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); } } } } } Future _processPosition(Position position) async { _lastObservedPosition = position; final isEnabled = _ref.read(roadAlertsEnabledProvider); if (!isEnabled) { if (_cachedAlerts.isNotEmpty) { _cachedAlerts = []; _alertsController.add([]); } return; } final now = DateTime.now(); final radiusMeters = _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 (!_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 = true; _lastFetchTime = now; _lastFetchPosition = position; try { await _fetchAlertsFromProviders( position.latitude, position.longitude, radiusMeters, tomtomApiKey: tomtomApiKey, communityApiKey: communityApiKey, hereApiKey: hereApiKey, wazeApiKey: wazeApiKey, ); } finally { _isFetching = false; } } // Process cached alerts & filter out expired ones final List inRangeAlerts = []; for (var alert in _cachedAlerts) { if (alert.expiresAt != null && alert.expiresAt!.isBefore(now)) { continue; } if (!enabledCategories.contains(alert.category)) { continue; } final dist = Geolocator.distanceBetween( position.latitude, position.longitude, alert.latitude, alert.longitude, ); if (dist <= radiusMeters * 1.5) { inRangeAlerts.add(alert.copyWith(distanceFromUser: dist)); } } // Deduplicate across heterogeneous sources (Swarm, Zyla, GA511, OSM, etc.) final deduplicatedAlerts = MultiSourceAlertDeduplicator.deduplicate( inRangeAlerts, dismissedIds: _dismissedAlertIds, ); // Apply jurisdictional regulatory compliance (US standard, France danger zone, Germany/Switzerland prohibited) final compliantList = RoadAlertCompliance.applyCompliance( deduplicatedAlerts, userLat: position.latitude, userLng: position.longitude, complianceEnabled: complianceEnabled, ); 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); }); _cachedAlerts = compliantList; _alertsController.add(compliantList); // Approach Audio Warning with lean-angle cornering safety interlock and speech cooldown for (var updatedAlert in compliantList) { final key = '${updatedAlert.providerId}_${updatedAlert.id}'; final dist = updatedAlert.distanceFromUser ?? 999999.0; if (audioEnabled && dist <= 400.0 && !_announcedAlertIds.contains(key)) { final isAhead = _isAlertAheadOfRider(position, updatedAlert); if (isAhead) { 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); _announceAlertAudio(updatedAlert); } } } } } } 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 (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([]); 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(); 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 = 1200.0, }) { 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; } }