import 'dart:async'; import 'dart:math' as math; 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; List _cachedAlerts = []; final Set _announcedAlertIds = {}; Stream> get alertsStream => _alertsController.stream; RoadAlertService(this._ref) { _initTts(); _startListening(); } void _initTts() { _tts.setLanguage("en-US"); _tts.setSpeechRate(0.5); _tts.setVolume(1.0); } void _startListening() { _locationSub = _ref.read(locationServiceProvider).getFilteredPositionStream().listen((position) { _processPosition(position); }); } Future _processPosition(Position position) async { final isEnabled = _ref.read(roadAlertsEnabledProvider); if (!isEnabled) { _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); // Throttle fetches: query every 45s OR when moved > 500m bool shouldFetch = false; 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) { _lastFetchTime = now; _lastFetchPosition = position; await _fetchAlertsFromProviders( position.latitude, position.longitude, radiusMeters, tomtomApiKey: tomtomApiKey, communityApiKey: communityApiKey, ); } final Map deduplicated = {}; for (var alert in _cachedAlerts) { // Expiration check if (alert.expiresAt != null && alert.expiresAt!.isBefore(now)) { continue; } // Category toggle check 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: check distance (< 400m), cooldown, and direction relevance if (audioEnabled && dist <= 400.0 && !_announcedAlertIds.contains(key)) { final isAhead = _isAlertAheadOfRider(position, alert); if (isAhead) { _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 rider speed < 2.0 m/s (standing still), assume alert is relevant 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; // Relevant if alert is within ±100 degrees of rider's heading (in front or side) 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 = []; 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 (_) {} } _cachedAlerts = newAlerts; } 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; } _tts.speak(phrase); } void dispose() { _locationSub?.cancel(); _alertsController.close(); _tts.stop(); } }