From 7bd5a0e58564a7513ed8614ac1ea12cce75bd019 Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 10 Oct 2026 02:31:06 -0400 Subject: [PATCH] feat(alerts): native community road alerts and experimental Waze OSINT adapter - Support 10 native alert categories: police, accident, hazard, pothole, debris, disabledVehicle, roadClosure, construction, traffic, weatherHazard --- .../emulator-tests/firestore.rules.test.js | 98 +++++ firestore.rules | 15 +- lib/features/map/map_screen.dart | 253 ++++++++++- lib/features/settings/settings_screen.dart | 97 ++--- lib/services/alerts/road_alert_models.dart | 54 ++- lib/services/alerts/road_alert_providers.dart | 50 ++- lib/services/alerts/road_alert_service.dart | 135 +++++- .../alerts/swarm_road_alert_models.dart | 50 ++- .../alerts/swarm_road_alert_service.dart | 289 +++++++++++-- .../waze_osint_road_alert_provider.dart | 402 ++++++++++++++++++ .../widgets/quick_swarm_report_modal.dart | 366 +++++++++++----- test/route_corridor_alert_filter_test.dart | 104 +++++ test/swarm_road_alert_test.dart | 238 ++++++++++- test/waze_osint_road_alert_provider_test.dart | 228 ++++++++++ 14 files changed, 2151 insertions(+), 228 deletions(-) create mode 100644 lib/services/alerts/waze_osint_road_alert_provider.dart create mode 100644 test/route_corridor_alert_filter_test.dart create mode 100644 test/waze_osint_road_alert_provider_test.dart diff --git a/firebase/emulator-tests/firestore.rules.test.js b/firebase/emulator-tests/firestore.rules.test.js index f661514..31a3961 100644 --- a/firebase/emulator-tests/firestore.rules.test.js +++ b/firebase/emulator-tests/firestore.rules.test.js @@ -6537,4 +6537,102 @@ describe('MotorCloud Rider Swarm Road Alerts Security Rules', () => { // Alice (reporter) can delete her alert await assertSucceeds(deleteDoc(alertRefAlice)); }); + + test('creating road alerts with new categories (pothole, debris, disabledVehicle, weatherHazard)', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const newCategories = ['pothole', 'debris', 'disabledVehicle', 'weatherHazard']; + + for (const category of newCategories) { + const alertId = `alert_${category}_test`; + await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { + ...baseAlert, + id: alertId, + category, + })); + } + }); + + test('creating road alerts with optional subtype, source, and status', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + + // Valid optional fields with status 'active' + await assertSucceeds(setDoc(doc(alice, 'road_alerts/alert_opt_active'), { + ...baseAlert, + id: 'alert_opt_active', + category: 'pothole', + subtype: 'deep_crater', + source: 'native_sensor', + status: 'active', + })); + + // Valid optional fields with status 'confirmed' + await assertSucceeds(setDoc(doc(alice, 'road_alerts/alert_opt_confirmed'), { + ...baseAlert, + id: 'alert_opt_confirmed', + category: 'debris', + subtype: 'tire_tread', + source: 'rider_report', + status: 'confirmed', + })); + + // Valid optional fields with status 'cleared' + await assertSucceeds(setDoc(doc(alice, 'road_alerts/alert_opt_cleared'), { + ...baseAlert, + id: 'alert_opt_cleared', + status: 'cleared', + })); + + // Valid optional fields with status 'expired' + await assertSucceeds(setDoc(doc(alice, 'road_alerts/alert_opt_expired'), { + ...baseAlert, + id: 'alert_opt_expired', + status: 'expired', + })); + }); + + test('rejecting invalid status or too-long subtype', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + + // Invalid status value + await assertFails(setDoc(doc(alice, 'road_alerts/alert_bad_status'), { + ...baseAlert, + id: 'alert_bad_status', + status: 'invalid_status_enum', + })); + + // Subtype exceeding 64 characters + await assertFails(setDoc(doc(alice, 'road_alerts/alert_long_subtype'), { + ...baseAlert, + id: 'alert_long_subtype', + subtype: 'x'.repeat(65), + })); + + // Source exceeding 64 characters + await assertFails(setDoc(doc(alice, 'road_alerts/alert_long_source'), { + ...baseAlert, + id: 'alert_long_source', + source: 's'.repeat(65), + })); + }); + + test('updating status to cleared alongside dismissal increment', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + const alertRefAlice = doc(alice, 'road_alerts/alert_hazard_101'); + const alertRefBob = doc(bob, 'road_alerts/alert_hazard_101'); + + await assertSucceeds(setDoc(alertRefAlice, baseAlert)); + + // Bob clears the alert: dismissal increments to 1, status becomes cleared + await assertSucceeds(updateDoc(alertRefBob, { + dismissals: 1, + status: 'cleared', + })); + + // Bob attempts to update with invalid status: rejected + await assertFails(updateDoc(alertRefBob, { + dismissals: 2, + status: 'invalid_status_enum', + })); + }); }); diff --git a/firestore.rules b/firestore.rules index dda1d9e..d5ac763 100644 --- a/firestore.rules +++ b/firestore.rules @@ -1505,9 +1505,9 @@ service cloud.firestore { match /road_alerts/{alertId} { function validAlertKeys() { return request.resource.data.keys().hasOnly([ - 'id', 'category', 'latitude', 'longitude', 'heading', + 'id', 'category', 'subtype', 'latitude', 'longitude', 'heading', 'roadName', 'description', 'reporterUid', 'reportedAt', - 'expiresAt', 'confirmations', 'dismissals', 'groupRideId' + 'expiresAt', 'confirmations', 'dismissals', 'groupRideId', 'source', 'status' ]); } @@ -1518,7 +1518,8 @@ service cloud.firestore { && validAlertKeys() && request.resource.data.category in [ 'police', 'hazard', 'accident', 'speedCamera', - 'roadClosure', 'construction', 'traffic', 'other' + 'roadClosure', 'construction', 'traffic', 'pothole', + 'debris', 'disabledVehicle', 'weatherHazard', 'other' ] && (request.resource.data.latitude is int || request.resource.data.latitude is float) && (request.resource.data.longitude is int || request.resource.data.longitude is float) @@ -1530,7 +1531,10 @@ service cloud.firestore { && request.resource.data.expiresAt is int && request.resource.data.expiresAt > request.resource.data.reportedAt && request.resource.data.description is string - && request.resource.data.description.size() <= 256; + && request.resource.data.description.size() <= 256 + && (!('subtype' in request.resource.data) || (request.resource.data.subtype is string && request.resource.data.subtype.size() <= 64)) + && (!('source' in request.resource.data) || (request.resource.data.source is string && request.resource.data.source.size() <= 64)) + && (!('status' in request.resource.data) || request.resource.data.status in ['active', 'confirmed', 'cleared', 'expired']); } function validAlertUpdate() { @@ -1542,7 +1546,8 @@ service cloud.firestore { && request.resource.data.latitude == resource.data.latitude && request.resource.data.longitude == resource.data.longitude && request.resource.data.reportedAt == resource.data.reportedAt - && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['confirmations', 'dismissals', 'expiresAt']) + && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['confirmations', 'dismissals', 'expiresAt', 'status']) + && (!('status' in request.resource.data) || request.resource.data.status in ['active', 'confirmed', 'cleared', 'expired']) && ( ( request.resource.data.confirmations == resource.data.confirmations + 1 diff --git a/lib/features/map/map_screen.dart b/lib/features/map/map_screen.dart index 8e771dd..7277e7f 100644 --- a/lib/features/map/map_screen.dart +++ b/lib/features/map/map_screen.dart @@ -12,6 +12,7 @@ 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_providers.dart'; import '../../services/alerts/road_alert_service.dart'; import '../../services/alerts/widgets/quick_swarm_report_modal.dart'; import '../../services/map/map_layer_settings.dart'; @@ -636,30 +637,240 @@ class _MapScreenState extends ConsumerState { } void _showRoadAlertDetails(RoadAlert alert) { + final swarmService = ref.read(swarmRoadAlertServiceProvider); + final canVote = alert.sourceType == RoadAlertSourceType.userReported && + swarmService.canUserVote(alert.id); + final hasVoted = swarmService.hasUserVoted(alert.id); + final isConfirmed = alert.isCommunityConfirmed; + showModalBottomSheet( - context: context, backgroundColor: Colors.grey[900], - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), + context: context, + backgroundColor: Colors.grey[900], + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), builder: (ctx) => Padding( padding: const EdgeInsets.all(20), - child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Container(padding: const EdgeInsets.all(8), decoration: BoxDecoration(color: alert.category.color.withValues(alpha: 0.2), shape: BoxShape.circle), child: Icon(alert.category.icon, color: alert.category.color, size: 28)), - const SizedBox(width: 12), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(alert.category.displayName.toUpperCase(), style: TextStyle(color: alert.category.color, fontWeight: FontWeight.bold, fontSize: 12, letterSpacing: 1.1)), - 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)), - ]), - ]), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: alert.category.color.withValues(alpha: 0.2), + shape: BoxShape.circle, + ), + child: Icon(alert.category.icon, + color: alert.category.color, size: 28), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + alert.category.displayName.toUpperCase(), + style: TextStyle( + color: alert.category.color, + fontWeight: FontWeight.bold, + fontSize: 12, + letterSpacing: 1.1, + ), + ), + 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: isConfirmed + ? Colors.greenAccent.withValues(alpha: 0.2) + : (alert.sourceType == RoadAlertSourceType.userReported + ? Colors.orangeAccent.withValues(alpha: 0.2) + : Colors.white10), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isConfirmed + ? Colors.greenAccent + : (alert.sourceType == + RoadAlertSourceType.userReported + ? Colors.orangeAccent + : Colors.transparent), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isConfirmed + ? Icons.verified + : (alert.sourceType == + RoadAlertSourceType.userReported + ? Icons.schedule + : Icons.shield_outlined), + size: 12, + color: isConfirmed + ? Colors.greenAccent + : (alert.sourceType == + RoadAlertSourceType.userReported + ? Colors.orangeAccent + : Colors.white70), + ), + const SizedBox(width: 4), + Text( + isConfirmed + ? 'COMMUNITY CONFIRMED' + : (alert.sourceType == + RoadAlertSourceType.userReported + ? 'UNVERIFIED' + : 'VERIFIED'), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: isConfirmed + ? Colors.greenAccent + : (alert.sourceType == + RoadAlertSourceType.userReported + ? Colors.orangeAccent + : Colors.white70), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + Text(alert.description, + style: const TextStyle(fontSize: 15, height: 1.3)), + const SizedBox(height: 12), + if (alert.sourceType == RoadAlertSourceType.userReported) + Text( + 'Trust & Verification: ${alert.confirmations} confirmed ยท ${alert.dismissals} dismissed', + style: const TextStyle(fontSize: 12, color: Colors.white70), + ), + const SizedBox(height: 12), + 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), + ), + ], + ), + if (alert.sourceType == RoadAlertSourceType.userReported) ...[ + const SizedBox(height: 16), + if (hasVoted) + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white10, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: const Text( + 'You have already verified this report.', + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + ) + else + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: + Colors.greenAccent.withValues(alpha: 0.2), + foregroundColor: Colors.greenAccent, + side: const BorderSide( + color: Colors.greenAccent, width: 1.5), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + icon: const Icon(Icons.thumb_up_alt_outlined, size: 18), + label: const Text('STILL THERE', + style: TextStyle(fontWeight: FontWeight.bold)), + onPressed: canVote + ? () async { + final messenger = + ScaffoldMessenger.of(context); + Navigator.of(ctx).pop(); + await swarmService.confirmAlert(alert.id); + messenger.showSnackBar( + const SnackBar( + content: Text( + 'Alert confirmed! Thank you for verifying.'), + backgroundColor: Colors.green, + duration: Duration(seconds: 2), + ), + ); + } + : null, + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: + Colors.redAccent.withValues(alpha: 0.2), + foregroundColor: Colors.redAccent, + side: const BorderSide( + color: Colors.redAccent, width: 1.5), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + icon: + const Icon(Icons.thumb_down_alt_outlined, size: 18), + label: const Text('NOT THERE', + style: TextStyle(fontWeight: FontWeight.bold)), + onPressed: canVote + ? () async { + final messenger = + ScaffoldMessenger.of(context); + Navigator.of(ctx).pop(); + await swarmService.clearAlert(alert.id); + messenger.showSnackBar( + const SnackBar( + content: Text( + 'Report marked as clear. Thank you!'), + backgroundColor: Colors.orange, + duration: Duration(seconds: 2), + ), + ); + } + : null, + ), + ), + ], + ), + ], + ], + ), ), ); } diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 29d4cc0..332f744 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -1026,8 +1026,8 @@ class _SettingsScreenState extends ConsumerState { Widget _buildWazeProviderCard(BuildContext context) { final colors = context.motorColors; - final wazeStatus = ref.watch(wazeStatusProvider); - final wazeEnabled = ref.watch(wazeRoadAlertsEnabledProvider); + final wazeOsintStatus = ref.watch(wazeOsintStatusProvider); + final wazeOsintEnabled = ref.watch(wazeOsintEnabledProvider); return Card( clipBehavior: Clip.antiAlias, @@ -1041,50 +1041,57 @@ class _SettingsScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildProviderHeader( - icon: Icons.local_police, + icon: Icons.alt_route, iconColor: colors.velocityBlue, - title: 'Waze Police Alerts', + title: 'Waze Community (Experimental OSINT)', subtitle: - 'Real-time Waze crowd-sourced police speed traps & enforcement via OpenWebNinja.', - status: wazeStatus, + 'Optional community hazard & traffic feed inspired by ArgeliusLabs/waze_osint. Disabled by default; zero paid subscriptions.', + status: wazeOsintStatus, ), const SizedBox(height: 12), SwitchListTile.adaptive( contentPadding: EdgeInsets.zero, title: const Text( - 'Enable Waze Police Alerts', + 'Enable Experimental Waze Feed', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14), ), subtitle: Text( - 'Live speed traps and police alerts during ride navigation (Type: Police only).', + 'Streams public community road alerts, hazards, and traffic (Disabled by default).', style: TextStyle( fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), - value: wazeEnabled, + value: wazeOsintEnabled, onChanged: (val) { ref - .read(wazeRoadAlertsEnabledProvider.notifier) + .read(wazeOsintEnabledProvider.notifier) .setEnabled(val); }, ), - const SizedBox(height: 12), - TextField( - controller: _wazeKeyController, - decoration: const InputDecoration( - labelText: 'OpenWebNinja / Waze API Key', - hintText: 'Enter your OpenWebNinja x-api-key', - helperText: - 'Supplies real-time Waze police traps and enforcement.', - helperMaxLines: 2, - border: OutlineInputBorder(), - isDense: true, + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colors.surfaceContainer, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colors.outlineVariant), + ), + child: Row( + children: [ + Icon(Icons.info_outline, size: 16, color: colors.cyberCyan), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Community-sourced experimental data. No API key required.', + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], ), - style: const TextStyle(fontSize: 14), - onChanged: (val) { - ref.read(wazeApiKeyProvider.notifier).setApiKey(val); - }, ), const SizedBox(height: 12), Align( @@ -1101,35 +1108,17 @@ class _SettingsScreenState extends ConsumerState { : () async { setState(() => _isTestingWaze = true); final messenger = ScaffoldMessenger.of(context); - final provider = ref.read(wazeProviderInstance); - final status = await provider.testConnection( - _wazeKeyController.text, - ); - ref.read(wazeStatusProvider.notifier).state = status; + final provider = ref.read(wazeOsintProviderInstance); + final status = await provider.testConnection(''); + ref.read(wazeOsintStatusProvider.notifier).state = status; setState(() => _isTestingWaze = false); if (mounted) { - String msg = ''; - if (status == RoadAlertProviderStatus.connected) { - msg = 'Waze API Connected successfully!'; - } else if (status == - RoadAlertProviderStatus.authError) { - msg = - 'Authentication Error: Invalid or inactive OpenWebNinja API key.'; - } else if (status == - RoadAlertProviderStatus.notConfigured) { - msg = 'API Key is empty.'; - } else if (status == - RoadAlertProviderStatus.networkError) { - msg = - 'Network Error: Failed to connect to OpenWebNinja API.'; - } else if (status == - RoadAlertProviderStatus.rateLimited) { - msg = 'Rate Limited: Plan quota exceeded.'; - } else { - msg = 'Status: ${status.name}'; - } - messenger.showSnackBar(SnackBar(content: Text(msg))); + messenger.showSnackBar( + const SnackBar( + content: Text('Waze OSINT adapter configured successfully!'), + ), + ); } }, icon: _isTestingWaze @@ -1297,7 +1286,7 @@ class _SettingsScreenState extends ConsumerState { controller: _communityKeyController, decoration: const InputDecoration( labelText: 'Community / RapidAPI Key', - hintText: 'Enter OpenWebNinja, RapidAPI, or Community API Key', + hintText: 'Enter RapidAPI or Custom Community Key', helperText: 'Supplies Police, Speed Cameras, and Crowd Hazards.', helperMaxLines: 2, @@ -1317,7 +1306,7 @@ class _SettingsScreenState extends ConsumerState { hintText: 'e.g. https://google-map-places.p.rapidapi.com or custom URL', helperText: - 'Leave blank for automatic OpenWebNinja / RapidAPI detection.', + 'Leave blank for default endpoint detection.', helperMaxLines: 2, border: OutlineInputBorder(), isDense: true, @@ -1356,7 +1345,7 @@ class _SettingsScreenState extends ConsumerState { String msg = ''; if (status == RoadAlertProviderStatus.connected) { msg = - 'Community / OpenWebNinja API Connected successfully!'; + 'Community Traffic API Connected successfully!'; } else if (status == RoadAlertProviderStatus.authError) { msg = diff --git a/lib/services/alerts/road_alert_models.dart b/lib/services/alerts/road_alert_models.dart index cf2b914..2ec35c3 100644 --- a/lib/services/alerts/road_alert_models.dart +++ b/lib/services/alerts/road_alert_models.dart @@ -5,9 +5,13 @@ enum RoadAlertCategory { speedCamera, accident, hazard, + pothole, + debris, + disabledVehicle, roadClosure, construction, traffic, + weatherHazard, other, } @@ -22,12 +26,20 @@ extension RoadAlertCategoryExtension on RoadAlertCategory { return 'Accident'; case RoadAlertCategory.hazard: return 'Hazard'; + case RoadAlertCategory.pothole: + return 'Pothole'; + case RoadAlertCategory.debris: + return 'Road Debris'; + case RoadAlertCategory.disabledVehicle: + return 'Disabled Vehicle'; case RoadAlertCategory.roadClosure: return 'Road Closure'; case RoadAlertCategory.construction: return 'Construction'; case RoadAlertCategory.traffic: - return 'Traffic Delay'; + return 'Heavy Traffic'; + case RoadAlertCategory.weatherHazard: + return 'Weather Hazard'; case RoadAlertCategory.other: return 'Other'; } @@ -43,12 +55,20 @@ extension RoadAlertCategoryExtension on RoadAlertCategory { return Icons.car_crash; case RoadAlertCategory.hazard: return Icons.warning_amber; + case RoadAlertCategory.pothole: + return Icons.radio_button_checked; + case RoadAlertCategory.debris: + return Icons.scatter_plot; + case RoadAlertCategory.disabledVehicle: + return Icons.car_repair; case RoadAlertCategory.roadClosure: return Icons.do_not_disturb_on; case RoadAlertCategory.construction: return Icons.engineering; case RoadAlertCategory.traffic: return Icons.traffic; + case RoadAlertCategory.weatherHazard: + return Icons.thunderstorm; case RoadAlertCategory.other: return Icons.report_problem; } @@ -64,12 +84,20 @@ extension RoadAlertCategoryExtension on RoadAlertCategory { return Colors.redAccent; case RoadAlertCategory.hazard: return Colors.orangeAccent; + case RoadAlertCategory.pothole: + return const Color(0xFFFFB300); // Amber 700 + case RoadAlertCategory.debris: + return const Color(0xFFFF7043); // Deep Orange 400 + case RoadAlertCategory.disabledVehicle: + return const Color(0xFFFF5252); // Red accent 200 case RoadAlertCategory.roadClosure: return Colors.deepOrange; case RoadAlertCategory.construction: return Colors.amber; case RoadAlertCategory.traffic: return Colors.yellowAccent; + case RoadAlertCategory.weatherHazard: + return const Color(0xFF4FC3F7); // Light Blue 300 case RoadAlertCategory.other: return Colors.cyanAccent; } @@ -80,6 +108,8 @@ enum RoadAlertSeverity { low, medium, high, critical } enum RoadAlertSourceType { providerReported, userReported } +enum RoadAlertStatus { active, confirmed, cleared, expired } + enum RoadAlertProviderStatus { notConfigured, configured, @@ -93,6 +123,7 @@ class RoadAlert { final String id; final String providerId; final RoadAlertCategory category; + final String? subtype; final double latitude; final double longitude; final double? heading; @@ -107,11 +138,16 @@ class RoadAlert { final String? sourceUrl; final double? distanceFromUser; // meters final RoadAlertSourceType sourceType; + final RoadAlertStatus status; + final int confirmations; + final int dismissals; + final String? reporterUid; const RoadAlert({ required this.id, required this.providerId, required this.category, + this.subtype, required this.latitude, required this.longitude, this.heading, @@ -126,12 +162,19 @@ class RoadAlert { this.sourceUrl, this.distanceFromUser, this.sourceType = RoadAlertSourceType.providerReported, + this.status = RoadAlertStatus.active, + this.confirmations = 1, + this.dismissals = 0, + this.reporterUid, }); + bool get isCommunityConfirmed => confirmations >= 2; + RoadAlert copyWith({ String? id, String? providerId, RoadAlertCategory? category, + String? subtype, double? latitude, double? longitude, double? heading, @@ -146,11 +189,16 @@ class RoadAlert { String? sourceUrl, double? distanceFromUser, RoadAlertSourceType? sourceType, + RoadAlertStatus? status, + int? confirmations, + int? dismissals, + String? reporterUid, }) { return RoadAlert( id: id ?? this.id, providerId: providerId ?? this.providerId, category: category ?? this.category, + subtype: subtype ?? this.subtype, latitude: latitude ?? this.latitude, longitude: longitude ?? this.longitude, heading: heading ?? this.heading, @@ -165,6 +213,10 @@ class RoadAlert { sourceUrl: sourceUrl ?? this.sourceUrl, distanceFromUser: distanceFromUser ?? this.distanceFromUser, sourceType: sourceType ?? this.sourceType, + status: status ?? this.status, + confirmations: confirmations ?? this.confirmations, + dismissals: dismissals ?? this.dismissals, + reporterUid: reporterUid ?? this.reporterUid, ); } } diff --git a/lib/services/alerts/road_alert_providers.dart b/lib/services/alerts/road_alert_providers.dart index 4873ea0..6b9e6a4 100644 --- a/lib/services/alerts/road_alert_providers.dart +++ b/lib/services/alerts/road_alert_providers.dart @@ -10,13 +10,20 @@ import 'swarm_road_alert_service.dart'; import 'swarm_road_alert_provider.dart'; import 'here_road_alert_provider.dart'; import 'waze_road_alert_provider.dart'; +import 'waze_osint_road_alert_provider.dart'; final wazeProviderInstance = Provider((ref) { return WazeRoadAlertProvider(); }); +final wazeOsintProviderInstance = Provider((ref) { + return WazeOsintRoadAlertProvider(); +}); + final swarmRoadAlertServiceProvider = Provider((ref) { - return SwarmRoadAlertService(); + return SwarmRoadAlertService( + minReportInterval: const Duration(seconds: 15), + ); }); final swarmRoadAlertProviderInstance = Provider((ref) { @@ -118,9 +125,13 @@ class EnabledCategoriesNotifier extends StateNotifier> { RoadAlertCategory.speedCamera, RoadAlertCategory.accident, RoadAlertCategory.hazard, + RoadAlertCategory.pothole, + RoadAlertCategory.debris, + RoadAlertCategory.disabledVehicle, RoadAlertCategory.roadClosure, RoadAlertCategory.construction, RoadAlertCategory.traffic, + RoadAlertCategory.weatherHazard, RoadAlertCategory.other, }); @@ -404,9 +415,43 @@ final wazeStatusProvider = StateProvider((ref) { return RoadAlertProviderStatus.configured; }); +// Experimental Waze OSINT Community Adapter Toggle (Disabled by default) +class WazeOsintEnabledNotifier extends StateNotifier { + static const _key = 'waze_osint_enabled'; + WazeOsintEnabledNotifier() : super(false) { + _load(); + } + Future _load() async { + final prefs = await SharedPreferences.getInstance(); + if (!mounted) return; + final saved = prefs.getBool(_key); + if (saved != null) { + state = saved; + } + } + Future setEnabled(bool enabled) async { + state = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_key, enabled); + if (mounted) { + state = enabled; + } + } +} +final wazeOsintEnabledProvider = + StateNotifierProvider((ref) { + return WazeOsintEnabledNotifier(); +}); + +final wazeOsintStatusProvider = StateProvider((ref) { + final enabled = ref.watch(wazeOsintEnabledProvider); + return enabled ? RoadAlertProviderStatus.connected : RoadAlertProviderStatus.notConfigured; +}); + // Active Providers List final activeRoadAlertProviders = Provider>((ref) { final swarmEnabled = ref.watch(swarmAlertsEnabledProvider); + final wazeOsintEnabled = ref.watch(wazeOsintEnabledProvider); final usCamerasEnabled = ref.watch(usSpeedCamerasEnabledProvider); final tomtomKey = ref.watch(tomtomApiKeyProvider); final communityKey = ref.watch(communityApiKeyProvider); @@ -419,6 +464,9 @@ final activeRoadAlertProviders = Provider>((ref) { if (swarmEnabled) { list.add(ref.watch(swarmRoadAlertProviderInstance)); } + if (wazeOsintEnabled) { + list.add(ref.watch(wazeOsintProviderInstance)); + } if (wazeEnabled && wazeKey.isNotEmpty) { list.add(ref.watch(wazeProviderInstance)); } diff --git a/lib/services/alerts/road_alert_service.dart b/lib/services/alerts/road_alert_service.dart index 73401c5..add244d 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/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -7,6 +8,8 @@ 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'; @@ -238,7 +241,35 @@ class RoadAlertService { userLat: position.latitude, userLng: position.longitude, complianceEnabled: complianceEnabled, - )..sort((a, b) => (a.distanceFromUser ?? 0).compareTo(b.distanceFromUser ?? 0)); + ); + + final navSession = _ref.read(navigationSessionProvider); + final hasActiveRoute = navSession.isNavigating && + navSession.calculationResult != null && + navSession.calculationResult!.geometry.length >= 2; + + compliantList.sort((a, b) { + if (hasActiveRoute) { + final geom = navSession.calculationResult!.geometry; + final aOnRoute = RouteCorridorAlertFilter.isAlertAheadOnRoute( + userLat: position.latitude, + userLng: position.longitude, + alertLat: a.latitude, + alertLng: a.longitude, + geometry: geom, + ); + final bOnRoute = RouteCorridorAlertFilter.isAlertAheadOnRoute( + userLat: position.latitude, + userLng: position.longitude, + alertLat: b.latitude, + alertLng: b.longitude, + geometry: geom, + ); + if (aOnRoute && !bOnRoute) return -1; + if (!aOnRoute && bOnRoute) return 1; + } + return (a.distanceFromUser ?? 0).compareTo(b.distanceFromUser ?? 0); + }); _cachedAlerts = compliantList; _alertsController.add(compliantList); @@ -395,6 +426,18 @@ class RoadAlertService { 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; @@ -588,3 +631,93 @@ class RoadAlertService { } 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 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 geometry, + double maxCrossTrackMeters = 80.0, + double maxAheadMeters = 1200.0, + }) { + if (geometry.length < 2) return false; + + final crossTrack = minDistanceToPolyline(alertLat, alertLng, geometry); + if (crossTrack > maxCrossTrackMeters) return false; + + int userSeg = 0; + double minUserDist = double.infinity; + int alertSeg = 0; + double minAlertDist = double.infinity; + + for (int i = 0; i < geometry.length; i++) { + final du = Geolocator.distanceBetween(userLat, userLng, geometry[i].latitude, geometry[i].longitude); + if (du < minUserDist) { + minUserDist = du; + userSeg = i; + } + final da = Geolocator.distanceBetween(alertLat, alertLng, geometry[i].latitude, geometry[i].longitude); + if (da < minAlertDist) { + minAlertDist = da; + alertSeg = i; + } + } + + if (alertSeg < userSeg) return false; + + final directDist = Geolocator.distanceBetween(userLat, userLng, alertLat, alertLng); + return directDist <= maxAheadMeters; + } +} diff --git a/lib/services/alerts/swarm_road_alert_models.dart b/lib/services/alerts/swarm_road_alert_models.dart index 231f410..c1cc576 100644 --- a/lib/services/alerts/swarm_road_alert_models.dart +++ b/lib/services/alerts/swarm_road_alert_models.dart @@ -5,6 +5,7 @@ import 'road_alert_models.dart'; class SwarmReport { final String id; final RoadAlertCategory category; + final String? subtype; final double latitude; final double longitude; final double? heading; @@ -16,10 +17,13 @@ class SwarmReport { final int confirmations; final int dismissals; final String? groupRideId; + final String? source; + final RoadAlertStatus status; const SwarmReport({ required this.id, required this.category, + this.subtype, required this.latitude, required this.longitude, this.heading, @@ -31,6 +35,8 @@ class SwarmReport { this.confirmations = 1, this.dismissals = 0, this.groupRideId, + this.source, + this.status = RoadAlertStatus.active, }); double get confidence { @@ -41,9 +47,12 @@ class SwarmReport { bool get isExpired => DateTime.now().isAfter(expiresAt); + bool get isCommunityConfirmed => confirmations >= 2; + Map toJson() => { 'id': id, 'category': category.name, + if (subtype != null) 'subtype': subtype, 'latitude': latitude, 'longitude': longitude, if (heading != null) 'heading': heading, @@ -55,6 +64,8 @@ class SwarmReport { 'confirmations': confirmations, 'dismissals': dismissals, if (groupRideId != null) 'groupRideId': groupRideId, + if (source != null) 'source': source, + 'status': status.name, }; factory SwarmReport.fromJson(Map json) { @@ -75,9 +86,21 @@ class SwarmReport { : (DateTime.tryParse(rawExpiresAt?.toString() ?? '') ?? reportedAt.add(const Duration(hours: 1))); + final confirmations = (json['confirmations'] as num?)?.toInt() ?? 1; + final dismissals = (json['dismissals'] as num?)?.toInt() ?? 0; + + final rawStatus = json['status']?.toString(); + final status = RoadAlertStatus.values.firstWhere( + (s) => s.name == rawStatus, + orElse: () => confirmations >= 2 + ? RoadAlertStatus.confirmed + : RoadAlertStatus.active, + ); + return SwarmReport( id: json['id']?.toString() ?? '', category: category, + subtype: json['subtype']?.toString(), latitude: (json['latitude'] as num).toDouble(), longitude: (json['longitude'] as num).toDouble(), heading: json['heading'] != null ? (json['heading'] as num).toDouble() : null, @@ -86,21 +109,26 @@ class SwarmReport { reporterUid: json['reporterUid']?.toString() ?? 'anonymous', reportedAt: reportedAt, expiresAt: expiresAt, - confirmations: (json['confirmations'] as num?)?.toInt() ?? 1, - dismissals: (json['dismissals'] as num?)?.toInt() ?? 0, + confirmations: confirmations, + dismissals: dismissals, groupRideId: json['groupRideId']?.toString(), + source: json['source']?.toString(), + status: status, ); } RoadAlert toRoadAlert({double? distanceFromUser}) { - final isPoliceOrAccident = - category == RoadAlertCategory.police || category == RoadAlertCategory.accident; - final sourceName = groupRideId != null ? 'Pack Rider Report' : 'MotorSignal Swarm'; + final isHighSeverity = category == RoadAlertCategory.police || + category == RoadAlertCategory.accident || + category == RoadAlertCategory.weatherHazard; + final sourceName = source ?? + (groupRideId != null ? 'Pack Rider Report' : 'MotorSignal Swarm'); return RoadAlert( id: id, providerId: 'swarm_alerts', category: category, + subtype: subtype, latitude: latitude, longitude: longitude, heading: heading, @@ -110,9 +138,13 @@ class SwarmReport { expiresAt: expiresAt, lastUpdated: DateTime.now(), confidence: confidence, - severity: isPoliceOrAccident ? RoadAlertSeverity.high : RoadAlertSeverity.medium, + severity: isHighSeverity ? RoadAlertSeverity.high : RoadAlertSeverity.medium, source: sourceName, sourceType: RoadAlertSourceType.userReported, + status: status, + confirmations: confirmations, + dismissals: dismissals, + reporterUid: reporterUid, distanceFromUser: distanceFromUser, ); } @@ -120,6 +152,7 @@ class SwarmReport { SwarmReport copyWith({ String? id, RoadAlertCategory? category, + String? subtype, double? latitude, double? longitude, double? heading, @@ -131,10 +164,13 @@ class SwarmReport { int? confirmations, int? dismissals, String? groupRideId, + String? source, + RoadAlertStatus? status, }) { return SwarmReport( id: id ?? this.id, category: category ?? this.category, + subtype: subtype ?? this.subtype, latitude: latitude ?? this.latitude, longitude: longitude ?? this.longitude, heading: heading ?? this.heading, @@ -146,6 +182,8 @@ class SwarmReport { confirmations: confirmations ?? this.confirmations, dismissals: dismissals ?? this.dismissals, groupRideId: groupRideId ?? this.groupRideId, + source: source ?? this.source, + status: status ?? this.status, ); } } diff --git a/lib/services/alerts/swarm_road_alert_service.dart b/lib/services/alerts/swarm_road_alert_service.dart index cfef51b..6832486 100644 --- a/lib/services/alerts/swarm_road_alert_service.dart +++ b/lib/services/alerts/swarm_road_alert_service.dart @@ -10,18 +10,35 @@ import 'swarm_road_alert_models.dart'; enum SwarmCloudStatus { unavailable, confirmed, pending, failed } +class RoadAlertRateLimitException implements Exception { + final int retryAfterSeconds; + RoadAlertRateLimitException(this.retryAfterSeconds); + @override + String toString() => + 'Please wait $retryAfterSeconds seconds before submitting another road alert.'; +} + class SwarmRoadAlertService { final FirebaseFirestore? _firestore; final FirebaseAuth? _auth; + final Duration minReportInterval; + final double duplicateDistanceMeters; + final Duration duplicateWindow; static int _idCounter = 0; + DateTime? _lastReportTime; + final Set _userVotes = {}; // Key format: '$alertId:$uid' + // Local cache for offline resilience and immediate zero-latency feedback final Map _localSwarmCache = {}; SwarmRoadAlertService({ FirebaseFirestore? firestore, FirebaseAuth? auth, + this.minReportInterval = Duration.zero, + this.duplicateDistanceMeters = 200.0, + this.duplicateWindow = const Duration(minutes: 15), }) : _firestore = firestore ?? _resolveFirestore(), _auth = auth ?? _resolveAuth(); @@ -43,43 +60,184 @@ class SwarmRoadAlertService { String? get _currentUid => _auth?.currentUser?.uid; - /// Reports a new road alert (e.g. Police Speed Trap, Road Hazard, Accident). + /// Returns a cached report by alert ID if available. + SwarmReport? getCachedReport(String alertId) => _localSwarmCache[alertId]; + + static Duration defaultTtlForCategory(RoadAlertCategory category) { + switch (category) { + case RoadAlertCategory.police: + return const Duration(minutes: 60); + case RoadAlertCategory.traffic: + return const Duration(minutes: 45); + case RoadAlertCategory.accident: + case RoadAlertCategory.roadClosure: + return const Duration(hours: 3); + case RoadAlertCategory.weatherHazard: + return const Duration(hours: 4); + case RoadAlertCategory.hazard: + case RoadAlertCategory.pothole: + case RoadAlertCategory.debris: + case RoadAlertCategory.disabledVehicle: + case RoadAlertCategory.construction: + case RoadAlertCategory.speedCamera: + case RoadAlertCategory.other: + return const Duration(hours: 2); + } + } + + static String defaultDescriptionForCategory(RoadAlertCategory category) { + switch (category) { + case RoadAlertCategory.police: + return 'Police speed trap reported by rider'; + case RoadAlertCategory.accident: + return 'Traffic accident reported'; + case RoadAlertCategory.hazard: + return 'Road hazard reported'; + case RoadAlertCategory.pothole: + return 'Severe pothole reported on road'; + case RoadAlertCategory.debris: + return 'Road debris reported'; + case RoadAlertCategory.disabledVehicle: + return 'Disabled vehicle reported on roadway'; + case RoadAlertCategory.roadClosure: + return 'Road closure reported'; + case RoadAlertCategory.construction: + return 'Road construction reported'; + case RoadAlertCategory.traffic: + return 'Heavy traffic reported'; + case RoadAlertCategory.weatherHazard: + return 'Adverse weather road hazard reported'; + case RoadAlertCategory.speedCamera: + return 'Speed enforcement camera reported'; + case RoadAlertCategory.other: + return 'Rider safety report'; + } + } + + bool _isCategoryMatch(RoadAlertCategory a, RoadAlertCategory b) { + if (a == b) return true; + final hazardGroup = { + RoadAlertCategory.hazard, + RoadAlertCategory.pothole, + RoadAlertCategory.debris, + RoadAlertCategory.disabledVehicle, + }; + return hazardGroup.contains(a) && hazardGroup.contains(b); + } + + SwarmReport? _findDuplicateAlert({ + required RoadAlertCategory category, + required double latitude, + required double longitude, + required DateTime reportedAt, + }) { + for (final report in _localSwarmCache.values) { + if (report.isExpired) continue; + if (report.status == RoadAlertStatus.cleared || + report.status == RoadAlertStatus.expired) { + continue; + } + final age = reportedAt.difference(report.reportedAt).abs(); + if (age > duplicateWindow) continue; + + if (!_isCategoryMatch(report.category, category)) continue; + + final distance = Geolocator.distanceBetween( + latitude, + longitude, + report.latitude, + report.longitude, + ); + if (distance <= duplicateDistanceMeters) { + return report; + } + } + return null; + } + + /// Checks if the given user (or current user) has already voted on this alert. + bool hasUserVoted(String alertId, [String? voterUid]) { + final uid = voterUid ?? _currentUid ?? 'local_rider'; + return _userVotes.contains('$alertId:$uid'); + } + + /// Checks if the given user is eligible to vote on this alert. + bool canUserVote(String alertId, [String? voterUid]) { + final uid = voterUid ?? _currentUid ?? 'local_rider'; + final cached = _localSwarmCache[alertId]; + if (cached != null && cached.reporterUid == uid) { + return false; // Reporter cannot vote on their own alert + } + return !_userVotes.contains('$alertId:$uid'); + } + + /// Reports a new road alert. /// Saves to Firestore `/road_alerts` and caches locally. + /// Automatically checks for duplicate alerts nearby (< 200m within 15min) + /// and confirms existing alerts instead of duplicating. Future reportAlert({ required RoadAlertCategory category, required double latitude, required double longitude, + String? subtype, + String? source, double? heading, String? roadName, String? description, String? groupRideId, + String? reporterUid, Duration? ttl, + bool ignoreRateLimit = false, void Function(SwarmCloudStatus)? onCloudStatus, }) async { final now = DateTime.now(); - final uid = _currentUid ?? 'local_rider'; + final uid = reporterUid ?? _currentUid ?? 'local_rider'; + + // Rate Limiting + if (!ignoreRateLimit && minReportInterval > Duration.zero && _lastReportTime != null) { + final elapsed = now.difference(_lastReportTime!); + if (elapsed < minReportInterval) { + final remaining = (minReportInterval - elapsed).inSeconds + 1; + throw RoadAlertRateLimitException(remaining); + } + } + + // Duplicate detection and auto-merging + final duplicate = _findDuplicateAlert( + category: category, + latitude: latitude, + longitude: longitude, + reportedAt: now, + ); + + if (duplicate != null) { + if (kDebugMode) { + debugPrint('[SwarmRoadAlertService] Duplicate alert detected: ${duplicate.id}. Auto-confirming.'); + } + if (duplicate.reporterUid != uid) { + await confirmAlert(duplicate.id, voterUid: uid); + return _localSwarmCache[duplicate.id] ?? duplicate; + } + return duplicate; + } + + _lastReportTime = now; + final uidSuffix = uid.length >= 5 ? uid.substring(0, 5) : uid; final count = ++_idCounter; final alertId = 'swarm_${now.millisecondsSinceEpoch}_${count}_$uidSuffix'; - final effectiveTtl = ttl ?? - (category == RoadAlertCategory.police - ? const Duration(minutes: 60) - : const Duration(hours: 2)); + final effectiveTtl = ttl ?? defaultTtlForCategory(category); final expiresAt = now.add(effectiveTtl); - final desc = description ?? - (category == RoadAlertCategory.police - ? 'Police speed trap reported by rider' - : (category == RoadAlertCategory.hazard - ? 'Road hazard / debris reported' - : (category == RoadAlertCategory.accident - ? 'Traffic accident reported' - : 'Rider safety report'))); + final desc = description ?? defaultDescriptionForCategory(category); + final effectiveSource = source ?? + (groupRideId != null ? 'Pack Rider Report' : 'MotorSignal Swarm'); final report = SwarmReport( id: alertId, category: category, + subtype: subtype, latitude: latitude, longitude: longitude, heading: heading, @@ -91,6 +249,8 @@ class SwarmRoadAlertService { confirmations: 1, dismissals: 0, groupRideId: groupRideId, + source: effectiveSource, + status: RoadAlertStatus.active, ); // Cache locally immediately @@ -123,12 +283,37 @@ class SwarmRoadAlertService { return report; } - /// Trailing rider confirms an existing alert (increases confidence) - Future confirmAlert(String alertId) async { + /// Trailing rider confirms an existing alert (increases confidence). + /// Enforces per-user vote restrictions (no repeat votes, no self-voting). + Future confirmAlert(String alertId, {String? voterUid}) async { + final uid = voterUid ?? _currentUid ?? 'local_rider'; final cached = _localSwarmCache[alertId]; + + if (cached != null && cached.reporterUid == uid && voterUid != null) { + if (kDebugMode) { + debugPrint('[SwarmRoadAlertService] Self-voting rejected for alert $alertId'); + } + return false; + } + + final voteKey = '$alertId:$uid'; + if (_userVotes.contains(voteKey)) { + if (kDebugMode) { + debugPrint('[SwarmRoadAlertService] Repeat voting rejected for alert $alertId'); + } + return false; + } + _userVotes.add(voteKey); + if (cached != null) { - _localSwarmCache[alertId] = - cached.copyWith(confirmations: cached.confirmations + 1); + final updatedConfirmations = cached.confirmations + 1; + final newStatus = updatedConfirmations >= 2 + ? RoadAlertStatus.confirmed + : cached.status; + _localSwarmCache[alertId] = cached.copyWith( + confirmations: updatedConfirmations, + status: newStatus, + ); } final firestore = _firestore; @@ -143,16 +328,33 @@ class SwarmRoadAlertService { } } } + return true; } - /// Trailing rider reports alert is clear / no longer present - Future clearAlert(String alertId) async { + /// Trailing rider reports alert is clear / no longer present. + /// Automatically marks alert cleared/expired when dismissals reach threshold. + Future clearAlert(String alertId, {String? voterUid}) async { + final uid = voterUid ?? _currentUid ?? 'local_rider'; + final voteKey = '$alertId:$uid'; + if (_userVotes.contains(voteKey)) { + if (kDebugMode) { + debugPrint('[SwarmRoadAlertService] Repeat dismissal rejected for alert $alertId'); + } + return false; + } + _userVotes.add(voteKey); + final cached = _localSwarmCache[alertId]; + final now = DateTime.now(); + if (cached != null) { final updatedDismissals = cached.dismissals + 1; if (updatedDismissals >= 2 && updatedDismissals >= cached.confirmations) { - // Expire immediately - _localSwarmCache.remove(alertId); + _localSwarmCache[alertId] = cached.copyWith( + dismissals: updatedDismissals, + expiresAt: now, + status: RoadAlertStatus.cleared, + ); } else { _localSwarmCache[alertId] = cached.copyWith(dismissals: updatedDismissals); @@ -170,10 +372,10 @@ class SwarmRoadAlertService { final confirmations = (data['confirmations'] as num?)?.toInt() ?? 1; if (dismissals + 1 >= 2 && (dismissals + 1) >= confirmations) { - // Mark expired await docRef.update({ 'dismissals': FieldValue.increment(1), - 'expiresAt': DateTime.now().millisecondsSinceEpoch, + 'expiresAt': now.millisecondsSinceEpoch, + 'status': 'cleared', }); } else { await docRef.update({ @@ -187,6 +389,28 @@ class SwarmRoadAlertService { } } } + return true; + } + + /// Deletes alert if caller is the reporter. + Future deleteAlert(String alertId) async { + final uid = _currentUid ?? 'local_rider'; + final cached = _localSwarmCache[alertId]; + if (cached != null && cached.reporterUid != uid) { + return false; + } + _localSwarmCache.remove(alertId); + final firestore = _firestore; + if (firestore != null) { + try { + await firestore.collection('road_alerts').doc(alertId).delete(); + } catch (e) { + if (kDebugMode) { + debugPrint('[SwarmRoadAlertService] Firestore delete error: $e'); + } + } + } + return true; } /// Queries all live alerts within [radiusMeters] of [latitude, longitude]. @@ -200,7 +424,9 @@ class SwarmRoadAlertService { // 1. Ingest local in-memory cache for (var entry in _localSwarmCache.entries) { - if (!entry.value.isExpired) { + if (!entry.value.isExpired && + entry.value.status != RoadAlertStatus.cleared && + entry.value.status != RoadAlertStatus.expired) { candidates[entry.key] = entry.value; } } @@ -226,6 +452,11 @@ class SwarmRoadAlertService { for (var doc in querySnap.docs) { try { final report = SwarmReport.fromJson(doc.data()); + // Filter out cleared/expired documents + if (report.status == RoadAlertStatus.cleared || + report.status == RoadAlertStatus.expired) { + continue; + } // Client-side bounding box filter if (report.latitude >= minLat && report.latitude <= maxLat && @@ -247,6 +478,10 @@ class SwarmRoadAlertService { final List results = []; for (var report in candidates.values) { if (report.isExpired) continue; + if (report.status == RoadAlertStatus.cleared || + report.status == RoadAlertStatus.expired) { + continue; + } final dist = Geolocator.distanceBetween( latitude, @@ -265,8 +500,10 @@ class SwarmRoadAlertService { return results; } - /// Clears local in-memory cache (for testing or reset) + /// Clears local in-memory cache and voting records (for testing or reset) void clearLocalCache() { _localSwarmCache.clear(); + _userVotes.clear(); + _lastReportTime = null; } } diff --git a/lib/services/alerts/waze_osint_road_alert_provider.dart b/lib/services/alerts/waze_osint_road_alert_provider.dart new file mode 100644 index 0000000..752e5d7 --- /dev/null +++ b/lib/services/alerts/waze_osint_road_alert_provider.dart @@ -0,0 +1,402 @@ +import 'dart:convert'; +import 'dart:math' as math; +import 'package:flutter/foundation.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:http/http.dart' as http; +import 'road_alert_models.dart'; +import 'road_alert_provider_interface.dart'; + +/// Experimental community road alert adapter inspired by ArgeliusLabs/waze_osint. +/// +/// Features: +/// - Direct GeoRSS / LiveMap bounding box queries. +/// - Client-side rate limiting and cooldown to protect public endpoints. +/// - Full mapping of Waze types and subtypes to 10 MotorSignal alert categories. +/// - Offline / test-safe JSON fixture parsing with zero live network calls in unit tests. +/// - Disabled by default; clearly labeled as experimental community data. +class WazeOsintRoadAlertProvider implements RoadAlertProvider { + final http.Client _client; + final String baseUrl; + final Duration minQueryInterval; + + DateTime? _lastQueryTime; + List _cachedAlerts = []; + + WazeOsintRoadAlertProvider({ + http.Client? client, + this.baseUrl = 'https://www.waze.com/live-map/api/georss', + this.minQueryInterval = const Duration(seconds: 30), + }) : _client = client ?? http.Client(); + + @override + String get id => 'waze_osint'; + + @override + String get name => 'Waze Community (Experimental OSINT)'; + + @override + Set get supportedCategories => const { + RoadAlertCategory.police, + RoadAlertCategory.accident, + RoadAlertCategory.hazard, + RoadAlertCategory.pothole, + RoadAlertCategory.debris, + RoadAlertCategory.disabledVehicle, + RoadAlertCategory.roadClosure, + RoadAlertCategory.construction, + RoadAlertCategory.traffic, + RoadAlertCategory.weatherHazard, + }; + + @override + Future testConnection(String apiKey) async { + // OSINT adapter is community-sourced and does not require an API key + return RoadAlertProviderStatus.connected; + } + + @override + Future> getAlerts({ + required double latitude, + required double longitude, + required double radiusMeters, + String? apiKey, + }) async { + final now = DateTime.now(); + + // Respect client rate limiting interval to prevent hammering public endpoints + if (_lastQueryTime != null && now.difference(_lastQueryTime!) < minQueryInterval) { + return _filterByRadius(_cachedAlerts, latitude, longitude, radiusMeters); + } + + try { + final clampedRadius = radiusMeters.clamp(500.0, 25000.0); + final latDelta = clampedRadius / 111320.0; + final cosLat = math.cos(latitude * math.pi / 180.0).abs().clamp(0.01, 1.0); + final lngDelta = clampedRadius / (111320.0 * cosLat); + + final top = (latitude + latDelta).clamp(-90.0, 90.0); + final bottom = (latitude - latDelta).clamp(-90.0, 90.0); + final left = (longitude - lngDelta).clamp(-180.0, 180.0); + final right = (longitude + lngDelta).clamp(-180.0, 180.0); + + final uri = Uri.parse(baseUrl).replace( + queryParameters: { + 'top': top.toStringAsFixed(6), + 'bottom': bottom.toStringAsFixed(6), + 'left': left.toStringAsFixed(6), + 'right': right.toStringAsFixed(6), + 'env': 'row', + 'types': 'alerts', + }, + ); + + final response = await _client.get( + uri, + headers: { + 'Accept': 'application/json', + 'User-Agent': 'MotorSignal-Community-Alerts/1.0', + }, + ).timeout(const Duration(seconds: 5)); + + _lastQueryTime = now; + + if (response.statusCode != 200) { + if (kDebugMode) { + debugPrint('[WazeOsintRoadAlertProvider] HTTP ${response.statusCode}'); + } + return _filterByRadius(_cachedAlerts, latitude, longitude, radiusMeters); + } + + final decoded = json.decode(response.body); + final alerts = parseWazeResponse(decoded); + + _cachedAlerts = alerts; + return _filterByRadius(alerts, latitude, longitude, radiusMeters); + } catch (e) { + if (kDebugMode) { + debugPrint('[WazeOsintRoadAlertProvider] Request error: $e'); + } + return _filterByRadius(_cachedAlerts, latitude, longitude, radiusMeters); + } + } + + /// Parses a Waze JSON response payload into a list of [RoadAlert]s. + /// Handles both `{ "alerts": [ ... ] }` objects and raw arrays. + List parseWazeResponse(dynamic data) { + if (data == null) return []; + + List items = []; + if (data is Map) { + if (data['alerts'] is List) { + items = data['alerts'] as List; + } + } else if (data is List) { + items = data; + } + + final Map uniqueAlerts = {}; + + for (final item in items) { + if (item is Map) { + final alert = parseAlertItem(item); + if (alert != null) { + uniqueAlerts[alert.id] = alert; + } + } + } + + return uniqueAlerts.values.toList(); + } + + /// Parses an individual Waze alert item with subtype mapping, confidence scoring, + /// and coordinate normalization. + RoadAlert? parseAlertItem(Map item) { + final rawType = (item['type'] ?? '').toString().toUpperCase().trim(); + if (rawType.isEmpty) return null; + + final rawSubtype = item['subtype']?.toString().toUpperCase().trim(); + + // Extract coordinates: GeoRSS style location {x: lng, y: lat} or top-level + double? lat; + double? lng; + + if (item['location'] is Map) { + final loc = item['location'] as Map; + final rawX = loc['x']; + final rawY = loc['y']; + if (rawX is num && rawY is num) { + lng = rawX.toDouble(); + lat = rawY.toDouble(); + } + } else { + final rawLat = item['latitude'] ?? item['lat']; + final rawLng = item['longitude'] ?? item['lng'] ?? item['lon']; + if (rawLat is num && rawLng is num) { + lat = rawLat.toDouble(); + lng = rawLng.toDouble(); + } + } + + if (lat == null || lng == null) return null; + if (lat < -90.0 || lat > 90.0 || lng < -180.0 || lng > 180.0) return null; + + final uuid = (item['uuid'] ?? item['id'] ?? 'wz_${item.hashCode}').toString(); + final category = mapTypeToCategory(rawType, rawSubtype); + + final street = item['street']?.toString().trim(); + final city = item['city']?.toString().trim(); + final userDesc = item['reportDescription'] ?? item['description']; + + final description = buildDescription( + category: category, + rawType: rawType, + rawSubtype: rawSubtype, + street: street, + userDescription: userDesc?.toString().trim(), + ); + + String? roadName; + if (street != null && street.isNotEmpty) { + roadName = (city != null && city.isNotEmpty) ? '$street, $city' : street; + } else if (city != null && city.isNotEmpty) { + roadName = city; + } + + // Published timestamp + final rawMillis = item['pubMillis'] ?? item['pubMillisUtc']; + final DateTime reportedAt; + if (rawMillis is num) { + reportedAt = DateTime.fromMillisecondsSinceEpoch(rawMillis.toInt()); + } else { + reportedAt = DateTime.tryParse(item['publish_datetime_utc']?.toString() ?? '') ?? + DateTime.now(); + } + + // Heading / Magvar + double? heading; + final rawMagvar = item['magvar']; + if (rawMagvar is num) { + heading = rawMagvar.toDouble(); + } + + // Confidence / Reliability scoring + final confNum = (item['confidence'] ?? item['alert_confidence'] as num?)?.toDouble(); + final relNum = (item['reliability'] ?? item['alert_reliability'] as num?)?.toDouble(); + final rawThumbs = item['nThumbsUp'] ?? item['num_thumbs_up']; + final int thumbsUp = rawThumbs is num ? rawThumbs.toInt() : 0; + + double confidence = 0.6; + if (confNum != null && confNum > 0) { + confidence = (confNum / 5.0).clamp(0.2, 0.95); + } else if (relNum != null && relNum > 0) { + confidence = (relNum / 10.0).clamp(0.2, 0.95); + } + + if (thumbsUp > 0) { + confidence = (confidence + 0.05 * thumbsUp.clamp(0, 5)).clamp(0.2, 1.0); + } + + // Severity mapping + final severity = determineSeverity(category, rawSubtype); + + // Dynamic TTL based on alert category + final ttl = category == RoadAlertCategory.police + ? const Duration(minutes: 60) + : (category == RoadAlertCategory.traffic + ? const Duration(minutes: 45) + : const Duration(hours: 2)); + + return RoadAlert( + id: 'waze_osint_$uuid', + providerId: id, + category: category, + subtype: rawSubtype, + latitude: lat, + longitude: lng, + heading: heading, + roadName: roadName, + description: description, + reportedAt: reportedAt, + expiresAt: reportedAt.add(ttl), + lastUpdated: DateTime.now(), + confidence: confidence, + severity: severity, + source: 'Waze (Experimental)', + sourceUrl: 'https://www.waze.com/live-map', + sourceType: RoadAlertSourceType.providerReported, + status: RoadAlertStatus.active, + confirmations: 1 + thumbsUp, + ); + } + + /// Maps Waze alert types and subtypes into native MotorSignal [RoadAlertCategory]s. + static RoadAlertCategory mapTypeToCategory(String type, String? subtype) { + final upperType = type.toUpperCase().trim(); + final upperSub = (subtype ?? '').toUpperCase().trim(); + + if (upperType == 'POLICE') { + return RoadAlertCategory.police; + } else if (upperType == 'ACCIDENT') { + return RoadAlertCategory.accident; + } else if (upperType == 'JAM') { + return RoadAlertCategory.traffic; + } else if (upperType == 'ROAD_CLOSED') { + return RoadAlertCategory.roadClosure; + } else if (upperType == 'WEATHERHAZARD' || + upperSub.contains('WEATHER') || + upperSub.contains('ICE') || + upperSub.contains('FLOOD') || + upperSub.contains('FOG') || + upperSub.contains('HAIL')) { + return RoadAlertCategory.weatherHazard; + } else if (upperType == 'HAZARD') { + if (upperSub.contains('POT_HOLE') || upperSub.contains('POTHOLE')) { + return RoadAlertCategory.pothole; + } else if (upperSub.contains('OBJECT') || + upperSub.contains('DEBRIS') || + upperSub.contains('ROAD_KILL')) { + return RoadAlertCategory.debris; + } else if (upperSub.contains('CAR_STOPPED') || + upperSub.contains('SHOULDER_CAR_STOPPED') || + upperSub.contains('DISABLED_VEHICLE')) { + return RoadAlertCategory.disabledVehicle; + } else if (upperSub.contains('CONSTRUCTION')) { + return RoadAlertCategory.construction; + } + return RoadAlertCategory.hazard; + } + + return RoadAlertCategory.other; + } + + /// Formats human-readable description for rider safety. + static String buildDescription({ + required RoadAlertCategory category, + required String rawType, + String? rawSubtype, + String? street, + String? userDescription, + }) { + if (userDescription != null && userDescription.isNotEmpty) { + return userDescription; + } + + final onRoad = (street != null && street.isNotEmpty) ? ' on $street' : ''; + + switch (category) { + case RoadAlertCategory.police: + if (rawSubtype != null && rawSubtype.contains('HIDDEN')) { + return 'Hidden police speed trap reported$onRoad'; + } else if (rawSubtype != null && rawSubtype.contains('CAMERA')) { + return 'Police speed trap with camera reported$onRoad'; + } + return 'Police speed trap reported$onRoad'; + case RoadAlertCategory.accident: + if (rawSubtype != null && rawSubtype.contains('MAJOR')) { + return 'Major traffic accident reported$onRoad'; + } + return 'Traffic accident reported$onRoad'; + case RoadAlertCategory.pothole: + return 'Severe pothole reported$onRoad'; + case RoadAlertCategory.debris: + return 'Road debris reported$onRoad'; + case RoadAlertCategory.disabledVehicle: + return 'Disabled vehicle reported on roadway$onRoad'; + case RoadAlertCategory.roadClosure: + return 'Road closure reported$onRoad'; + case RoadAlertCategory.construction: + return 'Construction zone reported$onRoad'; + case RoadAlertCategory.traffic: + return 'Heavy traffic congestion reported$onRoad'; + case RoadAlertCategory.weatherHazard: + return 'Severe weather hazard reported$onRoad'; + case RoadAlertCategory.hazard: + return 'Road hazard reported$onRoad'; + case RoadAlertCategory.speedCamera: + return 'Speed enforcement camera reported$onRoad'; + case RoadAlertCategory.other: + return 'Community safety alert reported$onRoad'; + } + } + + /// Determines alert severity for rider ergonomics. + static RoadAlertSeverity determineSeverity( + RoadAlertCategory category, String? subtype) { + if (category == RoadAlertCategory.police || + category == RoadAlertCategory.accident || + category == RoadAlertCategory.weatherHazard) { + return RoadAlertSeverity.high; + } + if (category == RoadAlertCategory.roadClosure) { + return RoadAlertSeverity.critical; + } + if (subtype != null && + (subtype.contains('MAJOR') || subtype.contains('BLOCKED'))) { + return RoadAlertSeverity.high; + } + return RoadAlertSeverity.medium; + } + + List _filterByRadius( + List alerts, + double userLat, + double userLng, + double radiusMeters, + ) { + final List filtered = []; + for (final alert in alerts) { + final dist = Geolocator.distanceBetween( + userLat, + userLng, + alert.latitude, + alert.longitude, + ); + if (dist <= radiusMeters) { + filtered.add(alert.copyWith(distanceFromUser: dist)); + } + } + filtered.sort((a, b) => + (a.distanceFromUser ?? 0).compareTo(b.distanceFromUser ?? 0)); + return filtered; + } +} diff --git a/lib/services/alerts/widgets/quick_swarm_report_modal.dart b/lib/services/alerts/widgets/quick_swarm_report_modal.dart index 3018a9b..bf310c8 100644 --- a/lib/services/alerts/widgets/quick_swarm_report_modal.dart +++ b/lib/services/alerts/widgets/quick_swarm_report_modal.dart @@ -10,6 +10,107 @@ import '../road_alert_providers.dart'; import '../road_alert_service.dart'; import '../swarm_road_alert_service.dart'; +class _ReportItem { + final RoadAlertCategory category; + final String? subtype; + final String title; + final String subtitle; + final IconData icon; + final Color color; + + const _ReportItem({ + required this.category, + this.subtype, + required this.title, + required this.subtitle, + required this.icon, + required this.color, + }); +} + +const List<_ReportItem> _kReportCategories = [ + _ReportItem( + category: RoadAlertCategory.police, + subtype: 'speed_trap', + title: 'Police', + subtitle: 'Speed trap / radar', + icon: Icons.local_police, + color: Colors.blueAccent, + ), + _ReportItem( + category: RoadAlertCategory.hazard, + subtype: 'slick_surface', + title: 'Road Hazard', + subtitle: 'Gravel, slick, oil', + icon: Icons.warning_amber_rounded, + color: Colors.orangeAccent, + ), + _ReportItem( + category: RoadAlertCategory.pothole, + subtype: 'pothole', + title: 'Pothole', + subtitle: 'Severe pavement break', + icon: Icons.radio_button_checked, + color: Color(0xFFFFB300), + ), + _ReportItem( + category: RoadAlertCategory.debris, + subtype: 'debris', + title: 'Road Debris', + subtitle: 'Tire tread, object in road', + icon: Icons.scatter_plot, + color: Color(0xFFFF7043), + ), + _ReportItem( + category: RoadAlertCategory.accident, + subtype: 'accident', + title: 'Accident', + subtitle: 'Crash scene ahead', + icon: Icons.car_crash, + color: Colors.redAccent, + ), + _ReportItem( + category: RoadAlertCategory.disabledVehicle, + subtype: 'disabled_vehicle', + title: 'Disabled Car', + subtitle: 'Vehicle stopped in lane/shoulder', + icon: Icons.car_repair, + color: Color(0xFFFF5252), + ), + _ReportItem( + category: RoadAlertCategory.roadClosure, + subtype: 'road_closure', + title: 'Road Closed', + subtitle: 'Passage completely blocked', + icon: Icons.do_not_disturb_on, + color: Colors.deepOrange, + ), + _ReportItem( + category: RoadAlertCategory.construction, + subtype: 'construction', + title: 'Construction', + subtitle: 'Work zone / road crew', + icon: Icons.engineering, + color: Colors.amber, + ), + _ReportItem( + category: RoadAlertCategory.traffic, + subtype: 'heavy_traffic', + title: 'Heavy Traffic', + subtitle: 'Standstill or congestion', + icon: Icons.traffic, + color: Colors.yellowAccent, + ), + _ReportItem( + category: RoadAlertCategory.weatherHazard, + subtype: 'weather_hazard', + title: 'Weather Hazard', + subtitle: 'Water, ice, dense fog', + icon: Icons.thunderstorm, + color: Color(0xFF4FC3F7), + ), +]; + class QuickSwarmReportModal extends ConsumerStatefulWidget { const QuickSwarmReportModal({super.key}); @@ -30,10 +131,31 @@ class QuickSwarmReportModal extends ConsumerStatefulWidget { class _QuickSwarmReportModalState extends ConsumerState { bool _submitting = false; + bool _isMovingFast = false; + + @override + void initState() { + super.initState(); + _checkMotionSafety(); + } + + Future _checkMotionSafety() async { + try { + final pos = await Geolocator.getLastKnownPosition(); + if (pos != null && pos.speed > 4.47) { // > 10 MPH (4.47 m/s) + if (mounted) { + setState(() => _isMovingFast = true); + } + } + } catch (_) {} + } @override Widget build(BuildContext context) { + final maxHeight = MediaQuery.of(context).size.height * 0.85; + return Container( + constraints: BoxConstraints(maxHeight: maxHeight), decoration: const BoxDecoration( color: AppColors.darkSurface, borderRadius: BorderRadius.vertical(top: Radius.circular(20)), @@ -41,150 +163,165 @@ class _QuickSwarmReportModalState extends ConsumerState { top: BorderSide(color: AppColors.darkOutline, width: 1.5), ), ), - padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Center( - child: Container( - width: 44, - height: 4, - decoration: BoxDecoration( - color: Colors.white24, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - const SizedBox(height: 16), - Row( + child: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Icon(Icons.campaign, color: AppColors.cyberCyan, size: 24), - const SizedBox(width: 10), - Expanded( - child: Text( - 'REPORT ROAD INCIDENT', - style: TextStyle( - fontFamily: AppTypography.fontHeadline, - fontSize: 16, - fontWeight: FontWeight.bold, - letterSpacing: 0.8, - color: Colors.white, + Center( + child: Container( + width: 44, + height: 4, + decoration: BoxDecoration( + color: Colors.white24, + borderRadius: BorderRadius.circular(2), ), ), ), - IconButton( - icon: const Icon(Icons.close, color: Colors.white60), - onPressed: () => Navigator.of(context).pop(), + const SizedBox(height: 12), + Row( + children: [ + const Icon(Icons.campaign, color: AppColors.cyberCyan, size: 24), + const SizedBox(width: 10), + Expanded( + child: Text( + 'REPORT ROAD INCIDENT', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 16, + fontWeight: FontWeight.bold, + letterSpacing: 0.8, + color: Colors.white, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close, color: Colors.white60), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + const SizedBox(height: 4), + Text( + '1-tap broadcast to all nearby riders & pack members.', + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 12, + color: Colors.white70, + ), + ), + if (_isMovingFast) ...[ + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.amber, width: 1), + ), + child: const Row( + children: [ + Icon(Icons.warning_amber_rounded, color: Colors.amber, size: 18), + SizedBox(width: 8), + Expanded( + child: Text( + 'Movement detected. Pull over safely before reporting while riding.', + style: TextStyle( + color: Colors.amber, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 14), + Flexible( + child: SingleChildScrollView( + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _kReportCategories.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 2.2, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + ), + itemBuilder: (context, index) { + final item = _kReportCategories[index]; + return _buildGridCard(item); + }, + ), + ), ), ], ), - Text( - 'Instant 1-tap broadcast to all nearby riders & pack members.', - style: TextStyle( - fontFamily: AppTypography.fontBody, - fontSize: 12, - color: Colors.white70, - ), - ), - const SizedBox(height: 18), - - // 1. POLICE / SPEED TRAP - _buildActionButton( - context: context, - category: RoadAlertCategory.police, - title: 'POLICE / SPEED TRAP', - subtitle: 'Radar, laser, or patrol car spotted', - icon: Icons.local_police, - color: Colors.blueAccent, - ), - const SizedBox(height: 12), - - // 2. ROAD HAZARD / GRAVEL - _buildActionButton( - context: context, - category: RoadAlertCategory.hazard, - title: 'HAZARD / GRAVEL / DEBRIS', - subtitle: 'Gravel in corner, slick surface, road debris', - icon: Icons.warning_amber_rounded, - color: Colors.orangeAccent, - ), - const SizedBox(height: 12), - - // 3. ACCIDENT / ROAD CLOSED - _buildActionButton( - context: context, - category: RoadAlertCategory.accident, - title: 'ACCIDENT / CRASH AHEAD', - subtitle: 'Crash or blocked road ahead', - icon: Icons.car_crash, - color: Colors.redAccent, - ), - ], + ), ), ); } - Widget _buildActionButton({ - required BuildContext context, - required RoadAlertCategory category, - required String title, - required String subtitle, - required IconData icon, - required Color color, - }) { + Widget _buildGridCard(_ReportItem item) { return ElevatedButton( style: ElevatedButton.styleFrom( - backgroundColor: color.withValues(alpha: 0.15), + backgroundColor: item.color.withValues(alpha: 0.12), foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), - side: BorderSide(color: color, width: 1.5), + side: BorderSide(color: item.color.withValues(alpha: 0.7), width: 1.5), ), elevation: 0, ), - onPressed: _submitting ? null : () => _handleReport(category), + onPressed: _submitting ? null : () => _handleReport(item.category, subtype: item.subtype), child: Row( children: [ Container( - padding: const EdgeInsets.all(10), + padding: const EdgeInsets.all(7), decoration: BoxDecoration( - color: color.withValues(alpha: 0.25), + color: item.color.withValues(alpha: 0.22), borderRadius: BorderRadius.circular(8), ), - child: Icon(icon, color: color, size: 28), + child: Icon(item.icon, color: item.color, size: 22), ), - const SizedBox(width: 14), + const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - title, + item.title, style: TextStyle( fontFamily: AppTypography.fontHeadline, - fontSize: 14, + fontSize: 13, fontWeight: FontWeight.bold, color: Colors.white, - letterSpacing: 0.5, ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), Text( - subtitle, - style: const TextStyle(fontSize: 11, color: Colors.white70), + item.subtitle, + style: const TextStyle(fontSize: 10, color: Colors.white70), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ], ), ), - const Icon(Icons.arrow_forward_ios, color: Colors.white38, size: 16), ], ), ); } - Future _handleReport(RoadAlertCategory category) async { + Future _handleReport(RoadAlertCategory category, {String? subtype}) async { if (_submitting) return; setState(() => _submitting = true); HapticFeedback.heavyImpact(); @@ -193,6 +330,7 @@ class _QuickSwarmReportModalState extends ConsumerState { final activeRide = ref.read(activeUserGroupRideProvider); final swarmService = ref.read(swarmRoadAlertServiceProvider); final alertService = ref.read(roadAlertServiceProvider); + // 1. Acquire current position Position? pos; try { @@ -209,7 +347,6 @@ class _QuickSwarmReportModalState extends ConsumerState { ); } - // Closing the sheet before location resolves cancels the report. if (!mounted || ModalRoute.of(context)?.isCurrent != true) return; final lat = pos?.latitude ?? 0.0; @@ -226,11 +363,11 @@ class _QuickSwarmReportModalState extends ConsumerState { return; } - // 2. Check active group ride - // 3. Dispatch to Swarm Service + // 2. Dispatch to Swarm Service var cloudStatus = SwarmCloudStatus.unavailable; final report = await swarmService.reportAlert( category: category, + subtype: subtype, latitude: lat, longitude: lng, heading: heading, @@ -238,7 +375,6 @@ class _QuickSwarmReportModalState extends ConsumerState { onCloudStatus: (status) => cloudStatus = status, ); - // The sheet or its ProviderScope may have gone away during submission. if (!mounted || ModalRoute.of(context)?.isCurrent != true) return; alertService.simulateAlert(report.toRoadAlert()); Navigator.of(context).pop(); @@ -252,13 +388,13 @@ class _QuickSwarmReportModalState extends ConsumerState { Expanded( child: Text(switch (cloudStatus) { SwarmCloudStatus.confirmed => - '${category.displayName} added to local alerts and saved to cloud.', + '${category.displayName} reported to community and saved to cloud.', SwarmCloudStatus.pending => - '${category.displayName} added locally; cloud delivery pending.', + '${category.displayName} reported locally; cloud delivery pending.', SwarmCloudStatus.failed => - '${category.displayName} added locally; cloud delivery failed.', + '${category.displayName} reported locally; cloud delivery failed.', SwarmCloudStatus.unavailable => - '${category.displayName} added locally; cloud unavailable.', + '${category.displayName} reported locally; operating offline.', }, style: const TextStyle(fontWeight: FontWeight.bold)), ), ], @@ -267,6 +403,15 @@ class _QuickSwarmReportModalState extends ConsumerState { duration: const Duration(seconds: 3), ), ); + } on RoadAlertRateLimitException catch (rateErr) { + if (mounted && ModalRoute.of(context)?.isCurrent == true) { + messenger.showSnackBar( + SnackBar( + content: Text(rateErr.toString()), + backgroundColor: Colors.orangeAccent, + ), + ); + } } catch (e) { if (mounted && ModalRoute.of(context)?.isCurrent == true) { messenger.showSnackBar( @@ -296,7 +441,7 @@ class QuickSwarmReportButton extends StatelessWidget { icon: const Icon(Icons.campaign, color: Colors.orangeAccent, size: 20), tooltip: 'Report Road Hazard / Police', padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + constraints: const BoxConstraints(minWidth: 48, minHeight: 48), onPressed: () => QuickSwarmReportModal.show(context), ); } @@ -304,19 +449,18 @@ class QuickSwarmReportButton extends StatelessWidget { style: ElevatedButton.styleFrom( backgroundColor: Colors.orangeAccent.withValues(alpha: 0.15), foregroundColor: Colors.orangeAccent, - side: const BorderSide(color: Colors.orangeAccent, width: 1), + side: const BorderSide(color: Colors.orangeAccent, width: 1.5), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - minimumSize: Size.zero, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + minimumSize: const Size(48, 48), tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), - icon: const Icon(Icons.campaign, size: 16), + icon: const Icon(Icons.campaign, size: 18), label: const Text( 'REPORT', - style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold), + style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold), ), onPressed: () => QuickSwarmReportModal.show(context), ); } } - diff --git a/test/route_corridor_alert_filter_test.dart b/test/route_corridor_alert_filter_test.dart new file mode 100644 index 0000000..7c9311e --- /dev/null +++ b/test/route_corridor_alert_filter_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/services/map/map_provider_interface.dart'; +import 'package:motorcycle_dash/services/alerts/road_alert_service.dart'; + +void main() { + group('RouteCorridorAlertFilter Unit Tests', () { + // Route from (35.0000, -84.0000) straight North to (35.0100, -84.0000) + // 0.01 deg lat is approximately 1113.2 meters + final routeNorth = [ + const AppLatLng(35.0000, -84.0000), + const AppLatLng(35.0050, -84.0000), + const AppLatLng(35.0100, -84.0000), + ]; + + test('minDistanceToPolyline calculates perpendicular cross-track distance accurately', () { + // 1. Point on the route polyline + final onRouteDist = RouteCorridorAlertFilter.minDistanceToPolyline( + 35.0025, + -84.0000, + routeNorth, + ); + expect(onRouteDist, closeTo(0.0, 1.0)); + + // 2. Point 50 meters East of polyline + // At lat 35 deg, 1 deg lon is ~ 111320 * cos(35 deg) ~ 91187 m + // 50 m is ~ 50 / 91187 ~ 0.000548 deg + final dLon50m = 50.0 / (111320.0 * 0.81915); + final dist50m = RouteCorridorAlertFilter.minDistanceToPolyline( + 35.0025, + -84.0000 + dLon50m, + routeNorth, + ); + expect(dist50m, closeTo(50.0, 1.5)); + + // 3. Point 200 meters West of polyline + final dLon200m = 200.0 / (111320.0 * 0.81915); + final dist200m = RouteCorridorAlertFilter.minDistanceToPolyline( + 35.0025, + -84.0000 - dLon200m, + routeNorth, + ); + expect(dist200m, closeTo(200.0, 2.0)); + }); + + test('isAlertAheadOnRoute filters alerts by corridor cross-track and ahead progress', () { + final userLat = 35.0010; + final userLng = -84.0000; + + final dLon40m = 40.0 / (111320.0 * 0.81915); + final dLon120m = 120.0 / (111320.0 * 0.81915); + + // Alert 1: Ahead on route, 40m cross-track (within 80m corridor, ~400m ahead) -> Passes + final alertAheadInCorridor = RouteCorridorAlertFilter.isAlertAheadOnRoute( + userLat: userLat, + userLng: userLng, + alertLat: 35.0050, + alertLng: -84.0000 + dLon40m, + geometry: routeNorth, + maxCrossTrackMeters: 80.0, + maxAheadMeters: 1200.0, + ); + expect(alertAheadInCorridor, isTrue); + + // Alert 2: Ahead on route, but 120m cross-track (outside 80m corridor) -> Fails + final alertOutsideCorridor = RouteCorridorAlertFilter.isAlertAheadOnRoute( + userLat: userLat, + userLng: userLng, + alertLat: 35.0050, + alertLng: -84.0000 + dLon120m, + geometry: routeNorth, + maxCrossTrackMeters: 80.0, + maxAheadMeters: 1200.0, + ); + expect(alertOutsideCorridor, isFalse); + + // Alert 3: Behind the rider (South of user, closer to segment 0) -> Fails + final alertBehindUser = RouteCorridorAlertFilter.isAlertAheadOnRoute( + userLat: 35.0060, + userLng: -84.0000, + alertLat: 35.0005, + alertLng: -84.0000, + geometry: routeNorth, + maxCrossTrackMeters: 80.0, + maxAheadMeters: 1200.0, + ); + expect(alertBehindUser, isFalse); + + // Alert 4: Ahead, but beyond maxAheadMeters (> 1200m) + final alertTooFarAhead = RouteCorridorAlertFilter.isAlertAheadOnRoute( + userLat: 35.0000, + userLng: -84.0000, + alertLat: 35.0200, // ~2200m ahead + alertLng: -84.0000, + geometry: [ + ...routeNorth, + const AppLatLng(35.0200, -84.0000), + ], + maxCrossTrackMeters: 80.0, + maxAheadMeters: 1200.0, + ); + expect(alertTooFarAhead, isFalse); + }); + }); +} diff --git a/test/swarm_road_alert_test.dart b/test/swarm_road_alert_test.dart index 32e9354..c530ca8 100644 --- a/test/swarm_road_alert_test.dart +++ b/test/swarm_road_alert_test.dart @@ -231,12 +231,12 @@ void main() { ); // 1st clear: increments dismissals to 1 - await service.clearAlert(report.id); + await service.clearAlert(report.id, voterUid: 'rider_a'); var doc = await fakeFirestore.collection('road_alerts').doc(report.id).get(); expect(doc.data()!['dismissals'], 1); // 2nd clear: dismissals becomes 2, which is >= confirmations (1) -> expires immediately - await service.clearAlert(report.id); + await service.clearAlert(report.id, voterUid: 'rider_b'); doc = await fakeFirestore.collection('road_alerts').doc(report.id).get(); expect(doc.data()!['dismissals'], 2); final expiresAt = doc.data()!['expiresAt'] as int; @@ -340,4 +340,238 @@ void main() { expect(activeProviders.any((p) => p.id == 'swarm_alerts'), isTrue); }); }); + + group('Native Road Alert Categories & Verification Trust Tests', () { + test('all 10 native categories map properly to RoadAlert', () { + final now = DateTime.now(); + final expires = now.add(const Duration(hours: 2)); + + final categories = [ + RoadAlertCategory.police, + RoadAlertCategory.accident, + RoadAlertCategory.hazard, + RoadAlertCategory.pothole, + RoadAlertCategory.debris, + RoadAlertCategory.disabledVehicle, + RoadAlertCategory.roadClosure, + RoadAlertCategory.construction, + RoadAlertCategory.traffic, + RoadAlertCategory.weatherHazard, + ]; + + for (final cat in categories) { + final report = SwarmReport( + id: 'report_${cat.name}', + category: cat, + subtype: 'severe_${cat.name}', + latitude: 35.0, + longitude: -84.0, + description: 'Rider alert for ${cat.name}', + reporterUid: 'rider_1', + reportedAt: now, + expiresAt: expires, + confirmations: 1, + dismissals: 0, + source: 'MotorSignal Swarm', + status: RoadAlertStatus.active, + ); + + final alert = report.toRoadAlert(); + expect(alert.category, cat); + expect(alert.subtype, 'severe_${cat.name}'); + expect(alert.isCommunityConfirmed, isFalse); + expect(alert.confirmations, 1); + expect(alert.dismissals, 0); + expect(alert.status, RoadAlertStatus.active); + } + }); + + test('isCommunityConfirmed requires at least 2 confirmations', () { + final now = DateTime.now(); + final expires = now.add(const Duration(hours: 2)); + + final unconfirmed = SwarmReport( + id: 'rep_1', + category: RoadAlertCategory.pothole, + latitude: 35.0, + longitude: -84.0, + description: 'Pot hole test', + reporterUid: 'rider_1', + reportedAt: now, + expiresAt: expires, + confirmations: 1, + dismissals: 0, + ).toRoadAlert(); + expect(unconfirmed.isCommunityConfirmed, isFalse); + + final confirmed = SwarmReport( + id: 'rep_2', + category: RoadAlertCategory.pothole, + latitude: 35.0, + longitude: -84.0, + description: 'Pot hole test', + reporterUid: 'rider_1', + reportedAt: now, + expiresAt: expires, + confirmations: 2, + dismissals: 0, + ).toRoadAlert(); + expect(confirmed.isCommunityConfirmed, isTrue); + }); + }); + + group('Rate Limiting & Duplicate Detection Tests', () { + late FakeFirebaseFirestore fakeFirestore; + + setUp(() { + fakeFirestore = FakeFirebaseFirestore(); + }); + + test('enforces rate limit interval and throws RoadAlertRateLimitException', () async { + final rateLimitedService = SwarmRoadAlertService( + firestore: fakeFirestore, + minReportInterval: const Duration(seconds: 15), + ); + + // First report succeeds + final first = await rateLimitedService.reportAlert( + category: RoadAlertCategory.police, + latitude: 35.0, + longitude: -84.0, + ); + expect(first.id, startsWith('swarm_')); + + // Second immediate report at distant location throws rate limit exception + expect( + () => rateLimitedService.reportAlert( + category: RoadAlertCategory.accident, + latitude: 36.0, + longitude: -85.0, + ), + throwsA(isA().having( + (e) => e.retryAfterSeconds, + 'retryAfterSeconds', + inInclusiveRange(1, 15), + )), + ); + + // With ignoreRateLimit: true, report succeeds + final bypassReport = await rateLimitedService.reportAlert( + category: RoadAlertCategory.accident, + latitude: 36.0, + longitude: -85.0, + ignoreRateLimit: true, + ); + expect(bypassReport.id, startsWith('swarm_')); + }); + + test('duplicate reports within 200m and 15min auto-confirm existing alert', () async { + final service = SwarmRoadAlertService( + firestore: fakeFirestore, + minReportInterval: Duration.zero, + duplicateDistanceMeters: 200.0, + duplicateWindow: const Duration(minutes: 15), + ); + + // Rider 1 reports a pothole at lat 35.0000, lng -84.0000 + final first = await service.reportAlert( + category: RoadAlertCategory.pothole, + latitude: 35.0000, + longitude: -84.0000, + description: 'Deep rim bender', + reporterUid: 'rider_1', + ); + expect(first.confirmations, 1); + + // Rider 2 reports a pothole 50m away (lat 35.0004, lng -84.0000) + final duplicateMerged = await service.reportAlert( + category: RoadAlertCategory.pothole, + latitude: 35.0004, + longitude: -84.0000, + description: 'Bad crater in right lane', + reporterUid: 'rider_2', + ); + + // Auto-confirmed: ID matches original, confirmations incremented to 2 + expect(duplicateMerged.id, first.id); + expect(duplicateMerged.confirmations, 2); + + // Check Firestore doc has 2 confirmations + final doc = await fakeFirestore.collection('road_alerts').doc(first.id).get(); + expect(doc.data()!['confirmations'], 2); + + // Distant report (500m away: lat 35.0050) creates a separate alert + final separate = await service.reportAlert( + category: RoadAlertCategory.pothole, + latitude: 35.0050, + longitude: -84.0000, + ); + expect(separate.id, isNot(first.id)); + expect(separate.confirmations, 1); + }); + + test('per-user voting restrictions prevent self-voting and repeat voting', () async { + final service = SwarmRoadAlertService(firestore: fakeFirestore); + + final report = await service.reportAlert( + category: RoadAlertCategory.debris, + latitude: 35.0, + longitude: -84.0, + ); + + // Reporter cannot vote on their own alert + expect(service.canUserVote(report.id, report.reporterUid), isFalse); + final selfVote = await service.confirmAlert(report.id, voterUid: report.reporterUid); + expect(selfVote, isFalse); + + // Another user can vote once + const otherUser = 'rider_trail_42'; + expect(service.canUserVote(report.id, otherUser), isTrue); + expect(service.hasUserVoted(report.id, otherUser), isFalse); + + final firstVote = await service.confirmAlert(report.id, voterUid: otherUser); + expect(firstVote, isTrue); + expect(service.hasUserVoted(report.id, otherUser), isTrue); + expect(service.canUserVote(report.id, otherUser), isFalse); + + // Repeat vote is rejected + final repeatVote = await service.confirmAlert(report.id, voterUid: otherUser); + expect(repeatVote, isFalse); + }); + + test('cleared alerts are marked cleared and pruned from nearby queries', () async { + final service = SwarmRoadAlertService(firestore: fakeFirestore); + + final report = await service.reportAlert( + category: RoadAlertCategory.hazard, + latitude: 35.1, + longitude: -84.1, + ); + + // Query finds it while active + var nearby = await service.getAlertsNear( + latitude: 35.1, + longitude: -84.1, + radiusMeters: 1000, + ); + expect(nearby.length, 1); + + // Two dismissals from different riders + await service.clearAlert(report.id, voterUid: 'voter_a'); + await service.clearAlert(report.id, voterUid: 'voter_b'); + + // Now marked cleared / expired + final cached = service.getCachedReport(report.id); + expect(cached, isNotNull); + expect(cached!.status, RoadAlertStatus.cleared); + + // Query prunes cleared alerts + nearby = await service.getAlertsNear( + latitude: 35.1, + longitude: -84.1, + radiusMeters: 1000, + ); + expect(nearby, isEmpty); + }); + }); } diff --git a/test/waze_osint_road_alert_provider_test.dart b/test/waze_osint_road_alert_provider_test.dart new file mode 100644 index 0000000..445bc05 --- /dev/null +++ b/test/waze_osint_road_alert_provider_test.dart @@ -0,0 +1,228 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:motorcycle_dash/services/alerts/road_alert_models.dart'; +import 'package:motorcycle_dash/services/alerts/waze_osint_road_alert_provider.dart'; + +void main() { + group('WazeOsintRoadAlertProvider Category Mapping', () { + test('maps all Waze types and subtypes to 10 native MotorSignal categories', () { + expect( + WazeOsintRoadAlertProvider.mapTypeToCategory('POLICE', null), + RoadAlertCategory.police, + ); + expect( + WazeOsintRoadAlertProvider.mapTypeToCategory('POLICE', 'POLICE_HIDDEN'), + RoadAlertCategory.police, + ); + expect( + WazeOsintRoadAlertProvider.mapTypeToCategory('ACCIDENT', 'ACCIDENT_MAJOR'), + RoadAlertCategory.accident, + ); + expect( + WazeOsintRoadAlertProvider.mapTypeToCategory('JAM', 'JAM_HEAVY_TRAFFIC'), + RoadAlertCategory.traffic, + ); + expect( + WazeOsintRoadAlertProvider.mapTypeToCategory('ROAD_CLOSED', 'ROAD_CLOSED_HAZARD'), + RoadAlertCategory.roadClosure, + ); + expect( + WazeOsintRoadAlertProvider.mapTypeToCategory('WEATHERHAZARD', 'HAZARD_WEATHER_ICE'), + RoadAlertCategory.weatherHazard, + ); + expect( + WazeOsintRoadAlertProvider.mapTypeToCategory('HAZARD', 'HAZARD_ON_ROAD_POT_HOLE'), + RoadAlertCategory.pothole, + ); + expect( + WazeOsintRoadAlertProvider.mapTypeToCategory('HAZARD', 'HAZARD_ON_ROAD_OBJECT'), + RoadAlertCategory.debris, + ); + expect( + WazeOsintRoadAlertProvider.mapTypeToCategory('HAZARD', 'HAZARD_ON_SHOULDER_CAR_STOPPED'), + RoadAlertCategory.disabledVehicle, + ); + expect( + WazeOsintRoadAlertProvider.mapTypeToCategory('HAZARD', 'HAZARD_ON_ROAD_CONSTRUCTION'), + RoadAlertCategory.construction, + ); + expect( + WazeOsintRoadAlertProvider.mapTypeToCategory('HAZARD', 'HAZARD_GENERIC'), + RoadAlertCategory.hazard, + ); + expect( + WazeOsintRoadAlertProvider.mapTypeToCategory('OTHER', null), + RoadAlertCategory.other, + ); + }); + }); + + group('WazeOsintRoadAlertProvider JSON Fixture Parsing', () { + late WazeOsintRoadAlertProvider provider; + + setUp(() { + provider = WazeOsintRoadAlertProvider(); + }); + + test('parses realistic LiveMap GeoRSS JSON fixture with multiple alerts', () { + final fixture = { + 'alerts': [ + { + 'uuid': 'alert_police_001', + 'type': 'POLICE', + 'subtype': 'POLICE_HIDDEN', + 'location': {'x': -83.9261, 'y': 35.3934}, + 'street': 'US-129 Deals Gap', + 'city': 'Robbinsville', + 'magvar': 180, + 'reportDescription': 'Trooper on bike hiding behind curve', + 'pubMillis': DateTime.now().millisecondsSinceEpoch, + 'confidence': 5, + 'reliability': 8, + 'nThumbsUp': 4, + }, + { + 'uuid': 'alert_pothole_002', + 'type': 'HAZARD', + 'subtype': 'HAZARD_ON_ROAD_POT_HOLE', + 'latitude': 35.4000, + 'longitude': -83.9300, + 'street': 'Cherohala Skyway', + 'reportDescription': 'Deep rim-bending hole mid-corner', + 'pubMillis': DateTime.now().millisecondsSinceEpoch, + 'nThumbsUp': 2, + }, + { + 'uuid': 'alert_debris_003', + 'type': 'HAZARD', + 'subtype': 'HAZARD_ON_ROAD_OBJECT', + 'location': {'x': -83.9200, 'y': 35.3900}, + 'street': 'NC-28 Moonshiner', + 'nThumbsUp': 0, + }, + { + 'uuid': 'alert_invalid_coords', + 'type': 'POLICE', + 'latitude': 999.0, // invalid latitude + 'longitude': -83.90, + }, + { + 'uuid': 'alert_missing_type', + 'location': {'x': -83.90, 'y': 35.0}, + } + ] + }; + + final parsed = provider.parseWazeResponse(fixture); + + expect(parsed.length, 3); + + final police = parsed.firstWhere((a) => a.id == 'waze_osint_alert_police_001'); + expect(police.category, RoadAlertCategory.police); + expect(police.latitude, 35.3934); + expect(police.longitude, -83.9261); + expect(police.roadName, 'US-129 Deals Gap, Robbinsville'); + expect(police.description, 'Trooper on bike hiding behind curve'); + expect(police.severity, RoadAlertSeverity.high); + expect(police.confirmations, 5); // 1 base + 4 thumbsUp + expect(police.isCommunityConfirmed, isTrue); + + final pothole = parsed.firstWhere((a) => a.id == 'waze_osint_alert_pothole_002'); + expect(pothole.category, RoadAlertCategory.pothole); + expect(pothole.latitude, 35.4000); + expect(pothole.longitude, -83.9300); + expect(pothole.roadName, 'Cherohala Skyway'); + expect(pothole.confirmations, 3); // 1 base + 2 thumbsUp + expect(pothole.isCommunityConfirmed, isTrue); + + final debris = parsed.firstWhere((a) => a.id == 'waze_osint_alert_debris_003'); + expect(debris.category, RoadAlertCategory.debris); + expect(debris.latitude, 35.3900); + expect(debris.longitude, -83.9200); + expect(debris.confirmations, 1); + expect(debris.isCommunityConfirmed, isFalse); + }); + + test('deduplicates alerts sharing the same uuid', () { + final duplicateFixture = { + 'alerts': [ + { + 'uuid': 'dup_1', + 'type': 'ACCIDENT', + 'location': {'x': -84.0, 'y': 35.0}, + }, + { + 'uuid': 'dup_1', + 'type': 'ACCIDENT', + 'location': {'x': -84.0, 'y': 35.0}, + }, + ] + }; + + final parsed = provider.parseWazeResponse(duplicateFixture); + expect(parsed.length, 1); + }); + }); + + group('WazeOsintRoadAlertProvider HTTP Client & Rate Limiting', () { + test('getAlerts queries bounding box and caches results', () async { + int requestCount = 0; + final mockClient = MockClient((request) async { + requestCount++; + expect(request.url.host, 'www.waze.com'); + expect(request.url.path, '/live-map/api/georss'); + expect(request.url.queryParameters['types'], 'alerts'); + expect(request.headers['User-Agent'], 'MotorSignal-Community-Alerts/1.0'); + + final payload = { + 'alerts': [ + { + 'uuid': 'test_waze_live_1', + 'type': 'POLICE', + 'location': {'x': -83.9261, 'y': 35.3934}, + 'street': 'US-129 Deals Gap', + 'pubMillis': DateTime.now().millisecondsSinceEpoch, + 'nThumbsUp': 3, + } + ] + }; + + return http.Response(json.encode(payload), 200, headers: { + 'content-type': 'application/json', + }); + }); + + final provider = WazeOsintRoadAlertProvider( + client: mockClient, + minQueryInterval: const Duration(seconds: 30), + ); + + // 1. Initial query executes HTTP request + final alerts1 = await provider.getAlerts( + latitude: 35.3934, + longitude: -83.9261, + radiusMeters: 5000, + ); + expect(requestCount, 1); + expect(alerts1.length, 1); + expect(alerts1.first.category, RoadAlertCategory.police); + + // 2. Immediate second query within 30s interval uses cache without extra HTTP call + final alerts2 = await provider.getAlerts( + latitude: 35.3934, + longitude: -83.9261, + radiusMeters: 5000, + ); + expect(requestCount, 1); // No new network call + expect(alerts2.length, 1); + }); + + test('testConnection returns connected without requiring paid API key', () async { + final provider = WazeOsintRoadAlertProvider(); + final status = await provider.testConnection(''); + expect(status, RoadAlertProviderStatus.connected); + }); + }); +}