import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_tts/flutter_tts.dart'; import 'package:geolocator/geolocator.dart'; import '../location_service.dart'; import 'road_alert_models.dart'; import 'road_alert_providers.dart'; import '../field_test/field_test_logger.dart'; import '../../models/field_test_event.dart'; final roadAlertServiceProvider = Provider((ref) { final service = RoadAlertService(ref); ref.onDispose(() => service.dispose()); return service; }); final activeAlertsStreamProvider = StreamProvider>((ref) { return ref.watch(roadAlertServiceProvider).alertsStream; }); class RoadAlertService { final Ref _ref; final FlutterTts _tts = FlutterTts(); final _alertsController = StreamController>.broadcast(); StreamSubscription? _locationSub; DateTime? _lastFetchTime; Position? _lastFetchPosition; bool _isFetching = false; // Request serialization lock DateTime? _lastSpeechTime; // Audio cooldown timer List _cachedAlerts = []; final Set _announcedAlertIds = {}; final Set _dismissedAlertIds = {}; Timer? _cleanupTimer; Stream> get alertsStream => _alertsController.stream; RoadAlertService(this._ref) { _initTts(); _startListening(); _cleanupTimer = Timer.periodic(const Duration(seconds: 2), (_) { _pruneExpiredAlerts(); }); } void _initTts() { try { _tts.setLanguage("en-US"); _tts.setSpeechRate(0.5); _tts.setVolume(1.0); } catch (e) { if (kDebugMode) { debugPrint('[RoadAlerts] TTS init warning: $e'); } } } void _startListening() { if (kDebugMode) { debugPrint('[RoadAlerts] Service initialized'); } _locationSub = _ref.read(locationServiceProvider).getFilteredPositionStream().listen((position) { _processPosition(position); }); } Future _processPosition(Position position) async { 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); // 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, ); } finally { _isFetching = false; } } // Process cached alerts & filter out expired ones final Map deduplicated = {}; 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) { final updatedAlert = alert.copyWith(distanceFromUser: dist); final key = '${alert.providerId}_${alert.id}'; deduplicated[key] = updatedAlert; // Approach Audio Warning with 4-second speech cooldown if (audioEnabled && dist <= 400.0 && !_announcedAlertIds.contains(key)) { final isAhead = _isAlertAheadOfRider(position, alert); if (isAhead) { final speechTime = DateTime.now(); if (_lastSpeechTime == null || speechTime.difference(_lastSpeechTime!).inSeconds >= 4) { _lastSpeechTime = speechTime; _announcedAlertIds.add(key); _announceAlertAudio(updatedAlert); } } } } } final resultList = deduplicated.values.toList() ..sort((a, b) => (a.distanceFromUser ?? 0).compareTo(b.distanceFromUser ?? 0)); _cachedAlerts = resultList; _alertsController.add(resultList); } 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, }) 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; 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) { 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.other: phrase = "Traffic alert ahead."; break; } if (kDebugMode) { debugPrint('[RoadAlerts] Announcing audio alert: $phrase'); } try { _tts.speak(phrase); _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 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); _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; _cachedAlerts = [alert, ..._cachedAlerts]; _alertsController.add(_cachedAlerts); if (_ref.read(audioAlertsEnabledProvider)) { _announceAlertAudio(alert); } } void dispose() { if (kDebugMode) { debugPrint('[RoadAlerts] Disposing service resources'); } _cleanupTimer?.cancel(); _locationSub?.cancel(); _alertsController.close(); try { _tts.stop(); } catch (_) {} } }