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

725 lines
23 KiB
Dart

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<Stream<double>?>((ref) => null);
final roadAlertServiceProvider = Provider<RoadAlertService>((ref) {
final customLeanStream = ref.watch(leanStreamOverrideProvider);
final service = RoadAlertService(ref, leanStream: customLeanStream);
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;
final _alertsController = BehaviorSubject<List<RoadAlert>>.seeded([]);
StreamSubscription<Position>? _locationSub;
StreamSubscription<double>? _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<RoadAlert> _cachedAlerts = [];
final Set<String> _announcedAlertIds = {};
final Set<String> _dismissedAlertIds = {};
Timer? _cleanupTimer;
Stream<List<RoadAlert>> 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<double>? 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<double>? 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<void> _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<RoadAlert> 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<void> _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<RoadAlert> 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<AppLatLng> 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<AppLatLng> 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;
}
}