MotorSignal-App/lib/services/alerts/road_alert_service.dart

221 lines
6.3 KiB
Dart

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<RoadAlertService>((ref) {
final service = RoadAlertService(ref);
ref.onDispose(() => service.dispose());
return service;
});
final activeAlertsStreamProvider = StreamProvider<List<RoadAlert>>((ref) {
return ref.watch(roadAlertServiceProvider).alertsStream;
});
class RoadAlertService {
final Ref _ref;
final FlutterTts _tts = FlutterTts();
final _alertsController = StreamController<List<RoadAlert>>.broadcast();
StreamSubscription<Position>? _locationSub;
DateTime? _lastFetchTime;
Position? _lastFetchPosition;
List<RoadAlert> _cachedAlerts = [];
final Set<String> _announcedAlertIds = {};
Stream<List<RoadAlert>> 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<void> _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<String, RoadAlert> 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<void> _fetchAlertsFromProviders(
double lat,
double lng,
double radiusMeters, {
required String tomtomApiKey,
required String communityApiKey,
}) async {
final providers = _ref.read(activeRoadAlertProviders);
final List<RoadAlert> 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();
}
}