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'; 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 = {}; Stream> get alertsStream => _alertsController.stream; RoadAlertService(this._ref) { _initTts(); _startListening(); } 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); } catch (e) { if (kDebugMode) { debugPrint('[RoadAlerts] Provider ${provider.id} error: $e'); } } } _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); } catch (e) { if (kDebugMode) { debugPrint('[RoadAlerts] TTS speak error: $e'); } } } void dispose() { if (kDebugMode) { debugPrint('[RoadAlerts] Disposing service resources'); } _locationSub?.cancel(); _alertsController.close(); try { _tts.stop(); } catch (_) {} } }