From 930b7a9f558ca2d567a88014f248d847cdcefbd4 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 27 Aug 2026 03:47:24 -0400 Subject: [PATCH] Pre-Police-Alert-Feature --- lib/features/map/map_screen.dart | 104 ++++++++- lib/features/settings/settings_screen.dart | 217 +++++++++++++++++- .../alerts/local_road_alert_provider.dart | 89 +++++++ lib/services/alerts/road_alert_models.dart | 170 ++++++++++++++ .../alerts/road_alert_provider_interface.dart | 16 ++ lib/services/alerts/road_alert_providers.dart | 119 ++++++++++ lib/services/alerts/road_alert_service.dart | 175 ++++++++++++++ .../alerts/tomtom_road_alert_provider.dart | 162 +++++++++++++ test/road_alert_test.dart | 61 +++++ 9 files changed, 1109 insertions(+), 4 deletions(-) create mode 100644 lib/services/alerts/local_road_alert_provider.dart create mode 100644 lib/services/alerts/road_alert_models.dart create mode 100644 lib/services/alerts/road_alert_provider_interface.dart create mode 100644 lib/services/alerts/road_alert_providers.dart create mode 100644 lib/services/alerts/road_alert_service.dart create mode 100644 lib/services/alerts/tomtom_road_alert_provider.dart create mode 100644 test/road_alert_test.dart diff --git a/lib/features/map/map_screen.dart b/lib/features/map/map_screen.dart index 7bb49f9..e0a2642 100644 --- a/lib/features/map/map_screen.dart +++ b/lib/features/map/map_screen.dart @@ -7,6 +7,8 @@ import '../../services/location_service.dart'; import '../../services/map/map_provider_interface.dart'; import '../../services/map/map_providers.dart'; import '../../services/map/unified_map_view.dart'; +import '../../services/alerts/road_alert_models.dart'; +import '../../services/alerts/road_alert_service.dart'; class MapScreen extends ConsumerStatefulWidget { final int? rideId; @@ -159,6 +161,81 @@ class _MapScreenState extends ConsumerState { }).toList(); } + void _showRoadAlertDetails(RoadAlert alert) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.grey[900], + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) => Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: alert.category.color.withValues(alpha: 0.2), + shape: BoxShape.circle, + border: Border.all(color: alert.category.color), + ), + child: Icon(alert.category.icon, color: alert.category.color, size: 28), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + alert.category.displayName.toUpperCase(), + style: TextStyle( + color: alert.category.color, + fontWeight: FontWeight.bold, + letterSpacing: 1.5, + fontSize: 14, + ), + ), + if (alert.roadName != null && alert.roadName!.isNotEmpty) + Text(alert.roadName!, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.white10, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + alert.sourceType == RoadAlertSourceType.userReported ? 'User Reported' : 'Verified Provider', + style: const TextStyle(fontSize: 10, color: Colors.white70), + ), + ), + ], + ), + const SizedBox(height: 16), + Text(alert.description, style: const TextStyle(fontSize: 15, height: 1.3)), + const SizedBox(height: 16), + const Divider(color: Colors.white24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (alert.distanceFromUser != null) + Text('Distance: ${(alert.distanceFromUser! * 0.000621371).toStringAsFixed(2)} mi', + style: const TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold)), + Text('Source: ${alert.source}', style: const TextStyle(color: Colors.white54, fontSize: 12)), + ], + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { if (_isLoadingHistory) { @@ -206,6 +283,7 @@ class _MapScreenState extends ConsumerState { // Live Tracking View final locationAsync = ref.watch(locationStreamProvider); + final liveAlertsAsync = ref.watch(activeAlertsStreamProvider); final currentPos = locationAsync.when( data: (pos) => AppLatLng(pos.latitude, pos.longitude), @@ -230,6 +308,30 @@ class _MapScreenState extends ConsumerState { ), ); + final List mapMarkers = [motorcycleMarker]; + + final liveAlerts = liveAlertsAsync.value ?? []; + for (var alert in liveAlerts) { + mapMarkers.add( + AppMapMarker( + id: 'alert_${alert.providerId}_${alert.id}', + position: AppLatLng(alert.latitude, alert.longitude), + heading: alert.heading ?? 0.0, + label: alert.category.displayName, + iconWidget: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: alert.category.color.withValues(alpha: 0.9), + shape: BoxShape.circle, + boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 4)], + ), + child: Icon(alert.category.icon, color: Colors.black, size: 22), + ), + onTap: () => _showRoadAlertDetails(alert), + ), + ); + } + return Scaffold( appBar: AppBar( title: const Text('Live Map'), @@ -257,7 +359,7 @@ class _MapScreenState extends ConsumerState { UnifiedMapView( center: currentPos, zoom: 17.0, - markers: [motorcycleMarker], + markers: mapMarkers, ), if (!isFollowMode) Positioned( diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 2f9e932..22a9a25 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -4,14 +4,40 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../services/sensor_fusion_service.dart'; import '../../services/map/map_provider_interface.dart'; import '../../services/map/map_providers.dart'; +import '../../services/alerts/road_alert_models.dart'; +import '../../services/alerts/road_alert_providers.dart'; import '../../database/database.dart'; -class SettingsScreen extends ConsumerWidget { +class SettingsScreen extends ConsumerStatefulWidget { const SettingsScreen({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends ConsumerState { + late TextEditingController _tomtomKeyController; + bool _isTestingKey = false; + + @override + void initState() { + super.initState(); + _tomtomKeyController = TextEditingController(text: ref.read(tomtomApiKeyProvider)); + } + + @override + void dispose() { + _tomtomKeyController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { final mapType = ref.watch(mapProviderTypeNotifierProvider); + final roadAlertsEnabled = ref.watch(roadAlertsEnabledProvider); + final audioAlertsEnabled = ref.watch(audioAlertsEnabledProvider); + final alertRadiusMeters = ref.watch(alertRadiusMetersProvider); + final tomtomStatus = ref.watch(tomtomStatusProvider); return Scaffold( appBar: AppBar(title: const Text('Settings')), @@ -19,7 +45,7 @@ class SettingsScreen extends ConsumerWidget { padding: const EdgeInsets.all(16.0), children: [ const Text( - 'Map & Provider Settings', + 'Maps & Navigation Engine', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.cyanAccent), ), const SizedBox(height: 16), @@ -71,6 +97,148 @@ class SettingsScreen extends ConsumerWidget { ), ), ), + + const SizedBox(height: 32), + const Text( + 'Road Alerts & Incidents', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.cyanAccent), + ), + const SizedBox(height: 16), + Card( + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + SwitchListTile( + activeColor: Colors.cyanAccent, + title: const Text('Road Alerts', style: TextStyle(fontWeight: FontWeight.bold)), + subtitle: const Text('Display real-time traffic incidents, hazards, and road closures on map.'), + value: roadAlertsEnabled, + onChanged: (val) { + ref.read(roadAlertsEnabledProvider.notifier).setEnabled(val); + }, + ), + const Divider(), + SwitchListTile( + activeColor: Colors.cyanAccent, + title: const Text('Audio Warnings (TTS)', style: TextStyle(fontWeight: FontWeight.bold)), + subtitle: const Text('Announce approaching hazards over helmet headset.'), + value: audioAlertsEnabled, + onChanged: roadAlertsEnabled + ? (val) { + ref.read(audioAlertsEnabledProvider.notifier).setEnabled(val); + } + : null, + ), + const Divider(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Alert Radius', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + SizedBox(height: 4), + Text('Distance to detect upcoming incidents', style: TextStyle(fontSize: 12, color: Colors.white70)), + ], + ), + DropdownButton( + value: alertRadiusMeters, + items: const [ + DropdownMenuItem(value: 152.4, child: Text('500 ft')), + DropdownMenuItem(value: 304.8, child: Text('1,000 ft')), + DropdownMenuItem(value: 402.3, child: Text('1/4 Mile')), + DropdownMenuItem(value: 804.7, child: Text('1/2 Mile')), + DropdownMenuItem(value: 1609.3, child: Text('1 Mile')), + ], + onChanged: roadAlertsEnabled + ? (val) { + if (val != null) { + ref.read(alertRadiusMetersProvider.notifier).setRadius(val); + } + } + : null, + ), + ], + ), + ), + ], + ), + ), + ), + + const SizedBox(height: 16), + // TomTom Provider API Key Card + Card( + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.security, color: Colors.cyanAccent), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('TomTom Traffic Provider', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + SizedBox(height: 4), + Text('Supplies Accidents, Hazards, Closures & Construction data.', style: TextStyle(fontSize: 12, color: Colors.white70)), + ], + ), + ), + _buildStatusBadge(tomtomStatus), + ], + ), + const SizedBox(height: 16), + TextField( + controller: _tomtomKeyController, + decoration: const InputDecoration( + labelText: 'TomTom API Key', + hintText: 'Enter your free TomTom API Key', + border: OutlineInputBorder(), + ), + onChanged: (val) { + ref.read(tomtomApiKeyProvider.notifier).setApiKey(val); + }, + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: ElevatedButton.icon( + onPressed: _isTestingKey + ? null + : () async { + setState(() => _isTestingKey = true); + final provider = ref.read(tomtomProviderInstance); + final status = await provider.testConnection(_tomtomKeyController.text); + ref.read(tomtomStatusProvider.notifier).state = status; + setState(() => _isTestingKey = false); + + if (mounted) { + String msg = 'Connected successfully!'; + if (status == RoadAlertProviderStatus.authError) msg = 'Authentication Error: Invalid API Key.'; + if (status == RoadAlertProviderStatus.notConfigured) msg = 'Key is empty.'; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + } + }, + icon: _isTestingKey + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.sync), + label: const Text('Test Connection'), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 32), const Text( 'Sensors', @@ -209,4 +377,47 @@ class SettingsScreen extends ConsumerWidget { ), ); } + + Widget _buildStatusBadge(RoadAlertProviderStatus status) { + String label; + Color color; + + switch (status) { + case RoadAlertProviderStatus.connected: + label = 'Connected'; + color = Colors.greenAccent; + break; + case RoadAlertProviderStatus.configured: + label = 'Configured'; + color = Colors.cyanAccent; + break; + case RoadAlertProviderStatus.authError: + label = 'Auth Error'; + color = Colors.redAccent; + break; + case RoadAlertProviderStatus.rateLimited: + label = 'Rate Limited'; + color = Colors.orangeAccent; + break; + case RoadAlertProviderStatus.networkError: + label = 'Network Error'; + color = Colors.amberAccent; + break; + case RoadAlertProviderStatus.notConfigured: + default: + label = 'Not Set'; + color = Colors.white38; + break; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + border: Border.all(color: color), + borderRadius: BorderRadius.circular(8), + ), + child: Text(label, style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.bold)), + ); + } } diff --git a/lib/services/alerts/local_road_alert_provider.dart b/lib/services/alerts/local_road_alert_provider.dart new file mode 100644 index 0000000..3a53a20 --- /dev/null +++ b/lib/services/alerts/local_road_alert_provider.dart @@ -0,0 +1,89 @@ +import 'road_alert_models.dart'; +import 'road_alert_provider_interface.dart'; + +class LocalRoadAlertProvider implements RoadAlertProvider { + @override + String get id => 'local_demo'; + + @override + String get name => 'Demo / Local Alerts'; + + @override + Set get supportedCategories => const { + RoadAlertCategory.police, + RoadAlertCategory.speedCamera, + RoadAlertCategory.accident, + RoadAlertCategory.hazard, + RoadAlertCategory.roadClosure, + RoadAlertCategory.construction, + RoadAlertCategory.traffic, + RoadAlertCategory.other, + }; + + @override + Future testConnection(String apiKey) async { + return RoadAlertProviderStatus.connected; + } + + @override + Future> getAlerts({ + required double latitude, + required double longitude, + required double radiusMeters, + String? apiKey, + }) async { + // Generate simulated road alerts within 200m-800m of the user for offline demo + final now = DateTime.now(); + + return [ + RoadAlert( + id: 'demo_hazard_1', + providerId: id, + category: RoadAlertCategory.hazard, + latitude: latitude + 0.0025, + longitude: longitude + 0.0018, + roadName: 'Main Highway / Curve 4', + description: 'Debris reported in right lane', + reportedAt: now.subtract(const Duration(minutes: 10)), + expiresAt: now.add(const Duration(minutes: 50)), + lastUpdated: now, + confidence: 0.9, + severity: RoadAlertSeverity.medium, + source: 'Demo Local Provider', + sourceType: RoadAlertSourceType.userReported, + ), + RoadAlert( + id: 'demo_construction_1', + providerId: id, + category: RoadAlertCategory.construction, + latitude: latitude - 0.0031, + longitude: longitude - 0.0022, + roadName: 'Valley Expressway', + description: 'Roadwork & lane closure ahead', + reportedAt: now.subtract(const Duration(minutes: 30)), + expiresAt: now.add(const Duration(hours: 2)), + lastUpdated: now, + confidence: 0.95, + severity: RoadAlertSeverity.high, + source: 'Demo Local Provider', + sourceType: RoadAlertSourceType.providerReported, + ), + RoadAlert( + id: 'demo_police_1', + providerId: id, + category: RoadAlertCategory.police, + latitude: latitude + 0.0042, + longitude: longitude - 0.0015, + roadName: 'State Route 9', + description: 'Police reported on shoulder', + reportedAt: now.subtract(const Duration(minutes: 5)), + expiresAt: now.add(const Duration(minutes: 25)), + lastUpdated: now, + confidence: 0.75, + severity: RoadAlertSeverity.medium, + source: 'Demo Community Report', + sourceType: RoadAlertSourceType.userReported, + ), + ]; + } +} diff --git a/lib/services/alerts/road_alert_models.dart b/lib/services/alerts/road_alert_models.dart new file mode 100644 index 0000000..cf2b914 --- /dev/null +++ b/lib/services/alerts/road_alert_models.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; + +enum RoadAlertCategory { + police, + speedCamera, + accident, + hazard, + roadClosure, + construction, + traffic, + other, +} + +extension RoadAlertCategoryExtension on RoadAlertCategory { + String get displayName { + switch (this) { + case RoadAlertCategory.police: + return 'Police'; + case RoadAlertCategory.speedCamera: + return 'Speed Camera'; + case RoadAlertCategory.accident: + return 'Accident'; + case RoadAlertCategory.hazard: + return 'Hazard'; + case RoadAlertCategory.roadClosure: + return 'Road Closure'; + case RoadAlertCategory.construction: + return 'Construction'; + case RoadAlertCategory.traffic: + return 'Traffic Delay'; + case RoadAlertCategory.other: + return 'Other'; + } + } + + IconData get icon { + switch (this) { + case RoadAlertCategory.police: + return Icons.local_police; + case RoadAlertCategory.speedCamera: + return Icons.camera_alt; + case RoadAlertCategory.accident: + return Icons.car_crash; + case RoadAlertCategory.hazard: + return Icons.warning_amber; + case RoadAlertCategory.roadClosure: + return Icons.do_not_disturb_on; + case RoadAlertCategory.construction: + return Icons.engineering; + case RoadAlertCategory.traffic: + return Icons.traffic; + case RoadAlertCategory.other: + return Icons.report_problem; + } + } + + Color get color { + switch (this) { + case RoadAlertCategory.police: + return Colors.blueAccent; + case RoadAlertCategory.speedCamera: + return Colors.purpleAccent; + case RoadAlertCategory.accident: + return Colors.redAccent; + case RoadAlertCategory.hazard: + return Colors.orangeAccent; + case RoadAlertCategory.roadClosure: + return Colors.deepOrange; + case RoadAlertCategory.construction: + return Colors.amber; + case RoadAlertCategory.traffic: + return Colors.yellowAccent; + case RoadAlertCategory.other: + return Colors.cyanAccent; + } + } +} + +enum RoadAlertSeverity { low, medium, high, critical } + +enum RoadAlertSourceType { providerReported, userReported } + +enum RoadAlertProviderStatus { + notConfigured, + configured, + connected, + rateLimited, + authError, + networkError, +} + +class RoadAlert { + final String id; + final String providerId; + final RoadAlertCategory category; + final double latitude; + final double longitude; + final double? heading; + final String? roadName; + final String description; + final DateTime reportedAt; + final DateTime? expiresAt; + final DateTime lastUpdated; + final double? confidence; // 0.0 to 1.0 + final RoadAlertSeverity severity; + final String source; + final String? sourceUrl; + final double? distanceFromUser; // meters + final RoadAlertSourceType sourceType; + + const RoadAlert({ + required this.id, + required this.providerId, + required this.category, + required this.latitude, + required this.longitude, + this.heading, + this.roadName, + required this.description, + required this.reportedAt, + this.expiresAt, + required this.lastUpdated, + this.confidence, + this.severity = RoadAlertSeverity.medium, + required this.source, + this.sourceUrl, + this.distanceFromUser, + this.sourceType = RoadAlertSourceType.providerReported, + }); + + RoadAlert copyWith({ + String? id, + String? providerId, + RoadAlertCategory? category, + double? latitude, + double? longitude, + double? heading, + String? roadName, + String? description, + DateTime? reportedAt, + DateTime? expiresAt, + DateTime? lastUpdated, + double? confidence, + RoadAlertSeverity? severity, + String? source, + String? sourceUrl, + double? distanceFromUser, + RoadAlertSourceType? sourceType, + }) { + return RoadAlert( + id: id ?? this.id, + providerId: providerId ?? this.providerId, + category: category ?? this.category, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + heading: heading ?? this.heading, + roadName: roadName ?? this.roadName, + description: description ?? this.description, + reportedAt: reportedAt ?? this.reportedAt, + expiresAt: expiresAt ?? this.expiresAt, + lastUpdated: lastUpdated ?? this.lastUpdated, + confidence: confidence ?? this.confidence, + severity: severity ?? this.severity, + source: source ?? this.source, + sourceUrl: sourceUrl ?? this.sourceUrl, + distanceFromUser: distanceFromUser ?? this.distanceFromUser, + sourceType: sourceType ?? this.sourceType, + ); + } +} diff --git a/lib/services/alerts/road_alert_provider_interface.dart b/lib/services/alerts/road_alert_provider_interface.dart new file mode 100644 index 0000000..742e40e --- /dev/null +++ b/lib/services/alerts/road_alert_provider_interface.dart @@ -0,0 +1,16 @@ +import 'road_alert_models.dart'; + +abstract class RoadAlertProvider { + String get id; + String get name; + Set get supportedCategories; + + Future> getAlerts({ + required double latitude, + required double longitude, + required double radiusMeters, + String? apiKey, + }); + + Future testConnection(String apiKey); +} diff --git a/lib/services/alerts/road_alert_providers.dart b/lib/services/alerts/road_alert_providers.dart new file mode 100644 index 0000000..0397045 --- /dev/null +++ b/lib/services/alerts/road_alert_providers.dart @@ -0,0 +1,119 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'road_alert_models.dart'; +import 'road_alert_provider_interface.dart'; +import 'tomtom_road_alert_provider.dart'; +import 'local_road_alert_provider.dart'; + +final tomtomProviderInstance = Provider((ref) { + return TomTomRoadAlertProvider(); +}); + +final localProviderInstance = Provider((ref) { + return LocalRoadAlertProvider(); +}); + +// Master Enable +class RoadAlertsEnabledNotifier extends StateNotifier { + static const _key = 'road_alerts_enabled'; + RoadAlertsEnabledNotifier() : super(true) { + _load(); + } + Future _load() async { + final prefs = await SharedPreferences.getInstance(); + state = prefs.getBool(_key) ?? true; + } + Future setEnabled(bool enabled) async { + state = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_key, enabled); + } +} +final roadAlertsEnabledProvider = + StateNotifierProvider((ref) { + return RoadAlertsEnabledNotifier(); +}); + +// Audio Alerts Toggle +class AudioAlertsEnabledNotifier extends StateNotifier { + static const _key = 'audio_alerts_enabled'; + AudioAlertsEnabledNotifier() : super(true) { + _load(); + } + Future _load() async { + final prefs = await SharedPreferences.getInstance(); + state = prefs.getBool(_key) ?? true; + } + Future setEnabled(bool enabled) async { + state = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_key, enabled); + } +} +final audioAlertsEnabledProvider = + StateNotifierProvider((ref) { + return AudioAlertsEnabledNotifier(); +}); + +// Alert Radius in Meters (500ft = 152.4m, 1000ft = 304.8m, 0.25mi = 402.3m, 0.5mi = 804.7m, 1mi = 1609.3m) +class AlertRadiusMetersNotifier extends StateNotifier { + static const _key = 'alert_radius_meters'; + AlertRadiusMetersNotifier() : super(804.7) { + _load(); + } + Future _load() async { + final prefs = await SharedPreferences.getInstance(); + state = prefs.getDouble(_key) ?? 804.7; + } + Future setRadius(double radiusMeters) async { + state = radiusMeters; + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_key, radiusMeters); + } +} +final alertRadiusMetersProvider = + StateNotifierProvider((ref) { + return AlertRadiusMetersNotifier(); +}); + +// TomTom API Key +class TomTomApiKeyNotifier extends StateNotifier { + static const _key = 'tomtom_api_key'; + TomTomApiKeyNotifier() : super('') { + _load(); + } + Future _load() async { + final prefs = await SharedPreferences.getInstance(); + state = prefs.getString(_key) ?? ''; + } + Future setApiKey(String apiKey) async { + state = apiKey.trim(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_key, state); + } +} +final tomtomApiKeyProvider = + StateNotifierProvider((ref) { + return TomTomApiKeyNotifier(); +}); + +// TomTom Connection Status +final tomtomStatusProvider = StateProvider((ref) { + final key = ref.watch(tomtomApiKeyProvider); + if (key.isEmpty) { + return RoadAlertProviderStatus.notConfigured; + } + return RoadAlertProviderStatus.configured; +}); + +// Active Providers List +final activeRoadAlertProviders = Provider>((ref) { + final tomtomKey = ref.watch(tomtomApiKeyProvider); + final list = []; + if (tomtomKey.isNotEmpty) { + list.add(ref.watch(tomtomProviderInstance)); + } + // Include demo local provider as fallback/demo + list.add(ref.watch(localProviderInstance)); + return list; +}); diff --git a/lib/services/alerts/road_alert_service.dart b/lib/services/alerts/road_alert_service.dart new file mode 100644 index 0000000..53919de --- /dev/null +++ b/lib/services/alerts/road_alert_service.dart @@ -0,0 +1,175 @@ +import 'dart:async'; +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 tomtomApiKey = _ref.read(tomtomApiKeyProvider); + + // Throttle fetches: fetch if 45s passed OR 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); + } + + // Update distance for cached alerts & filter out expired ones + final Map deduplicated = {}; + for (var alert in _cachedAlerts) { + if (alert.expiresAt != null && alert.expiresAt!.isBefore(now)) { + 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; + + // Check audio alert approach (< 400m) + if (audioEnabled && dist <= 400.0 && !_announcedAlertIds.contains(key)) { + _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); + } + + Future _fetchAlertsFromProviders( + double lat, double lng, double radiusMeters, String tomtomApiKey) async { + final providers = _ref.read(activeRoadAlertProviders); + final List newAlerts = []; + + for (var provider in providers) { + try { + final fetched = await provider.getAlerts( + latitude: lat, + longitude: lng, + radiusMeters: radiusMeters, + apiKey: provider.id == 'tomtom' ? tomtomApiKey : null, + ); + newAlerts.addAll(fetched); + } catch (_) {} + } + + _cachedAlerts = newAlerts; + } + + void _announceAlertAudio(RoadAlert alert) { + String phrase; + switch (alert.category) { + case RoadAlertCategory.police: + phrase = "Police reported 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(); + } +} diff --git a/lib/services/alerts/tomtom_road_alert_provider.dart b/lib/services/alerts/tomtom_road_alert_provider.dart new file mode 100644 index 0000000..03d73ed --- /dev/null +++ b/lib/services/alerts/tomtom_road_alert_provider.dart @@ -0,0 +1,162 @@ +import 'dart:convert'; +import 'dart:math' as math; +import 'package:http/http.dart' as http; +import 'road_alert_models.dart'; +import 'road_alert_provider_interface.dart'; + +class TomTomRoadAlertProvider implements RoadAlertProvider { + @override + String get id => 'tomtom'; + + @override + String get name => 'TomTom Traffic'; + + @override + Set get supportedCategories => const { + RoadAlertCategory.accident, + RoadAlertCategory.hazard, + RoadAlertCategory.construction, + RoadAlertCategory.roadClosure, + RoadAlertCategory.traffic, + }; + + @override + Future testConnection(String apiKey) async { + if (apiKey.trim().isEmpty) { + return RoadAlertProviderStatus.notConfigured; + } + try { + // Test query using a small bounding box over San Francisco + final url = Uri.parse( + 'https://api.tomtom.com/traffic/services/5/incidentDetails/s3/-122.42,37.77,-122.40,37.79/10/-1/json?key=$apiKey&language=en-GB'); + final response = await http.get(url).timeout(const Duration(seconds: 6)); + if (response.statusCode == 200) { + return RoadAlertProviderStatus.connected; + } else if (response.statusCode == 401 || response.statusCode == 403) { + return RoadAlertProviderStatus.authError; + } else if (response.statusCode == 429) { + return RoadAlertProviderStatus.rateLimited; + } + return RoadAlertProviderStatus.networkError; + } catch (_) { + return RoadAlertProviderStatus.networkError; + } + } + + @override + Future> getAlerts({ + required double latitude, + required double longitude, + required double radiusMeters, + String? apiKey, + }) async { + if (apiKey == null || apiKey.trim().isEmpty) { + return []; + } + + try { + // Convert radius to bounding box offset (~111,000m per degree of latitude) + final latOffset = radiusMeters / 111000.0; + final lngOffset = radiusMeters / (111000.0 * math.cos(latitude * math.pi / 180.0)); + + final minLng = longitude - lngOffset; + final minLat = latitude - latOffset; + final maxLng = longitude + lngOffset; + final maxLat = latitude + latOffset; + + final bboxStr = '${minLng.toStringAsFixed(4)},${minLat.toStringAsFixed(4)},${maxLng.toStringAsFixed(4)},${maxLat.toStringAsFixed(4)}'; + + final url = Uri.parse( + 'https://api.tomtom.com/traffic/services/5/incidentDetails/s3/$bboxStr/10/-1/json?key=$apiKey&language=en-GB'); + + final response = await http.get(url).timeout(const Duration(seconds: 8)); + + if (response.statusCode != 200) { + return []; + } + + final data = json.decode(response.body); + final List incidents = data['incidents'] ?? []; + final List alerts = []; + + for (var inc in incidents) { + final props = inc['properties'] ?? {}; + final geometry = inc['geometry'] ?? {}; + final coords = geometry['coordinates']; + + double incLat = latitude; + double incLng = longitude; + + if (coords is List && coords.isNotEmpty) { + if (coords[0] is List) { + incLng = (coords[0][0] as num).toDouble(); + incLat = (coords[0][1] as num).toDouble(); + } else if (coords.length >= 2) { + incLng = (coords[0] as num).toDouble(); + incLat = (coords[1] as num).toDouble(); + } + } + + final iconCategory = (props['iconCategory'] as num?)?.toInt() ?? 0; + final RoadAlertCategory category = _mapTomTomIconCategory(iconCategory); + + final description = (props['events'] != null && (props['events'] as List).isNotEmpty) + ? props['events'][0]['description'] ?? 'Traffic incident' + : (props['iconCategory'] != null ? 'Incident category $iconCategory' : 'Road Incident'); + + final alertId = props['id']?.toString() ?? '${incLat}_${incLng}'; + + alerts.add(RoadAlert( + id: alertId, + providerId: id, + category: category, + latitude: incLat, + longitude: incLng, + roadName: props['from'] ?? props['to'], + description: description.toString(), + reportedAt: DateTime.now(), + expiresAt: DateTime.now().add(const Duration(hours: 1)), + lastUpdated: DateTime.now(), + severity: _mapTomTomMagnitude(props['magnitudeOfDelay'] as num?), + source: 'TomTom Traffic API', + sourceType: RoadAlertSourceType.providerReported, + )); + } + + return alerts; + } catch (_) { + return []; + } + } + + RoadAlertCategory _mapTomTomIconCategory(int iconCategory) { + switch (iconCategory) { + case 1: // Accident + return RoadAlertCategory.accident; + case 2: // Fog / Hazard + case 3: // Dangerous Conditions + case 4: // Rain / Ice / Snow + case 5: // Wind / Weather + return RoadAlertCategory.hazard; + case 6: // Lane restriction / Construction + case 7: // Roadworks + return RoadAlertCategory.construction; + case 8: // Road closed + case 9: // Blocked road + return RoadAlertCategory.roadClosure; + case 10: // Traffic Jam + case 11: // Slow traffic + return RoadAlertCategory.traffic; + default: + return RoadAlertCategory.hazard; + } + } + + RoadAlertSeverity _mapTomTomMagnitude(num? magnitude) { + if (magnitude == null) return RoadAlertSeverity.medium; + if (magnitude >= 3) return RoadAlertSeverity.critical; + if (magnitude >= 2) return RoadAlertSeverity.high; + if (magnitude >= 1) return RoadAlertSeverity.medium; + return RoadAlertSeverity.low; + } +} diff --git a/test/road_alert_test.dart b/test/road_alert_test.dart new file mode 100644 index 0000000..a056e0f --- /dev/null +++ b/test/road_alert_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:motorcycle_dash/services/alerts/road_alert_models.dart'; +import 'package:motorcycle_dash/services/alerts/tomtom_road_alert_provider.dart'; +import 'package:motorcycle_dash/services/alerts/local_road_alert_provider.dart'; +import 'package:motorcycle_dash/services/alerts/road_alert_providers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Road Alert System Tests', () { + test('RoadAlertCategory display names and colors', () { + expect(RoadAlertCategory.police.displayName, equals('Police')); + expect(RoadAlertCategory.accident.displayName, equals('Accident')); + expect(RoadAlertCategory.hazard.displayName, equals('Hazard')); + + expect(RoadAlertCategory.police.color, isNotNull); + expect(RoadAlertCategory.accident.icon, isNotNull); + }); + + test('TomTomRoadAlertProvider capability advertising', () { + final provider = TomTomRoadAlertProvider(); + expect(provider.id, equals('tomtom')); + expect(provider.name, equals('TomTom Traffic')); + + // Verify TomTom explicitly advertises its supported categories (No police/speed-traps) + expect(provider.supportedCategories, contains(RoadAlertCategory.accident)); + expect(provider.supportedCategories, contains(RoadAlertCategory.hazard)); + expect(provider.supportedCategories, contains(RoadAlertCategory.construction)); + expect(provider.supportedCategories, contains(RoadAlertCategory.roadClosure)); + expect(provider.supportedCategories, contains(RoadAlertCategory.traffic)); + + expect(provider.supportedCategories, isNot(contains(RoadAlertCategory.police))); + expect(provider.supportedCategories, isNot(contains(RoadAlertCategory.speedCamera))); + }); + + test('LocalRoadAlertProvider returns demo alerts', () async { + final localProvider = LocalRoadAlertProvider(); + final alerts = await localProvider.getAlerts( + latitude: 37.7749, + longitude: -122.4194, + radiusMeters: 1000.0, + ); + + expect(alerts.isNotEmpty, isTrue); + expect(alerts.any((a) => a.category == RoadAlertCategory.hazard), isTrue); + expect(alerts.any((a) => a.category == RoadAlertCategory.construction), isTrue); + }); + + test('Road alert providers & setting defaults', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + expect(container.read(roadAlertsEnabledProvider), isTrue); + expect(container.read(audioAlertsEnabledProvider), isTrue); + expect(container.read(alertRadiusMetersProvider), equals(804.7)); + expect(container.read(tomtomApiKeyProvider), isEmpty); + expect(container.read(tomtomStatusProvider), equals(RoadAlertProviderStatus.notConfigured)); + }); + }); +}