967 lines
32 KiB
Dart
967 lines
32 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 'road_alert_presentation_policy.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;
|
|
});
|
|
|
|
final dashboardAlertsStreamProvider = StreamProvider<List<RoadAlert>>((ref) {
|
|
return ref.watch(roadAlertServiceProvider).dashboardAlertsStream;
|
|
});
|
|
|
|
final dashboardBannerAlertProvider = Provider<RoadAlert?>((ref) {
|
|
return ref.watch(roadAlertServiceProvider).closestDashboardAlert;
|
|
});
|
|
|
|
class RoadAlertService {
|
|
final Ref _ref;
|
|
final FlutterTts _tts;
|
|
final _alertsController = BehaviorSubject<List<RoadAlert>>.seeded([]);
|
|
final _dashboardAlertsController = BehaviorSubject<List<RoadAlert>>.seeded([]);
|
|
|
|
StreamSubscription<Position>? _locationSub;
|
|
StreamSubscription<double>? _leanSub;
|
|
|
|
DateTime? _lastFetchTime;
|
|
DateTime? _lastSuccessfulFetchTime;
|
|
Position? _lastFetchPosition;
|
|
Position? _lastObservedPosition;
|
|
bool _isFetching = false; // Request serialization lock
|
|
DateTime? _lastSpeechTime; // Audio cooldown timer
|
|
String? _lastAudioWarningIssued;
|
|
|
|
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;
|
|
List<RoadAlert> get cachedAlerts => List.unmodifiable(_cachedAlerts);
|
|
Stream<List<RoadAlert>> get dashboardAlertsStream => _dashboardAlertsController.stream;
|
|
List<RoadAlert> get cachedDashboardAlerts => List.unmodifiable(_dashboardAlertsController.value);
|
|
RoadAlert? get closestDashboardAlert =>
|
|
_dashboardAlertsController.value.isNotEmpty ? _dashboardAlertsController.value.first : null;
|
|
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);
|
|
});
|
|
|
|
// Proactive initial bootstrap from last known position
|
|
try {
|
|
final initialPos = _ref.read(locationServiceProvider).lastValidPosition;
|
|
if (initialPos != null) {
|
|
_processPosition(initialPos);
|
|
} else {
|
|
Geolocator.getLastKnownPosition().then((pos) {
|
|
if (pos != null && _lastObservedPosition == null) {
|
|
_processPosition(pos);
|
|
}
|
|
}).catchError((_) {});
|
|
}
|
|
} catch (_) {}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Public refresh method: allows MapScreen, routes, or user diagnostics
|
|
/// to force a live or localized alert refresh.
|
|
Future<void> refreshAlerts({
|
|
double? overrideLat,
|
|
double? overrideLng,
|
|
double? overrideRadius,
|
|
bool force = false,
|
|
}) async {
|
|
Position? pos = _lastObservedPosition;
|
|
if (overrideLat != null && overrideLng != null) {
|
|
pos = Position(
|
|
latitude: overrideLat,
|
|
longitude: overrideLng,
|
|
timestamp: DateTime.now(),
|
|
accuracy: 5.0,
|
|
altitude: 0.0,
|
|
altitudeAccuracy: 0.0,
|
|
heading: 0.0,
|
|
headingAccuracy: 0.0,
|
|
speed: 0.0,
|
|
speedAccuracy: 0.0,
|
|
);
|
|
} else if (pos == null) {
|
|
try {
|
|
pos = _ref.read(locationServiceProvider).lastValidPosition;
|
|
} catch (_) {}
|
|
if (pos == null) {
|
|
try {
|
|
pos = await Geolocator.getLastKnownPosition();
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
if (pos != null) {
|
|
await _processPosition(pos, force: force, overrideRadius: overrideRadius);
|
|
}
|
|
}
|
|
|
|
Future<void> _processPosition(
|
|
Position position, {
|
|
bool force = false,
|
|
double? overrideRadius,
|
|
}) async {
|
|
_lastObservedPosition = position;
|
|
final isEnabled = _ref.read(roadAlertsEnabledProvider);
|
|
if (!isEnabled) {
|
|
if (_cachedAlerts.isNotEmpty || _dashboardAlertsController.value.isNotEmpty) {
|
|
_cachedAlerts = [];
|
|
_alertsController.add([]);
|
|
_dashboardAlertsController.add([]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
final now = DateTime.now();
|
|
final double tacticalRadiusMeters = overrideRadius ??
|
|
_ref.read(tacticalAlertRadiusMetersProvider) ??
|
|
25000.0;
|
|
final audioRadiusMeters = _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 (force) {
|
|
shouldFetch = true;
|
|
} else 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) {
|
|
_isFetching = true;
|
|
_lastFetchTime = now;
|
|
_lastFetchPosition = position;
|
|
try {
|
|
await _fetchAlertsFromProviders(
|
|
position.latitude,
|
|
position.longitude,
|
|
tacticalRadiusMeters,
|
|
tomtomApiKey: tomtomApiKey,
|
|
communityApiKey: communityApiKey,
|
|
hereApiKey: hereApiKey,
|
|
wazeApiKey: wazeApiKey,
|
|
);
|
|
} finally {
|
|
_isFetching = false;
|
|
}
|
|
}
|
|
|
|
int staleCount = 0;
|
|
int disabledCategoryCount = 0;
|
|
int outOfRangeCount = 0;
|
|
int dismissedCount = 0;
|
|
|
|
// Process cached alerts & filter out expired ones
|
|
final List<RoadAlert> inRangeAlerts = [];
|
|
for (var alert in _cachedAlerts) {
|
|
final key = '${alert.providerId}_${alert.id}';
|
|
if (_dismissedAlertIds.contains(alert.id) || _dismissedAlertIds.contains(key)) {
|
|
dismissedCount++;
|
|
continue;
|
|
}
|
|
|
|
if (alert.expiresAt != null && alert.expiresAt!.isBefore(now)) {
|
|
staleCount++;
|
|
continue;
|
|
}
|
|
|
|
if (!enabledCategories.contains(alert.category)) {
|
|
disabledCategoryCount++;
|
|
continue;
|
|
}
|
|
|
|
final dist = Geolocator.distanceBetween(
|
|
position.latitude,
|
|
position.longitude,
|
|
alert.latitude,
|
|
alert.longitude,
|
|
);
|
|
|
|
if (dist <= tacticalRadiusMeters) {
|
|
inRangeAlerts.add(alert.copyWith(distanceFromUser: dist));
|
|
} else {
|
|
outOfRangeCount++;
|
|
}
|
|
}
|
|
|
|
// Deduplicate across heterogeneous sources (Swarm, Zyla, GA511, OSM, etc.)
|
|
final deduplicatedAlerts = MultiSourceAlertDeduplicator.deduplicate(
|
|
inRangeAlerts,
|
|
dismissedIds: _dismissedAlertIds,
|
|
);
|
|
final deduplicatedDroppedCount = inRangeAlerts.length - deduplicatedAlerts.length;
|
|
|
|
// Apply jurisdictional regulatory compliance (US standard, France danger zone, Germany/Switzerland prohibited)
|
|
final compliantList = List<RoadAlert>.from(
|
|
RoadAlertCompliance.applyCompliance(
|
|
deduplicatedAlerts,
|
|
userLat: position.latitude,
|
|
userLng: position.longitude,
|
|
complianceEnabled: complianceEnabled,
|
|
),
|
|
);
|
|
final complianceDroppedCount = deduplicatedAlerts.length - compliantList.length;
|
|
|
|
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);
|
|
});
|
|
|
|
// Scope A: Tactical Map Discovery (full horizon up to 25 km)
|
|
_cachedAlerts = compliantList;
|
|
_alertsController.add(compliantList);
|
|
|
|
// Scope B: Dashboard Banner Evaluation (up to 2.0 mi, route/direction & GPS freshness verified)
|
|
final double dashboardRadiusMeters = _ref.read(dashboardAlertRadiusMetersProvider);
|
|
final List<RoadAlert> dashboardEligibleAlerts = [];
|
|
int directionRejectedCount = 0;
|
|
int distanceRejectedCount = 0;
|
|
int staleGpsRejectedCount = 0;
|
|
|
|
for (var alert in compliantList) {
|
|
final evaluation = RoadAlertPresentationPolicy.evaluateForDashboard(
|
|
alert: alert,
|
|
userPosition: position,
|
|
navSession: navSession,
|
|
dismissedIds: _dismissedAlertIds,
|
|
enabledCategories: enabledCategories,
|
|
tacticalRadiusMeters: tacticalRadiusMeters,
|
|
dashboardRadiusMeters: dashboardRadiusMeters,
|
|
now: now,
|
|
);
|
|
|
|
if (evaluation.isDashboardEligible) {
|
|
dashboardEligibleAlerts.add(alert.copyWith(
|
|
distanceFromUser: evaluation.distanceFromUserMeters,
|
|
));
|
|
} else {
|
|
switch (evaluation.dashboardRejectionReason) {
|
|
case AlertRejectionReason.distanceExceedsDashboardThreshold:
|
|
distanceRejectedCount++;
|
|
break;
|
|
case AlertRejectionReason.behindRiderHeading:
|
|
case AlertRejectionReason.notOnActiveRoute:
|
|
case AlertRejectionReason.uncertainHeadingStationary:
|
|
directionRejectedCount++;
|
|
break;
|
|
case AlertRejectionReason.staleOrMissingGps:
|
|
staleGpsRejectedCount++;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort dashboard alerts: on-route first, then closest distance
|
|
dashboardEligibleAlerts.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,
|
|
maxAheadMeters: dashboardRadiusMeters,
|
|
);
|
|
final bOnRoute = RouteCorridorAlertFilter.isAlertAheadOnRoute(
|
|
userLat: position.latitude,
|
|
userLng: position.longitude,
|
|
alertLat: b.latitude,
|
|
alertLng: b.longitude,
|
|
geometry: geom,
|
|
maxAheadMeters: dashboardRadiusMeters,
|
|
);
|
|
if (aOnRoute && !bOnRoute) return -1;
|
|
if (!aOnRoute && bOnRoute) return 1;
|
|
}
|
|
return (a.distanceFromUser ?? 0).compareTo(b.distanceFromUser ?? 0);
|
|
});
|
|
|
|
_dashboardAlertsController.add(dashboardEligibleAlerts);
|
|
|
|
// Scope C: Approach Audio Warning (evaluated from dashboard-eligible candidates against 400m threshold)
|
|
final List<RoadAlert> audioCandidates = [];
|
|
for (var updatedAlert in dashboardEligibleAlerts) {
|
|
final key = '${updatedAlert.providerId}_${updatedAlert.id}';
|
|
final dist = updatedAlert.distanceFromUser ?? 999999.0;
|
|
final approachThreshold = math.min(audioRadiusMeters, 400.0);
|
|
if (dist <= approachThreshold && _isAlertAheadOfRider(position, updatedAlert)) {
|
|
audioCandidates.add(updatedAlert);
|
|
if (audioEnabled && !_announcedAlertIds.contains(key)) {
|
|
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);
|
|
_lastAudioWarningIssued = '${updatedAlert.category.displayName} • ${RoadAlertPresentationPolicy.formatDistance(dist, isAhead: true)}';
|
|
_announceAlertAudio(updatedAlert);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Publish live pipeline diagnostics telemetry
|
|
final zylaProvider = _ref.read(zylaProviderInstance);
|
|
final zylaStatus = _ref.read(zylaStatusProvider);
|
|
|
|
final rejectedReasons = <String, int>{
|
|
if (staleCount > 0) 'stale_or_expired': staleCount,
|
|
if (disabledCategoryCount > 0) 'category_disabled': disabledCategoryCount,
|
|
if (outOfRangeCount > 0) 'out_of_tactical_range': outOfRangeCount,
|
|
if (deduplicatedDroppedCount > 0) 'deduplicated': deduplicatedDroppedCount,
|
|
if (complianceDroppedCount > 0) 'compliance_suppressed': complianceDroppedCount,
|
|
if (dismissedCount > 0) 'dismissed': dismissedCount,
|
|
};
|
|
|
|
final latDir = position.latitude >= 0 ? 'N' : 'S';
|
|
final lngDir = position.longitude >= 0 ? 'E' : 'W';
|
|
final areaStr = '${position.latitude.abs().toStringAsFixed(2)}° $latDir, '
|
|
'${position.longitude.abs().toStringAsFixed(2)}° $lngDir '
|
|
'(${(tacticalRadiusMeters / 1000).toStringAsFixed(1)} km radius)';
|
|
|
|
final isGpsFresh = RoadAlertPresentationPolicy.isLocationFreshAndAccurate(
|
|
position: position,
|
|
now: now,
|
|
);
|
|
final String locationState = isGpsFresh
|
|
? 'fresh'
|
|
: (_lastObservedPosition != null ? 'stale' : 'unavailable');
|
|
final double? locAgeSec = _lastObservedPosition != null
|
|
? now.difference(_lastObservedPosition!.timestamp).inMilliseconds / 1000.0
|
|
: null;
|
|
|
|
final String? lastDashboardText = dashboardEligibleAlerts.isNotEmpty
|
|
? '${dashboardEligibleAlerts.first.category.displayName} • ${RoadAlertPresentationPolicy.formatDistance(dashboardEligibleAlerts.first.distanceFromUser, isAhead: true)}'
|
|
: null;
|
|
|
|
_ref.read(roadAlertDiagnosticsProvider.notifier).updateDiagnostics(
|
|
RoadAlertDiagnostics(
|
|
providerStatus: zylaProvider.lastStatus != RoadAlertProviderStatus.notConfigured
|
|
? zylaProvider.lastStatus
|
|
: zylaStatus,
|
|
statusMessage: zylaProvider.lastError ??
|
|
(zylaStatus == RoadAlertProviderStatus.connected ? 'Operational' : zylaStatus.name),
|
|
lastFetchTime: _lastFetchTime ?? now,
|
|
lastSuccessfulFetchTime: _lastSuccessfulFetchTime,
|
|
lastRequestedArea: areaStr,
|
|
lastRequestedRadiusMeters: tacticalRadiusMeters,
|
|
isLive: zylaProvider.lastWasLive,
|
|
rawReportsCount: zylaProvider.lastRawCount,
|
|
normalizedAcceptedCount: _cachedAlerts.length + staleCount + disabledCategoryCount + outOfRangeCount,
|
|
rejectedReasons: rejectedReasons,
|
|
displayedOnMapCount: compliantList.length,
|
|
dashboardEligibleCount: dashboardEligibleAlerts.length,
|
|
audioCandidateCount: audioCandidates.length,
|
|
activeRouteRelevance: hasActiveRoute,
|
|
locationFreshnessState: locationState,
|
|
lastLocationAgeSeconds: locAgeSec,
|
|
locationAccuracyMeters: position.accuracy,
|
|
directionRejectedCount: directionRejectedCount,
|
|
distanceRejectedCount: distanceRejectedCount,
|
|
staleGpsRejectedCount: staleGpsRejectedCount,
|
|
lastDashboardAlertPresented: lastDashboardText,
|
|
lastAudioWarningIssued: _lastAudioWarningIssued,
|
|
lastError: zylaProvider.lastError,
|
|
),
|
|
);
|
|
}
|
|
|
|
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 (providers.isNotEmpty) {
|
|
_lastSuccessfulFetchTime = DateTime.now();
|
|
}
|
|
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([]);
|
|
_dashboardAlertsController.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();
|
|
_dashboardAlertsController.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 = 3218.688,
|
|
}) {
|
|
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;
|
|
}
|
|
}
|