410 lines
12 KiB
Dart
410 lines
12 KiB
Dart
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';
|
|
|
|
import '../field_test/field_test_logger.dart';
|
|
import '../../models/field_test_event.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;
|
|
bool _isFetching = false; // Request serialization lock
|
|
DateTime? _lastSpeechTime; // Audio cooldown timer
|
|
|
|
List<RoadAlert> _cachedAlerts = [];
|
|
final Set<String> _announcedAlertIds = {};
|
|
final Set<String> _dismissedAlertIds = {};
|
|
Timer? _cleanupTimer;
|
|
|
|
Stream<List<RoadAlert>> get alertsStream => _alertsController.stream;
|
|
|
|
RoadAlertService(this._ref) {
|
|
_initTts();
|
|
_startListening();
|
|
_cleanupTimer = Timer.periodic(const Duration(seconds: 2), (_) {
|
|
_pruneExpiredAlerts();
|
|
});
|
|
}
|
|
|
|
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<void> _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<String, RoadAlert> 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<void> _fetchAlertsFromProviders(
|
|
double lat,
|
|
double lng,
|
|
double radiusMeters, {
|
|
required String tomtomApiKey,
|
|
required String communityApiKey,
|
|
}) 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;
|
|
|
|
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) {
|
|
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);
|
|
_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 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);
|
|
_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;
|
|
_cachedAlerts = [alert, ..._cachedAlerts];
|
|
_alertsController.add(_cachedAlerts);
|
|
if (_ref.read(audioAlertsEnabledProvider)) {
|
|
_announceAlertAudio(alert);
|
|
}
|
|
}
|
|
|
|
void dispose() {
|
|
if (kDebugMode) {
|
|
debugPrint('[RoadAlerts] Disposing service resources');
|
|
}
|
|
_cleanupTimer?.cancel();
|
|
_locationSub?.cancel();
|
|
_alertsController.close();
|
|
try {
|
|
_tts.stop();
|
|
} catch (_) {}
|
|
}
|
|
}
|