From 3e9f8eb8e75ac0f1c23eb13b7c6c144df2a96c1a Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 27 Aug 2026 03:49:54 -0400 Subject: [PATCH] Pre-Production-Hardening --- lib/features/settings/settings_screen.dart | 121 ++++++++++++++-- .../alerts/community_traffic_parser.dart | 135 ++++++++++++++++++ .../alerts/community_traffic_provider.dart | 100 +++++++++++++ .../alerts/mock_road_alert_provider.dart | 95 ++++++++++++ lib/services/alerts/road_alert_providers.dart | 88 ++++++++++-- lib/services/alerts/road_alert_service.dart | 63 ++++++-- test/police_alert_test.dart | 94 ++++++++++++ 7 files changed, 664 insertions(+), 32 deletions(-) create mode 100644 lib/services/alerts/community_traffic_parser.dart create mode 100644 lib/services/alerts/community_traffic_provider.dart create mode 100644 lib/services/alerts/mock_road_alert_provider.dart create mode 100644 test/police_alert_test.dart diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 22a9a25..0f1336a 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -17,17 +17,21 @@ class SettingsScreen extends ConsumerStatefulWidget { class _SettingsScreenState extends ConsumerState { late TextEditingController _tomtomKeyController; - bool _isTestingKey = false; + late TextEditingController _communityKeyController; + bool _isTestingTomTom = false; + bool _isTestingCommunity = false; @override void initState() { super.initState(); _tomtomKeyController = TextEditingController(text: ref.read(tomtomApiKeyProvider)); + _communityKeyController = TextEditingController(text: ref.read(communityApiKeyProvider)); } @override void dispose() { _tomtomKeyController.dispose(); + _communityKeyController.dispose(); super.dispose(); } @@ -37,7 +41,9 @@ class _SettingsScreenState extends ConsumerState { final roadAlertsEnabled = ref.watch(roadAlertsEnabledProvider); final audioAlertsEnabled = ref.watch(audioAlertsEnabledProvider); final alertRadiusMeters = ref.watch(alertRadiusMetersProvider); + final enabledCategories = ref.watch(enabledCategoriesProvider); final tomtomStatus = ref.watch(tomtomStatusProvider); + final communityStatus = ref.watch(communityStatusProvider); return Scaffold( appBar: AppBar(title: const Text('Settings')), @@ -109,11 +115,12 @@ class _SettingsScreenState extends ConsumerState { child: Padding( padding: const EdgeInsets.all(16.0), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ SwitchListTile( - activeColor: Colors.cyanAccent, + activeThumbColor: 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.'), + subtitle: const Text('Display real-time police, hazards, and traffic incidents on map.'), value: roadAlertsEnabled, onChanged: (val) { ref.read(roadAlertsEnabledProvider.notifier).setEnabled(val); @@ -121,9 +128,9 @@ class _SettingsScreenState extends ConsumerState { ), const Divider(), SwitchListTile( - activeColor: Colors.cyanAccent, + activeThumbColor: Colors.cyanAccent, title: const Text('Audio Warnings (TTS)', style: TextStyle(fontWeight: FontWeight.bold)), - subtitle: const Text('Announce approaching hazards over helmet headset.'), + subtitle: const Text('Announce approaching hazards and enforcement over helmet headset.'), value: audioAlertsEnabled, onChanged: roadAlertsEnabled ? (val) { @@ -165,6 +172,97 @@ class _SettingsScreenState extends ConsumerState { ], ), ), + const Divider(), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Text('Alert Categories', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + ), + ...RoadAlertCategory.values.map((cat) { + final isChecked = enabledCategories.contains(cat); + return CheckboxListTile( + activeColor: Colors.cyanAccent, + checkColor: Colors.black, + title: Text(cat.displayName), + secondary: Icon(cat.icon, color: cat.color), + value: isChecked, + onChanged: roadAlertsEnabled + ? (val) { + ref + .read(enabledCategoriesProvider.notifier) + .toggleCategory(cat, val ?? true); + } + : null, + ); + }), + ], + ), + ), + ), + + const SizedBox(height: 16), + // Community Traffic Provider Card + Card( + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.local_police, color: Colors.blueAccent), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Community Traffic Provider', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + SizedBox(height: 4), + Text('Supplies Police, Speed Cameras, and Crowd Hazards.', style: TextStyle(fontSize: 12, color: Colors.white70)), + ], + ), + ), + _buildStatusBadge(communityStatus), + ], + ), + const SizedBox(height: 16), + TextField( + controller: _communityKeyController, + decoration: const InputDecoration( + labelText: 'Community Provider API Key', + hintText: 'Enter API Key for Community Traffic Feed', + border: OutlineInputBorder(), + ), + onChanged: (val) { + ref.read(communityApiKeyProvider.notifier).setApiKey(val); + }, + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: ElevatedButton.icon( + onPressed: _isTestingCommunity + ? null + : () async { + setState(() => _isTestingCommunity = true); + final provider = ref.read(communityProviderInstance); + final status = await provider.testConnection(_communityKeyController.text); + ref.read(communityStatusProvider.notifier).state = status; + setState(() => _isTestingCommunity = false); + + if (mounted) { + String msg = 'Community API Connected successfully!'; + if (status == RoadAlertProviderStatus.authError) msg = 'Authentication Error: Invalid Key.'; + if (status == RoadAlertProviderStatus.notConfigured) msg = 'Key is empty.'; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + } + }, + icon: _isTestingCommunity + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.sync), + label: const Text('Test Connection'), + ), + ), ], ), ), @@ -212,23 +310,23 @@ class _SettingsScreenState extends ConsumerState { Align( alignment: Alignment.centerRight, child: ElevatedButton.icon( - onPressed: _isTestingKey + onPressed: _isTestingTomTom ? null : () async { - setState(() => _isTestingKey = true); + setState(() => _isTestingTomTom = true); final provider = ref.read(tomtomProviderInstance); final status = await provider.testConnection(_tomtomKeyController.text); ref.read(tomtomStatusProvider.notifier).state = status; - setState(() => _isTestingKey = false); + setState(() => _isTestingTomTom = false); if (mounted) { - String msg = 'Connected successfully!'; - if (status == RoadAlertProviderStatus.authError) msg = 'Authentication Error: Invalid API Key.'; + String msg = 'TomTom API Connected successfully!'; + if (status == RoadAlertProviderStatus.authError) msg = 'Authentication Error: Invalid Key.'; if (status == RoadAlertProviderStatus.notConfigured) msg = 'Key is empty.'; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); } }, - icon: _isTestingKey + icon: _isTestingTomTom ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.sync), label: const Text('Test Connection'), @@ -404,7 +502,6 @@ class _SettingsScreenState extends ConsumerState { color = Colors.amberAccent; break; case RoadAlertProviderStatus.notConfigured: - default: label = 'Not Set'; color = Colors.white38; break; diff --git a/lib/services/alerts/community_traffic_parser.dart b/lib/services/alerts/community_traffic_parser.dart new file mode 100644 index 0000000..9590e76 --- /dev/null +++ b/lib/services/alerts/community_traffic_parser.dart @@ -0,0 +1,135 @@ +import 'road_alert_models.dart'; + +class CommunityTrafficParser { + static List parseAlertsJson( + Map json, { + required String providerId, + required String providerName, + }) { + final List alerts = []; + final rawAlerts = json['alerts'] ?? json['incidents'] ?? []; + + if (rawAlerts is! List) return alerts; + + for (var item in rawAlerts) { + if (item is! Map) continue; + + final id = item['id']?.toString() ?? item['uuid']?.toString() ?? '${item['latitude']}_${item['longitude']}'; + final typeStr = (item['type'] ?? item['category'] ?? item['typeGroup'] ?? '').toString().toUpperCase(); + final subTypeStr = (item['subtype'] ?? item['subCategory'] ?? '').toString().toUpperCase(); + + final category = mapTypeToCategory(typeStr, subTypeStr); + + final double? lat = _toDouble(item['latitude'] ?? item['lat'] ?? (item['location'] != null ? item['location']['y'] : null)); + final double? lng = _toDouble(item['longitude'] ?? item['lon'] ?? item['lng'] ?? (item['location'] != null ? item['location']['x'] : null)); + + if (lat == null || lng == null) continue; + + final double? heading = _toDouble(item['heading'] ?? item['bearing'] ?? item['direction']); + final String? street = item['street']?.toString() ?? item['road']?.toString() ?? item['locationName']?.toString(); + final String description = _buildDescription(typeStr, subTypeStr, item['reportDescription'] ?? item['description']); + + final now = DateTime.now(); + final int nMinutesOld = (item['pubMillis'] != null) + ? ((now.millisecondsSinceEpoch - (item['pubMillis'] as num).toInt()) / 60000).clamp(0, 120).toInt() + : 5; + + final reportedAt = now.subtract(Duration(minutes: nMinutesOld)); + final expiresAt = now.add(const Duration(minutes: 30)); + + final double? confidence = _toDouble(item['confidence'] ?? item['reliability']); + final severity = _mapSeverity(item['severity'] ?? item['reliability']); + + alerts.add(RoadAlert( + id: id, + providerId: providerId, + category: category, + latitude: lat, + longitude: lng, + heading: heading, + roadName: street, + description: description, + reportedAt: reportedAt, + expiresAt: expiresAt, + lastUpdated: now, + confidence: confidence != null ? (confidence > 1.0 ? confidence / 10.0 : confidence) : 0.8, + severity: severity, + source: providerName, + sourceType: RoadAlertSourceType.userReported, + )); + } + + return alerts; + } + + static RoadAlertCategory mapTypeToCategory(String type, String subtype) { + final combined = '${type}_$subtype'; + + if (combined.contains('POLICE') || + type == 'POLICE' || + subtype.contains('POLICE_VISIBLE') || + subtype.contains('POLICE_HIDDEN') || + subtype.contains('POLICE_TRAP')) { + return RoadAlertCategory.police; + } + + if (combined.contains('SPEED_CAM') || + combined.contains('CAMERA') || + subtype.contains('SPEED_CAMERA') || + subtype.contains('RED_LIGHT_CAMERA')) { + return RoadAlertCategory.speedCamera; + } + + if (combined.contains('ACCIDENT') || type == 'ACCIDENT') { + return RoadAlertCategory.accident; + } + + if (combined.contains('HAZARD') || type == 'HAZARD' || subtype.contains('DEBRIS') || subtype.contains('WEATHER')) { + return RoadAlertCategory.hazard; + } + + if (combined.contains('ROAD_CLOSED') || combined.contains('CLOSURE') || type == 'ROAD_CLOSED') { + return RoadAlertCategory.roadClosure; + } + + if (combined.contains('CONSTRUCTION') || combined.contains('ROAD_WORKS') || type == 'CONSTRUCTION') { + return RoadAlertCategory.construction; + } + + if (combined.contains('JAM') || combined.contains('TRAFFIC') || type == 'JAM') { + return RoadAlertCategory.traffic; + } + + return RoadAlertCategory.other; + } + + static String _buildDescription(String type, String subtype, dynamic rawDesc) { + if (rawDesc != null && rawDesc.toString().trim().isNotEmpty) { + return rawDesc.toString(); + } + if (subtype.contains('POLICE_HIDDEN')) return 'Police reported hidden on shoulder'; + if (subtype.contains('POLICE_VISIBLE')) return 'Police reported visible ahead'; + if (type == 'POLICE') return 'Police report in area'; + if (subtype.contains('SPEED_CAMERA')) return 'Speed enforcement camera reported'; + if (type == 'ACCIDENT') return 'Traffic accident reported'; + if (type == 'HAZARD') return 'Road hazard reported'; + if (type == 'ROAD_CLOSED') return 'Road closed'; + if (type == 'CONSTRUCTION') return 'Construction zone'; + return 'Community traffic report'; + } + + static RoadAlertSeverity _mapSeverity(dynamic val) { + if (val == null) return RoadAlertSeverity.medium; + final num n = (val is num) ? val : num.tryParse(val.toString()) ?? 5; + if (n >= 8) return RoadAlertSeverity.critical; + if (n >= 5) return RoadAlertSeverity.high; + if (n >= 3) return RoadAlertSeverity.medium; + return RoadAlertSeverity.low; + } + + static double? _toDouble(dynamic val) { + if (val == null) return null; + if (val is num) return val.toDouble(); + return double.tryParse(val.toString()); + } +} diff --git a/lib/services/alerts/community_traffic_provider.dart b/lib/services/alerts/community_traffic_provider.dart new file mode 100644 index 0000000..a2f40f6 --- /dev/null +++ b/lib/services/alerts/community_traffic_provider.dart @@ -0,0 +1,100 @@ +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'; +import 'community_traffic_parser.dart'; + +class CommunityTrafficRoadAlertProvider implements RoadAlertProvider { + final String _customEndpointUrl; + + CommunityTrafficRoadAlertProvider({String? endpointUrl}) + : _customEndpointUrl = endpointUrl ?? ''; + + @override + String get id => 'community_traffic'; + + @override + String get name => 'Community Traffic API'; + + @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 { + if (apiKey.trim().isEmpty && _customEndpointUrl.isEmpty) { + return RoadAlertProviderStatus.notConfigured; + } + + try { + final endpoint = _customEndpointUrl.isNotEmpty + ? _customEndpointUrl + : 'https://api.communitytraffic.org/v1/incidents'; + + final uri = Uri.parse('$endpoint?top=37.80&bottom=37.70&left=-122.50&right=-122.35&apiKey=$apiKey'); + final response = await http.get(uri).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) && _customEndpointUrl.isEmpty) { + return []; + } + + try { + final latOffset = radiusMeters / 111000.0; + final lngOffset = radiusMeters / (111000.0 * math.cos(latitude * math.pi / 180.0)); + + final top = (latitude + latOffset).toStringAsFixed(5); + final bottom = (latitude - latOffset).toStringAsFixed(5); + final left = (longitude - lngOffset).toStringAsFixed(5); + final right = (longitude + lngOffset).toStringAsFixed(5); + + final endpoint = _customEndpointUrl.isNotEmpty + ? _customEndpointUrl + : 'https://api.communitytraffic.org/v1/incidents'; + + final uri = Uri.parse('$endpoint?top=$top&bottom=$bottom&left=$left&right=$right&apiKey=${apiKey ?? ''}'); + final response = await http.get(uri).timeout(const Duration(seconds: 8)); + + if (response.statusCode != 200) { + return []; + } + + final Map jsonMap = json.decode(response.body); + return CommunityTrafficParser.parseAlertsJson( + jsonMap, + providerId: id, + providerName: name, + ); + } catch (_) { + return []; + } + } +} diff --git a/lib/services/alerts/mock_road_alert_provider.dart b/lib/services/alerts/mock_road_alert_provider.dart new file mode 100644 index 0000000..457b92c --- /dev/null +++ b/lib/services/alerts/mock_road_alert_provider.dart @@ -0,0 +1,95 @@ +import 'package:flutter/foundation.dart'; +import 'road_alert_models.dart'; +import 'road_alert_provider_interface.dart'; + +class MockRoadAlertProvider implements RoadAlertProvider { + @override + String get id => 'mock_police_debug'; + + @override + String get name => 'Debug Mock Police & Speed-Traps'; + + @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 kDebugMode ? RoadAlertProviderStatus.connected : RoadAlertProviderStatus.notConfigured; + } + + @override + Future> getAlerts({ + required double latitude, + required double longitude, + required double radiusMeters, + String? apiKey, + }) async { + // Safety check: Never return mock alerts in release mode + if (!kDebugMode) return []; + + final now = DateTime.now(); + + return [ + RoadAlert( + id: 'mock_police_1', + providerId: id, + category: RoadAlertCategory.police, + latitude: latitude + 0.0035, // ~380 meters ahead + longitude: longitude + 0.0012, + heading: 45.0, + roadName: 'Highway 101 North / Mile Marker 14', + description: 'Police reported visible on shoulder (Debug Mock)', + reportedAt: now.subtract(const Duration(minutes: 4)), + expiresAt: now.add(const Duration(minutes: 26)), + lastUpdated: now, + confidence: 0.88, + severity: RoadAlertSeverity.high, + source: 'Community Report (Debug Mock)', + sourceType: RoadAlertSourceType.userReported, + ), + RoadAlert( + id: 'mock_speedcam_1', + providerId: id, + category: RoadAlertCategory.speedCamera, + latitude: latitude + 0.0055, // ~600 meters ahead + longitude: longitude - 0.0020, + heading: 90.0, + roadName: 'Grand Avenue & 5th St', + description: 'Speed enforcement camera active (Debug Mock)', + reportedAt: now.subtract(const Duration(minutes: 15)), + expiresAt: now.add(const Duration(hours: 1)), + lastUpdated: now, + confidence: 0.95, + severity: RoadAlertSeverity.high, + source: 'Camera Database (Debug Mock)', + sourceType: RoadAlertSourceType.providerReported, + ), + RoadAlert( + id: 'mock_accident_1', + providerId: id, + category: RoadAlertCategory.accident, + latitude: latitude - 0.0040, + longitude: longitude - 0.0030, + heading: 180.0, + roadName: 'Bay Bridge Connector', + description: 'Two-car collision right lane (Debug Mock)', + reportedAt: now.subtract(const Duration(minutes: 8)), + expiresAt: now.add(const Duration(minutes: 40)), + lastUpdated: now, + confidence: 0.92, + severity: RoadAlertSeverity.critical, + source: 'Community Report (Debug Mock)', + sourceType: RoadAlertSourceType.userReported, + ), + ]; + } +} diff --git a/lib/services/alerts/road_alert_providers.dart b/lib/services/alerts/road_alert_providers.dart index 0397045..83e2ba7 100644 --- a/lib/services/alerts/road_alert_providers.dart +++ b/lib/services/alerts/road_alert_providers.dart @@ -1,16 +1,22 @@ +import 'package:flutter/foundation.dart'; 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'; +import 'community_traffic_provider.dart'; +import 'mock_road_alert_provider.dart'; final tomtomProviderInstance = Provider((ref) { return TomTomRoadAlertProvider(); }); -final localProviderInstance = Provider((ref) { - return LocalRoadAlertProvider(); +final communityProviderInstance = Provider((ref) { + return CommunityTrafficRoadAlertProvider(); +}); + +final mockProviderInstance = Provider((ref) { + return MockRoadAlertProvider(); }); // Master Enable @@ -55,7 +61,7 @@ final audioAlertsEnabledProvider = return AudioAlertsEnabledNotifier(); }); -// Alert Radius in Meters (500ft = 152.4m, 1000ft = 304.8m, 0.25mi = 402.3m, 0.5mi = 804.7m, 1mi = 1609.3m) +// Alert Radius in Meters class AlertRadiusMetersNotifier extends StateNotifier { static const _key = 'alert_radius_meters'; AlertRadiusMetersNotifier() : super(804.7) { @@ -76,6 +82,35 @@ final alertRadiusMetersProvider = return AlertRadiusMetersNotifier(); }); +// Category Filtering Notifier +class EnabledCategoriesNotifier extends StateNotifier> { + EnabledCategoriesNotifier() + : super({ + RoadAlertCategory.police, + RoadAlertCategory.speedCamera, + RoadAlertCategory.accident, + RoadAlertCategory.hazard, + RoadAlertCategory.roadClosure, + RoadAlertCategory.construction, + RoadAlertCategory.traffic, + RoadAlertCategory.other, + }); + + void toggleCategory(RoadAlertCategory category, bool enabled) { + final current = Set.from(state); + if (enabled) { + current.add(category); + } else { + current.remove(category); + } + state = current; + } +} +final enabledCategoriesProvider = + StateNotifierProvider>((ref) { + return EnabledCategoriesNotifier(); +}); + // TomTom API Key class TomTomApiKeyNotifier extends StateNotifier { static const _key = 'tomtom_api_key'; @@ -97,23 +132,54 @@ final tomtomApiKeyProvider = return TomTomApiKeyNotifier(); }); -// TomTom Connection Status +// Community Traffic API Key +class CommunityApiKeyNotifier extends StateNotifier { + static const _key = 'community_api_key'; + CommunityApiKeyNotifier() : 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 communityApiKeyProvider = + StateNotifierProvider((ref) { + return CommunityApiKeyNotifier(); +}); + final tomtomStatusProvider = StateProvider((ref) { final key = ref.watch(tomtomApiKeyProvider); - if (key.isEmpty) { - return RoadAlertProviderStatus.notConfigured; - } - return RoadAlertProviderStatus.configured; + return key.isEmpty ? RoadAlertProviderStatus.notConfigured : RoadAlertProviderStatus.configured; +}); + +final communityStatusProvider = StateProvider((ref) { + final key = ref.watch(communityApiKeyProvider); + return key.isEmpty ? RoadAlertProviderStatus.notConfigured : RoadAlertProviderStatus.configured; }); // Active Providers List final activeRoadAlertProviders = Provider>((ref) { final tomtomKey = ref.watch(tomtomApiKeyProvider); + final communityKey = ref.watch(communityApiKeyProvider); final list = []; + if (tomtomKey.isNotEmpty) { list.add(ref.watch(tomtomProviderInstance)); } - // Include demo local provider as fallback/demo - list.add(ref.watch(localProviderInstance)); + if (communityKey.isNotEmpty) { + list.add(ref.watch(communityProviderInstance)); + } + + // Include Debug Mock Provider only in debug mode + if (kDebugMode) { + list.add(ref.watch(mockProviderInstance)); + } + return list; }); diff --git a/lib/services/alerts/road_alert_service.dart b/lib/services/alerts/road_alert_service.dart index 53919de..5b0bce7 100644 --- a/lib/services/alerts/road_alert_service.dart +++ b/lib/services/alerts/road_alert_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_tts/flutter_tts.dart'; import 'package:geolocator/geolocator.dart'; @@ -58,9 +59,11 @@ class RoadAlertService { final now = DateTime.now(); final radiusMeters = _ref.read(alertRadiusMetersProvider); final audioEnabled = _ref.read(audioAlertsEnabledProvider); + final enabledCategories = _ref.read(enabledCategoriesProvider); final tomtomApiKey = _ref.read(tomtomApiKeyProvider); + final communityApiKey = _ref.read(communityApiKeyProvider); - // Throttle fetches: fetch if 45s passed OR moved > 500m + // Throttle fetches: query every 45s OR when moved > 500m bool shouldFetch = false; if (_lastFetchTime == null || now.difference(_lastFetchTime!).inSeconds >= 45) { shouldFetch = true; @@ -79,16 +82,27 @@ class RoadAlertService { if (shouldFetch) { _lastFetchTime = now; _lastFetchPosition = position; - await _fetchAlertsFromProviders(position.latitude, position.longitude, radiusMeters, tomtomApiKey); + await _fetchAlertsFromProviders( + position.latitude, + position.longitude, + radiusMeters, + tomtomApiKey: tomtomApiKey, + communityApiKey: communityApiKey, + ); } - // Update distance for cached alerts & filter out expired ones final Map deduplicated = {}; for (var alert in _cachedAlerts) { + // Expiration check if (alert.expiresAt != null && alert.expiresAt!.isBefore(now)) { continue; } + // Category toggle check + if (!enabledCategories.contains(alert.category)) { + continue; + } + final dist = Geolocator.distanceBetween( position.latitude, position.longitude, @@ -101,10 +115,13 @@ class RoadAlertService { final key = '${alert.providerId}_${alert.id}'; deduplicated[key] = updatedAlert; - // Check audio alert approach (< 400m) + // Approach Audio Warning: check distance (< 400m), cooldown, and direction relevance if (audioEnabled && dist <= 400.0 && !_announcedAlertIds.contains(key)) { - _announcedAlertIds.add(key); - _announceAlertAudio(updatedAlert); + final isAhead = _isAlertAheadOfRider(position, alert); + if (isAhead) { + _announcedAlertIds.add(key); + _announceAlertAudio(updatedAlert); + } } } } @@ -116,18 +133,46 @@ class RoadAlertService { _alertsController.add(resultList); } + bool _isAlertAheadOfRider(Position userPos, RoadAlert alert) { + // If rider speed < 2.0 m/s (standing still), assume alert is relevant + if (userPos.speed < 2.0) return true; + + final riderHeading = userPos.heading; + final bearingToAlert = Geolocator.bearingBetween( + userPos.latitude, + userPos.longitude, + alert.latitude, + alert.longitude, + ); + + double delta = (bearingToAlert - riderHeading).abs(); + if (delta > 180) delta = 360 - delta; + + // Relevant if alert is within ±100 degrees of rider's heading (in front or side) + return delta <= 100.0; + } + Future _fetchAlertsFromProviders( - double lat, double lng, double radiusMeters, String tomtomApiKey) async { + double lat, + double lng, + double radiusMeters, { + required String tomtomApiKey, + required String communityApiKey, + }) async { final providers = _ref.read(activeRoadAlertProviders); final List newAlerts = []; for (var provider in providers) { try { + String? key; + if (provider.id == 'tomtom') key = tomtomApiKey; + if (provider.id == 'community_traffic') key = communityApiKey; + final fetched = await provider.getAlerts( latitude: lat, longitude: lng, radiusMeters: radiusMeters, - apiKey: provider.id == 'tomtom' ? tomtomApiKey : null, + apiKey: key, ); newAlerts.addAll(fetched); } catch (_) {} @@ -140,7 +185,7 @@ class RoadAlertService { String phrase; switch (alert.category) { case RoadAlertCategory.police: - phrase = "Police reported ahead."; + phrase = "Police report ahead."; break; case RoadAlertCategory.speedCamera: phrase = "Speed camera reported ahead."; diff --git a/test/police_alert_test.dart b/test/police_alert_test.dart new file mode 100644 index 0000000..08c8399 --- /dev/null +++ b/test/police_alert_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/services/alerts/road_alert_models.dart'; +import 'package:motorcycle_dash/services/alerts/community_traffic_parser.dart'; +import 'package:motorcycle_dash/services/alerts/community_traffic_provider.dart'; +import 'package:motorcycle_dash/services/alerts/mock_road_alert_provider.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Police & Speed-Trap Provider Tests', () { + test('CommunityTrafficParser maps POLICE and POLICE_VISIBLE to police category', () { + final jsonSample = { + 'alerts': [ + { + 'id': 'police_101', + 'type': 'POLICE', + 'subtype': 'POLICE_VISIBLE', + 'latitude': 37.7750, + 'longitude': -122.4190, + 'street': 'US-101 N', + 'reportDescription': 'Officer visible on shoulder', + 'confidence': 9, + }, + { + 'id': 'speedcam_202', + 'type': 'SPEED_CAM', + 'subtype': 'RED_LIGHT_CAMERA', + 'latitude': 37.7800, + 'longitude': -122.4200, + 'street': 'Market St', + 'reportDescription': 'Fixed speed camera', + 'confidence': 10, + }, + { + 'id': 'hazard_303', + 'type': 'HAZARD', + 'subtype': 'DEBRIS_ON_ROAD', + 'latitude': 37.7700, + 'longitude': -122.4150, + 'street': 'Mission St', + 'reportDescription': 'Tire debris in lane', + 'confidence': 8, + } + ] + }; + + final alerts = CommunityTrafficParser.parseAlertsJson( + jsonSample, + providerId: 'community_test', + providerName: 'Community Test API', + ); + + expect(alerts.length, equals(3)); + + final policeAlert = alerts.firstWhere((a) => a.id == 'police_101'); + expect(policeAlert.category, equals(RoadAlertCategory.police)); + expect(policeAlert.roadName, equals('US-101 N')); + expect(policeAlert.source, equals('Community Test API')); + expect(policeAlert.sourceType, equals(RoadAlertSourceType.userReported)); + + final cameraAlert = alerts.firstWhere((a) => a.id == 'speedcam_202'); + expect(cameraAlert.category, equals(RoadAlertCategory.speedCamera)); + + final hazardAlert = alerts.firstWhere((a) => a.id == 'hazard_303'); + expect(hazardAlert.category, equals(RoadAlertCategory.hazard)); + }); + + test('CommunityTrafficRoadAlertProvider advertises police and speed camera support', () { + final provider = CommunityTrafficRoadAlertProvider(); + expect(provider.id, equals('community_traffic')); + expect(provider.supportedCategories, contains(RoadAlertCategory.police)); + expect(provider.supportedCategories, contains(RoadAlertCategory.speedCamera)); + expect(provider.supportedCategories, contains(RoadAlertCategory.accident)); + }); + + test('MockRoadAlertProvider debug guard', () async { + final mockProvider = MockRoadAlertProvider(); + final alerts = await mockProvider.getAlerts( + latitude: 37.7749, + longitude: -122.4194, + radiusMeters: 1000.0, + ); + + if (kDebugMode) { + expect(alerts.isNotEmpty, isTrue); + expect(alerts.any((a) => a.category == RoadAlertCategory.police), isTrue); + expect(alerts.any((a) => a.category == RoadAlertCategory.speedCamera), isTrue); + } else { + expect(alerts.isEmpty, isTrue); + } + }); + }); +}