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
This commit is contained in:
Developer 2026-10-10 02:31:06 -04:00
parent 082b22fe00
commit 7bd5a0e585
14 changed files with 2151 additions and 228 deletions

View File

@ -6537,4 +6537,102 @@ describe('MotorCloud Rider Swarm Road Alerts Security Rules', () => {
// Alice (reporter) can delete her alert // Alice (reporter) can delete her alert
await assertSucceeds(deleteDoc(alertRefAlice)); 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',
}));
});
}); });

View File

@ -1505,9 +1505,9 @@ service cloud.firestore {
match /road_alerts/{alertId} { match /road_alerts/{alertId} {
function validAlertKeys() { function validAlertKeys() {
return request.resource.data.keys().hasOnly([ return request.resource.data.keys().hasOnly([
'id', 'category', 'latitude', 'longitude', 'heading', 'id', 'category', 'subtype', 'latitude', 'longitude', 'heading',
'roadName', 'description', 'reporterUid', 'reportedAt', 'roadName', 'description', 'reporterUid', 'reportedAt',
'expiresAt', 'confirmations', 'dismissals', 'groupRideId' 'expiresAt', 'confirmations', 'dismissals', 'groupRideId', 'source', 'status'
]); ]);
} }
@ -1518,7 +1518,8 @@ service cloud.firestore {
&& validAlertKeys() && validAlertKeys()
&& request.resource.data.category in [ && request.resource.data.category in [
'police', 'hazard', 'accident', 'speedCamera', '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.latitude is int || request.resource.data.latitude is float)
&& (request.resource.data.longitude is int || request.resource.data.longitude 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 is int
&& request.resource.data.expiresAt > request.resource.data.reportedAt && request.resource.data.expiresAt > request.resource.data.reportedAt
&& request.resource.data.description is string && 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() { function validAlertUpdate() {
@ -1542,7 +1546,8 @@ service cloud.firestore {
&& request.resource.data.latitude == resource.data.latitude && request.resource.data.latitude == resource.data.latitude
&& request.resource.data.longitude == resource.data.longitude && request.resource.data.longitude == resource.data.longitude
&& request.resource.data.reportedAt == resource.data.reportedAt && 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 request.resource.data.confirmations == resource.data.confirmations + 1

View File

@ -12,6 +12,7 @@ import '../../services/map/map_provider_interface.dart';
import '../../services/map/map_providers.dart'; import '../../services/map/map_providers.dart';
import '../../services/map/unified_map_view.dart'; import '../../services/map/unified_map_view.dart';
import '../../services/alerts/road_alert_models.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/road_alert_service.dart';
import '../../services/alerts/widgets/quick_swarm_report_modal.dart'; import '../../services/alerts/widgets/quick_swarm_report_modal.dart';
import '../../services/map/map_layer_settings.dart'; import '../../services/map/map_layer_settings.dart';
@ -636,30 +637,240 @@ class _MapScreenState extends ConsumerState<MapScreen> {
} }
void _showRoadAlertDetails(RoadAlert alert) { 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( showModalBottomSheet(
context: context, backgroundColor: Colors.grey[900], context: context,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), backgroundColor: Colors.grey[900],
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => Padding( builder: (ctx) => Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(
Row(children: [ mainAxisSize: MainAxisSize.min,
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)), crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(width: 12), children: [
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(
Text(alert.category.displayName.toUpperCase(), style: TextStyle(color: alert.category.color, fontWeight: FontWeight.bold, fontSize: 12, letterSpacing: 1.1)), children: [
if (alert.roadName != null && alert.roadName!.isNotEmpty) Text(alert.roadName!, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), Container(
])), padding: const EdgeInsets.all(8),
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))), decoration: BoxDecoration(
]), color: alert.category.color.withValues(alpha: 0.2),
const SizedBox(height: 16), shape: BoxShape.circle,
Text(alert.description, style: const TextStyle(fontSize: 15, height: 1.3)), ),
const SizedBox(height: 16), child: Icon(alert.category.icon,
const Divider(color: Colors.white24), color: alert.category.color, size: 28),
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)), const SizedBox(width: 12),
Text('Source: ${alert.source}', style: const TextStyle(color: Colors.white54, fontSize: 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,
),
),
],
),
],
],
),
), ),
); );
} }

View File

@ -1026,8 +1026,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
Widget _buildWazeProviderCard(BuildContext context) { Widget _buildWazeProviderCard(BuildContext context) {
final colors = context.motorColors; final colors = context.motorColors;
final wazeStatus = ref.watch(wazeStatusProvider); final wazeOsintStatus = ref.watch(wazeOsintStatusProvider);
final wazeEnabled = ref.watch(wazeRoadAlertsEnabledProvider); final wazeOsintEnabled = ref.watch(wazeOsintEnabledProvider);
return Card( return Card(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
@ -1041,50 +1041,57 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildProviderHeader( _buildProviderHeader(
icon: Icons.local_police, icon: Icons.alt_route,
iconColor: colors.velocityBlue, iconColor: colors.velocityBlue,
title: 'Waze Police Alerts', title: 'Waze Community (Experimental OSINT)',
subtitle: subtitle:
'Real-time Waze crowd-sourced police speed traps & enforcement via OpenWebNinja.', 'Optional community hazard & traffic feed inspired by ArgeliusLabs/waze_osint. Disabled by default; zero paid subscriptions.',
status: wazeStatus, status: wazeOsintStatus,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
SwitchListTile.adaptive( SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: const Text( title: const Text(
'Enable Waze Police Alerts', 'Enable Experimental Waze Feed',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14), style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
), ),
subtitle: Text( 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( style: TextStyle(
fontSize: 12, fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
), ),
value: wazeEnabled, value: wazeOsintEnabled,
onChanged: (val) { onChanged: (val) {
ref ref
.read(wazeRoadAlertsEnabledProvider.notifier) .read(wazeOsintEnabledProvider.notifier)
.setEnabled(val); .setEnabled(val);
}, },
), ),
const SizedBox(height: 12), const SizedBox(height: 8),
TextField( Container(
controller: _wazeKeyController, padding: const EdgeInsets.all(10),
decoration: const InputDecoration( decoration: BoxDecoration(
labelText: 'OpenWebNinja / Waze API Key', color: colors.surfaceContainer,
hintText: 'Enter your OpenWebNinja x-api-key', borderRadius: BorderRadius.circular(8),
helperText: border: Border.all(color: colors.outlineVariant),
'Supplies real-time Waze police traps and enforcement.', ),
helperMaxLines: 2, child: Row(
border: OutlineInputBorder(), children: [
isDense: true, 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), const SizedBox(height: 12),
Align( Align(
@ -1101,35 +1108,17 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
: () async { : () async {
setState(() => _isTestingWaze = true); setState(() => _isTestingWaze = true);
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
final provider = ref.read(wazeProviderInstance); final provider = ref.read(wazeOsintProviderInstance);
final status = await provider.testConnection( final status = await provider.testConnection('');
_wazeKeyController.text, ref.read(wazeOsintStatusProvider.notifier).state = status;
);
ref.read(wazeStatusProvider.notifier).state = status;
setState(() => _isTestingWaze = false); setState(() => _isTestingWaze = false);
if (mounted) { if (mounted) {
String msg = ''; messenger.showSnackBar(
if (status == RoadAlertProviderStatus.connected) { const SnackBar(
msg = 'Waze API Connected successfully!'; content: Text('Waze OSINT adapter configured 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)));
} }
}, },
icon: _isTestingWaze icon: _isTestingWaze
@ -1297,7 +1286,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
controller: _communityKeyController, controller: _communityKeyController,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Community / RapidAPI Key', labelText: 'Community / RapidAPI Key',
hintText: 'Enter OpenWebNinja, RapidAPI, or Community API Key', hintText: 'Enter RapidAPI or Custom Community Key',
helperText: helperText:
'Supplies Police, Speed Cameras, and Crowd Hazards.', 'Supplies Police, Speed Cameras, and Crowd Hazards.',
helperMaxLines: 2, helperMaxLines: 2,
@ -1317,7 +1306,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
hintText: hintText:
'e.g. https://google-map-places.p.rapidapi.com or custom URL', 'e.g. https://google-map-places.p.rapidapi.com or custom URL',
helperText: helperText:
'Leave blank for automatic OpenWebNinja / RapidAPI detection.', 'Leave blank for default endpoint detection.',
helperMaxLines: 2, helperMaxLines: 2,
border: OutlineInputBorder(), border: OutlineInputBorder(),
isDense: true, isDense: true,
@ -1356,7 +1345,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
String msg = ''; String msg = '';
if (status == RoadAlertProviderStatus.connected) { if (status == RoadAlertProviderStatus.connected) {
msg = msg =
'Community / OpenWebNinja API Connected successfully!'; 'Community Traffic API Connected successfully!';
} else if (status == } else if (status ==
RoadAlertProviderStatus.authError) { RoadAlertProviderStatus.authError) {
msg = msg =

View File

@ -5,9 +5,13 @@ enum RoadAlertCategory {
speedCamera, speedCamera,
accident, accident,
hazard, hazard,
pothole,
debris,
disabledVehicle,
roadClosure, roadClosure,
construction, construction,
traffic, traffic,
weatherHazard,
other, other,
} }
@ -22,12 +26,20 @@ extension RoadAlertCategoryExtension on RoadAlertCategory {
return 'Accident'; return 'Accident';
case RoadAlertCategory.hazard: case RoadAlertCategory.hazard:
return 'Hazard'; return 'Hazard';
case RoadAlertCategory.pothole:
return 'Pothole';
case RoadAlertCategory.debris:
return 'Road Debris';
case RoadAlertCategory.disabledVehicle:
return 'Disabled Vehicle';
case RoadAlertCategory.roadClosure: case RoadAlertCategory.roadClosure:
return 'Road Closure'; return 'Road Closure';
case RoadAlertCategory.construction: case RoadAlertCategory.construction:
return 'Construction'; return 'Construction';
case RoadAlertCategory.traffic: case RoadAlertCategory.traffic:
return 'Traffic Delay'; return 'Heavy Traffic';
case RoadAlertCategory.weatherHazard:
return 'Weather Hazard';
case RoadAlertCategory.other: case RoadAlertCategory.other:
return 'Other'; return 'Other';
} }
@ -43,12 +55,20 @@ extension RoadAlertCategoryExtension on RoadAlertCategory {
return Icons.car_crash; return Icons.car_crash;
case RoadAlertCategory.hazard: case RoadAlertCategory.hazard:
return Icons.warning_amber; 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: case RoadAlertCategory.roadClosure:
return Icons.do_not_disturb_on; return Icons.do_not_disturb_on;
case RoadAlertCategory.construction: case RoadAlertCategory.construction:
return Icons.engineering; return Icons.engineering;
case RoadAlertCategory.traffic: case RoadAlertCategory.traffic:
return Icons.traffic; return Icons.traffic;
case RoadAlertCategory.weatherHazard:
return Icons.thunderstorm;
case RoadAlertCategory.other: case RoadAlertCategory.other:
return Icons.report_problem; return Icons.report_problem;
} }
@ -64,12 +84,20 @@ extension RoadAlertCategoryExtension on RoadAlertCategory {
return Colors.redAccent; return Colors.redAccent;
case RoadAlertCategory.hazard: case RoadAlertCategory.hazard:
return Colors.orangeAccent; 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: case RoadAlertCategory.roadClosure:
return Colors.deepOrange; return Colors.deepOrange;
case RoadAlertCategory.construction: case RoadAlertCategory.construction:
return Colors.amber; return Colors.amber;
case RoadAlertCategory.traffic: case RoadAlertCategory.traffic:
return Colors.yellowAccent; return Colors.yellowAccent;
case RoadAlertCategory.weatherHazard:
return const Color(0xFF4FC3F7); // Light Blue 300
case RoadAlertCategory.other: case RoadAlertCategory.other:
return Colors.cyanAccent; return Colors.cyanAccent;
} }
@ -80,6 +108,8 @@ enum RoadAlertSeverity { low, medium, high, critical }
enum RoadAlertSourceType { providerReported, userReported } enum RoadAlertSourceType { providerReported, userReported }
enum RoadAlertStatus { active, confirmed, cleared, expired }
enum RoadAlertProviderStatus { enum RoadAlertProviderStatus {
notConfigured, notConfigured,
configured, configured,
@ -93,6 +123,7 @@ class RoadAlert {
final String id; final String id;
final String providerId; final String providerId;
final RoadAlertCategory category; final RoadAlertCategory category;
final String? subtype;
final double latitude; final double latitude;
final double longitude; final double longitude;
final double? heading; final double? heading;
@ -107,11 +138,16 @@ class RoadAlert {
final String? sourceUrl; final String? sourceUrl;
final double? distanceFromUser; // meters final double? distanceFromUser; // meters
final RoadAlertSourceType sourceType; final RoadAlertSourceType sourceType;
final RoadAlertStatus status;
final int confirmations;
final int dismissals;
final String? reporterUid;
const RoadAlert({ const RoadAlert({
required this.id, required this.id,
required this.providerId, required this.providerId,
required this.category, required this.category,
this.subtype,
required this.latitude, required this.latitude,
required this.longitude, required this.longitude,
this.heading, this.heading,
@ -126,12 +162,19 @@ class RoadAlert {
this.sourceUrl, this.sourceUrl,
this.distanceFromUser, this.distanceFromUser,
this.sourceType = RoadAlertSourceType.providerReported, this.sourceType = RoadAlertSourceType.providerReported,
this.status = RoadAlertStatus.active,
this.confirmations = 1,
this.dismissals = 0,
this.reporterUid,
}); });
bool get isCommunityConfirmed => confirmations >= 2;
RoadAlert copyWith({ RoadAlert copyWith({
String? id, String? id,
String? providerId, String? providerId,
RoadAlertCategory? category, RoadAlertCategory? category,
String? subtype,
double? latitude, double? latitude,
double? longitude, double? longitude,
double? heading, double? heading,
@ -146,11 +189,16 @@ class RoadAlert {
String? sourceUrl, String? sourceUrl,
double? distanceFromUser, double? distanceFromUser,
RoadAlertSourceType? sourceType, RoadAlertSourceType? sourceType,
RoadAlertStatus? status,
int? confirmations,
int? dismissals,
String? reporterUid,
}) { }) {
return RoadAlert( return RoadAlert(
id: id ?? this.id, id: id ?? this.id,
providerId: providerId ?? this.providerId, providerId: providerId ?? this.providerId,
category: category ?? this.category, category: category ?? this.category,
subtype: subtype ?? this.subtype,
latitude: latitude ?? this.latitude, latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude, longitude: longitude ?? this.longitude,
heading: heading ?? this.heading, heading: heading ?? this.heading,
@ -165,6 +213,10 @@ class RoadAlert {
sourceUrl: sourceUrl ?? this.sourceUrl, sourceUrl: sourceUrl ?? this.sourceUrl,
distanceFromUser: distanceFromUser ?? this.distanceFromUser, distanceFromUser: distanceFromUser ?? this.distanceFromUser,
sourceType: sourceType ?? this.sourceType, sourceType: sourceType ?? this.sourceType,
status: status ?? this.status,
confirmations: confirmations ?? this.confirmations,
dismissals: dismissals ?? this.dismissals,
reporterUid: reporterUid ?? this.reporterUid,
); );
} }
} }

View File

@ -10,13 +10,20 @@ import 'swarm_road_alert_service.dart';
import 'swarm_road_alert_provider.dart'; import 'swarm_road_alert_provider.dart';
import 'here_road_alert_provider.dart'; import 'here_road_alert_provider.dart';
import 'waze_road_alert_provider.dart'; import 'waze_road_alert_provider.dart';
import 'waze_osint_road_alert_provider.dart';
final wazeProviderInstance = Provider<WazeRoadAlertProvider>((ref) { final wazeProviderInstance = Provider<WazeRoadAlertProvider>((ref) {
return WazeRoadAlertProvider(); return WazeRoadAlertProvider();
}); });
final wazeOsintProviderInstance = Provider<WazeOsintRoadAlertProvider>((ref) {
return WazeOsintRoadAlertProvider();
});
final swarmRoadAlertServiceProvider = Provider<SwarmRoadAlertService>((ref) { final swarmRoadAlertServiceProvider = Provider<SwarmRoadAlertService>((ref) {
return SwarmRoadAlertService(); return SwarmRoadAlertService(
minReportInterval: const Duration(seconds: 15),
);
}); });
final swarmRoadAlertProviderInstance = Provider<SwarmRoadAlertProvider>((ref) { final swarmRoadAlertProviderInstance = Provider<SwarmRoadAlertProvider>((ref) {
@ -118,9 +125,13 @@ class EnabledCategoriesNotifier extends StateNotifier<Set<RoadAlertCategory>> {
RoadAlertCategory.speedCamera, RoadAlertCategory.speedCamera,
RoadAlertCategory.accident, RoadAlertCategory.accident,
RoadAlertCategory.hazard, RoadAlertCategory.hazard,
RoadAlertCategory.pothole,
RoadAlertCategory.debris,
RoadAlertCategory.disabledVehicle,
RoadAlertCategory.roadClosure, RoadAlertCategory.roadClosure,
RoadAlertCategory.construction, RoadAlertCategory.construction,
RoadAlertCategory.traffic, RoadAlertCategory.traffic,
RoadAlertCategory.weatherHazard,
RoadAlertCategory.other, RoadAlertCategory.other,
}); });
@ -404,9 +415,43 @@ final wazeStatusProvider = StateProvider<RoadAlertProviderStatus>((ref) {
return RoadAlertProviderStatus.configured; return RoadAlertProviderStatus.configured;
}); });
// Experimental Waze OSINT Community Adapter Toggle (Disabled by default)
class WazeOsintEnabledNotifier extends StateNotifier<bool> {
static const _key = 'waze_osint_enabled';
WazeOsintEnabledNotifier() : super(false) {
_load();
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
if (!mounted) return;
final saved = prefs.getBool(_key);
if (saved != null) {
state = saved;
}
}
Future<void> setEnabled(bool enabled) async {
state = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_key, enabled);
if (mounted) {
state = enabled;
}
}
}
final wazeOsintEnabledProvider =
StateNotifierProvider<WazeOsintEnabledNotifier, bool>((ref) {
return WazeOsintEnabledNotifier();
});
final wazeOsintStatusProvider = StateProvider<RoadAlertProviderStatus>((ref) {
final enabled = ref.watch(wazeOsintEnabledProvider);
return enabled ? RoadAlertProviderStatus.connected : RoadAlertProviderStatus.notConfigured;
});
// Active Providers List // Active Providers List
final activeRoadAlertProviders = Provider<List<RoadAlertProvider>>((ref) { final activeRoadAlertProviders = Provider<List<RoadAlertProvider>>((ref) {
final swarmEnabled = ref.watch(swarmAlertsEnabledProvider); final swarmEnabled = ref.watch(swarmAlertsEnabledProvider);
final wazeOsintEnabled = ref.watch(wazeOsintEnabledProvider);
final usCamerasEnabled = ref.watch(usSpeedCamerasEnabledProvider); final usCamerasEnabled = ref.watch(usSpeedCamerasEnabledProvider);
final tomtomKey = ref.watch(tomtomApiKeyProvider); final tomtomKey = ref.watch(tomtomApiKeyProvider);
final communityKey = ref.watch(communityApiKeyProvider); final communityKey = ref.watch(communityApiKeyProvider);
@ -419,6 +464,9 @@ final activeRoadAlertProviders = Provider<List<RoadAlertProvider>>((ref) {
if (swarmEnabled) { if (swarmEnabled) {
list.add(ref.watch(swarmRoadAlertProviderInstance)); list.add(ref.watch(swarmRoadAlertProviderInstance));
} }
if (wazeOsintEnabled) {
list.add(ref.watch(wazeOsintProviderInstance));
}
if (wazeEnabled && wazeKey.isNotEmpty) { if (wazeEnabled && wazeKey.isNotEmpty) {
list.add(ref.watch(wazeProviderInstance)); list.add(ref.watch(wazeProviderInstance));
} }

View File

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -7,6 +8,8 @@ import 'package:geolocator/geolocator.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
import '../location_service.dart'; import '../location_service.dart';
import '../sensor_fusion_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_models.dart';
import 'road_alert_providers.dart'; import 'road_alert_providers.dart';
import 'road_alert_compliance.dart'; import 'road_alert_compliance.dart';
@ -238,7 +241,35 @@ class RoadAlertService {
userLat: position.latitude, userLat: position.latitude,
userLng: position.longitude, userLng: position.longitude,
complianceEnabled: complianceEnabled, 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; _cachedAlerts = compliantList;
_alertsController.add(compliantList); _alertsController.add(compliantList);
@ -395,6 +426,18 @@ class RoadAlertService {
case RoadAlertCategory.traffic: case RoadAlertCategory.traffic:
phrase = "Traffic delay ahead."; phrase = "Traffic delay ahead.";
break; 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: case RoadAlertCategory.other:
phrase = "Traffic alert ahead."; phrase = "Traffic alert ahead.";
break; break;
@ -588,3 +631,93 @@ class RoadAlertService {
} catch (_) {} } catch (_) {}
} }
} }
/// Helper to evaluate route corridor proximity and ahead-of-rider geometry.
class RouteCorridorAlertFilter {
/// Computes minimum cross-track perpendicular distance in meters from point to route polyline.
static double minDistanceToPolyline(
double alertLat,
double alertLng,
List<AppLatLng> geometry,
) {
if (geometry.isEmpty) return double.infinity;
if (geometry.length == 1) {
return Geolocator.distanceBetween(
alertLat,
alertLng,
geometry.first.latitude,
geometry.first.longitude,
);
}
double minDistance = double.infinity;
final cosLat = math.cos(alertLat * math.pi / 180.0);
for (int i = 0; i < geometry.length - 1; i++) {
final p1 = geometry[i];
final p2 = geometry[i + 1];
final dx = (p2.longitude - p1.longitude) * 111320.0 * cosLat;
final dy = (p2.latitude - p1.latitude) * 111320.0;
final segLengthSq = dx * dx + dy * dy;
final ax = (alertLng - p1.longitude) * 111320.0 * cosLat;
final ay = (alertLat - p1.latitude) * 111320.0;
if (segLengthSq == 0.0) {
final dist = math.sqrt(ax * ax + ay * ay);
if (dist < minDistance) minDistance = dist;
continue;
}
final t = ((ax * dx + ay * dy) / segLengthSq).clamp(0.0, 1.0);
final projX = t * dx;
final projY = t * dy;
final dist = math.sqrt((ax - projX) * (ax - projX) + (ay - projY) * (ay - projY));
if (dist < minDistance) {
minDistance = dist;
}
}
return minDistance;
}
/// Evaluates whether an alert is along and ahead of the user's active route corridor.
static bool isAlertAheadOnRoute({
required double userLat,
required double userLng,
required double alertLat,
required double alertLng,
required List<AppLatLng> geometry,
double maxCrossTrackMeters = 80.0,
double maxAheadMeters = 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;
}
}

View File

@ -5,6 +5,7 @@ import 'road_alert_models.dart';
class SwarmReport { class SwarmReport {
final String id; final String id;
final RoadAlertCategory category; final RoadAlertCategory category;
final String? subtype;
final double latitude; final double latitude;
final double longitude; final double longitude;
final double? heading; final double? heading;
@ -16,10 +17,13 @@ class SwarmReport {
final int confirmations; final int confirmations;
final int dismissals; final int dismissals;
final String? groupRideId; final String? groupRideId;
final String? source;
final RoadAlertStatus status;
const SwarmReport({ const SwarmReport({
required this.id, required this.id,
required this.category, required this.category,
this.subtype,
required this.latitude, required this.latitude,
required this.longitude, required this.longitude,
this.heading, this.heading,
@ -31,6 +35,8 @@ class SwarmReport {
this.confirmations = 1, this.confirmations = 1,
this.dismissals = 0, this.dismissals = 0,
this.groupRideId, this.groupRideId,
this.source,
this.status = RoadAlertStatus.active,
}); });
double get confidence { double get confidence {
@ -41,9 +47,12 @@ class SwarmReport {
bool get isExpired => DateTime.now().isAfter(expiresAt); bool get isExpired => DateTime.now().isAfter(expiresAt);
bool get isCommunityConfirmed => confirmations >= 2;
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'id': id, 'id': id,
'category': category.name, 'category': category.name,
if (subtype != null) 'subtype': subtype,
'latitude': latitude, 'latitude': latitude,
'longitude': longitude, 'longitude': longitude,
if (heading != null) 'heading': heading, if (heading != null) 'heading': heading,
@ -55,6 +64,8 @@ class SwarmReport {
'confirmations': confirmations, 'confirmations': confirmations,
'dismissals': dismissals, 'dismissals': dismissals,
if (groupRideId != null) 'groupRideId': groupRideId, if (groupRideId != null) 'groupRideId': groupRideId,
if (source != null) 'source': source,
'status': status.name,
}; };
factory SwarmReport.fromJson(Map<String, dynamic> json) { factory SwarmReport.fromJson(Map<String, dynamic> json) {
@ -75,9 +86,21 @@ class SwarmReport {
: (DateTime.tryParse(rawExpiresAt?.toString() ?? '') ?? : (DateTime.tryParse(rawExpiresAt?.toString() ?? '') ??
reportedAt.add(const Duration(hours: 1))); 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( return SwarmReport(
id: json['id']?.toString() ?? '', id: json['id']?.toString() ?? '',
category: category, category: category,
subtype: json['subtype']?.toString(),
latitude: (json['latitude'] as num).toDouble(), latitude: (json['latitude'] as num).toDouble(),
longitude: (json['longitude'] as num).toDouble(), longitude: (json['longitude'] as num).toDouble(),
heading: json['heading'] != null ? (json['heading'] as num).toDouble() : null, heading: json['heading'] != null ? (json['heading'] as num).toDouble() : null,
@ -86,21 +109,26 @@ class SwarmReport {
reporterUid: json['reporterUid']?.toString() ?? 'anonymous', reporterUid: json['reporterUid']?.toString() ?? 'anonymous',
reportedAt: reportedAt, reportedAt: reportedAt,
expiresAt: expiresAt, expiresAt: expiresAt,
confirmations: (json['confirmations'] as num?)?.toInt() ?? 1, confirmations: confirmations,
dismissals: (json['dismissals'] as num?)?.toInt() ?? 0, dismissals: dismissals,
groupRideId: json['groupRideId']?.toString(), groupRideId: json['groupRideId']?.toString(),
source: json['source']?.toString(),
status: status,
); );
} }
RoadAlert toRoadAlert({double? distanceFromUser}) { RoadAlert toRoadAlert({double? distanceFromUser}) {
final isPoliceOrAccident = final isHighSeverity = category == RoadAlertCategory.police ||
category == RoadAlertCategory.police || category == RoadAlertCategory.accident; category == RoadAlertCategory.accident ||
final sourceName = groupRideId != null ? 'Pack Rider Report' : 'MotorSignal Swarm'; category == RoadAlertCategory.weatherHazard;
final sourceName = source ??
(groupRideId != null ? 'Pack Rider Report' : 'MotorSignal Swarm');
return RoadAlert( return RoadAlert(
id: id, id: id,
providerId: 'swarm_alerts', providerId: 'swarm_alerts',
category: category, category: category,
subtype: subtype,
latitude: latitude, latitude: latitude,
longitude: longitude, longitude: longitude,
heading: heading, heading: heading,
@ -110,9 +138,13 @@ class SwarmReport {
expiresAt: expiresAt, expiresAt: expiresAt,
lastUpdated: DateTime.now(), lastUpdated: DateTime.now(),
confidence: confidence, confidence: confidence,
severity: isPoliceOrAccident ? RoadAlertSeverity.high : RoadAlertSeverity.medium, severity: isHighSeverity ? RoadAlertSeverity.high : RoadAlertSeverity.medium,
source: sourceName, source: sourceName,
sourceType: RoadAlertSourceType.userReported, sourceType: RoadAlertSourceType.userReported,
status: status,
confirmations: confirmations,
dismissals: dismissals,
reporterUid: reporterUid,
distanceFromUser: distanceFromUser, distanceFromUser: distanceFromUser,
); );
} }
@ -120,6 +152,7 @@ class SwarmReport {
SwarmReport copyWith({ SwarmReport copyWith({
String? id, String? id,
RoadAlertCategory? category, RoadAlertCategory? category,
String? subtype,
double? latitude, double? latitude,
double? longitude, double? longitude,
double? heading, double? heading,
@ -131,10 +164,13 @@ class SwarmReport {
int? confirmations, int? confirmations,
int? dismissals, int? dismissals,
String? groupRideId, String? groupRideId,
String? source,
RoadAlertStatus? status,
}) { }) {
return SwarmReport( return SwarmReport(
id: id ?? this.id, id: id ?? this.id,
category: category ?? this.category, category: category ?? this.category,
subtype: subtype ?? this.subtype,
latitude: latitude ?? this.latitude, latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude, longitude: longitude ?? this.longitude,
heading: heading ?? this.heading, heading: heading ?? this.heading,
@ -146,6 +182,8 @@ class SwarmReport {
confirmations: confirmations ?? this.confirmations, confirmations: confirmations ?? this.confirmations,
dismissals: dismissals ?? this.dismissals, dismissals: dismissals ?? this.dismissals,
groupRideId: groupRideId ?? this.groupRideId, groupRideId: groupRideId ?? this.groupRideId,
source: source ?? this.source,
status: status ?? this.status,
); );
} }
} }

View File

@ -10,18 +10,35 @@ import 'swarm_road_alert_models.dart';
enum SwarmCloudStatus { unavailable, confirmed, pending, failed } 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 { class SwarmRoadAlertService {
final FirebaseFirestore? _firestore; final FirebaseFirestore? _firestore;
final FirebaseAuth? _auth; final FirebaseAuth? _auth;
final Duration minReportInterval;
final double duplicateDistanceMeters;
final Duration duplicateWindow;
static int _idCounter = 0; static int _idCounter = 0;
DateTime? _lastReportTime;
final Set<String> _userVotes = {}; // Key format: '$alertId:$uid'
// Local cache for offline resilience and immediate zero-latency feedback // Local cache for offline resilience and immediate zero-latency feedback
final Map<String, SwarmReport> _localSwarmCache = {}; final Map<String, SwarmReport> _localSwarmCache = {};
SwarmRoadAlertService({ SwarmRoadAlertService({
FirebaseFirestore? firestore, FirebaseFirestore? firestore,
FirebaseAuth? auth, FirebaseAuth? auth,
this.minReportInterval = Duration.zero,
this.duplicateDistanceMeters = 200.0,
this.duplicateWindow = const Duration(minutes: 15),
}) : _firestore = firestore ?? _resolveFirestore(), }) : _firestore = firestore ?? _resolveFirestore(),
_auth = auth ?? _resolveAuth(); _auth = auth ?? _resolveAuth();
@ -43,43 +60,184 @@ class SwarmRoadAlertService {
String? get _currentUid => _auth?.currentUser?.uid; 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. /// 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<SwarmReport> reportAlert({ Future<SwarmReport> reportAlert({
required RoadAlertCategory category, required RoadAlertCategory category,
required double latitude, required double latitude,
required double longitude, required double longitude,
String? subtype,
String? source,
double? heading, double? heading,
String? roadName, String? roadName,
String? description, String? description,
String? groupRideId, String? groupRideId,
String? reporterUid,
Duration? ttl, Duration? ttl,
bool ignoreRateLimit = false,
void Function(SwarmCloudStatus)? onCloudStatus, void Function(SwarmCloudStatus)? onCloudStatus,
}) async { }) async {
final now = DateTime.now(); 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 uidSuffix = uid.length >= 5 ? uid.substring(0, 5) : uid;
final count = ++_idCounter; final count = ++_idCounter;
final alertId = 'swarm_${now.millisecondsSinceEpoch}_${count}_$uidSuffix'; final alertId = 'swarm_${now.millisecondsSinceEpoch}_${count}_$uidSuffix';
final effectiveTtl = ttl ?? final effectiveTtl = ttl ?? defaultTtlForCategory(category);
(category == RoadAlertCategory.police
? const Duration(minutes: 60)
: const Duration(hours: 2));
final expiresAt = now.add(effectiveTtl); final expiresAt = now.add(effectiveTtl);
final desc = description ?? final desc = description ?? defaultDescriptionForCategory(category);
(category == RoadAlertCategory.police final effectiveSource = source ??
? 'Police speed trap reported by rider' (groupRideId != null ? 'Pack Rider Report' : 'MotorSignal Swarm');
: (category == RoadAlertCategory.hazard
? 'Road hazard / debris reported'
: (category == RoadAlertCategory.accident
? 'Traffic accident reported'
: 'Rider safety report')));
final report = SwarmReport( final report = SwarmReport(
id: alertId, id: alertId,
category: category, category: category,
subtype: subtype,
latitude: latitude, latitude: latitude,
longitude: longitude, longitude: longitude,
heading: heading, heading: heading,
@ -91,6 +249,8 @@ class SwarmRoadAlertService {
confirmations: 1, confirmations: 1,
dismissals: 0, dismissals: 0,
groupRideId: groupRideId, groupRideId: groupRideId,
source: effectiveSource,
status: RoadAlertStatus.active,
); );
// Cache locally immediately // Cache locally immediately
@ -123,12 +283,37 @@ class SwarmRoadAlertService {
return report; return report;
} }
/// Trailing rider confirms an existing alert (increases confidence) /// Trailing rider confirms an existing alert (increases confidence).
Future<void> confirmAlert(String alertId) async { /// Enforces per-user vote restrictions (no repeat votes, no self-voting).
Future<bool> confirmAlert(String alertId, {String? voterUid}) async {
final uid = voterUid ?? _currentUid ?? 'local_rider';
final cached = _localSwarmCache[alertId]; 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) { if (cached != null) {
_localSwarmCache[alertId] = final updatedConfirmations = cached.confirmations + 1;
cached.copyWith(confirmations: cached.confirmations + 1); final newStatus = updatedConfirmations >= 2
? RoadAlertStatus.confirmed
: cached.status;
_localSwarmCache[alertId] = cached.copyWith(
confirmations: updatedConfirmations,
status: newStatus,
);
} }
final firestore = _firestore; final firestore = _firestore;
@ -143,16 +328,33 @@ class SwarmRoadAlertService {
} }
} }
} }
return true;
} }
/// Trailing rider reports alert is clear / no longer present /// Trailing rider reports alert is clear / no longer present.
Future<void> clearAlert(String alertId) async { /// Automatically marks alert cleared/expired when dismissals reach threshold.
Future<bool> 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 cached = _localSwarmCache[alertId];
final now = DateTime.now();
if (cached != null) { if (cached != null) {
final updatedDismissals = cached.dismissals + 1; final updatedDismissals = cached.dismissals + 1;
if (updatedDismissals >= 2 && updatedDismissals >= cached.confirmations) { if (updatedDismissals >= 2 && updatedDismissals >= cached.confirmations) {
// Expire immediately _localSwarmCache[alertId] = cached.copyWith(
_localSwarmCache.remove(alertId); dismissals: updatedDismissals,
expiresAt: now,
status: RoadAlertStatus.cleared,
);
} else { } else {
_localSwarmCache[alertId] = _localSwarmCache[alertId] =
cached.copyWith(dismissals: updatedDismissals); cached.copyWith(dismissals: updatedDismissals);
@ -170,10 +372,10 @@ class SwarmRoadAlertService {
final confirmations = (data['confirmations'] as num?)?.toInt() ?? 1; final confirmations = (data['confirmations'] as num?)?.toInt() ?? 1;
if (dismissals + 1 >= 2 && (dismissals + 1) >= confirmations) { if (dismissals + 1 >= 2 && (dismissals + 1) >= confirmations) {
// Mark expired
await docRef.update({ await docRef.update({
'dismissals': FieldValue.increment(1), 'dismissals': FieldValue.increment(1),
'expiresAt': DateTime.now().millisecondsSinceEpoch, 'expiresAt': now.millisecondsSinceEpoch,
'status': 'cleared',
}); });
} else { } else {
await docRef.update({ await docRef.update({
@ -187,6 +389,28 @@ class SwarmRoadAlertService {
} }
} }
} }
return true;
}
/// Deletes alert if caller is the reporter.
Future<bool> 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]. /// Queries all live alerts within [radiusMeters] of [latitude, longitude].
@ -200,7 +424,9 @@ class SwarmRoadAlertService {
// 1. Ingest local in-memory cache // 1. Ingest local in-memory cache
for (var entry in _localSwarmCache.entries) { 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; candidates[entry.key] = entry.value;
} }
} }
@ -226,6 +452,11 @@ class SwarmRoadAlertService {
for (var doc in querySnap.docs) { for (var doc in querySnap.docs) {
try { try {
final report = SwarmReport.fromJson(doc.data()); 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 // Client-side bounding box filter
if (report.latitude >= minLat && if (report.latitude >= minLat &&
report.latitude <= maxLat && report.latitude <= maxLat &&
@ -247,6 +478,10 @@ class SwarmRoadAlertService {
final List<RoadAlert> results = []; final List<RoadAlert> results = [];
for (var report in candidates.values) { for (var report in candidates.values) {
if (report.isExpired) continue; if (report.isExpired) continue;
if (report.status == RoadAlertStatus.cleared ||
report.status == RoadAlertStatus.expired) {
continue;
}
final dist = Geolocator.distanceBetween( final dist = Geolocator.distanceBetween(
latitude, latitude,
@ -265,8 +500,10 @@ class SwarmRoadAlertService {
return results; 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() { void clearLocalCache() {
_localSwarmCache.clear(); _localSwarmCache.clear();
_userVotes.clear();
_lastReportTime = null;
} }
} }

View File

@ -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<RoadAlert> _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<RoadAlertCategory> 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<RoadAlertProviderStatus> testConnection(String apiKey) async {
// OSINT adapter is community-sourced and does not require an API key
return RoadAlertProviderStatus.connected;
}
@override
Future<List<RoadAlert>> 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<RoadAlert> parseWazeResponse(dynamic data) {
if (data == null) return [];
List<dynamic> items = [];
if (data is Map<String, dynamic>) {
if (data['alerts'] is List) {
items = data['alerts'] as List;
}
} else if (data is List) {
items = data;
}
final Map<String, RoadAlert> uniqueAlerts = {};
for (final item in items) {
if (item is Map<String, dynamic>) {
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<String, dynamic> 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<String, dynamic>) {
final loc = item['location'] as Map<String, dynamic>;
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<RoadAlert> _filterByRadius(
List<RoadAlert> alerts,
double userLat,
double userLng,
double radiusMeters,
) {
final List<RoadAlert> 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;
}
}

View File

@ -10,6 +10,107 @@ import '../road_alert_providers.dart';
import '../road_alert_service.dart'; import '../road_alert_service.dart';
import '../swarm_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 { class QuickSwarmReportModal extends ConsumerStatefulWidget {
const QuickSwarmReportModal({super.key}); const QuickSwarmReportModal({super.key});
@ -30,10 +131,31 @@ class QuickSwarmReportModal extends ConsumerStatefulWidget {
class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> { class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
bool _submitting = false; bool _submitting = false;
bool _isMovingFast = false;
@override
void initState() {
super.initState();
_checkMotionSafety();
}
Future<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maxHeight = MediaQuery.of(context).size.height * 0.85;
return Container( return Container(
constraints: BoxConstraints(maxHeight: maxHeight),
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: AppColors.darkSurface, color: AppColors.darkSurface,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)), borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
@ -41,150 +163,165 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
top: BorderSide(color: AppColors.darkOutline, width: 1.5), top: BorderSide(color: AppColors.darkOutline, width: 1.5),
), ),
), ),
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), child: SafeArea(
child: Column( child: Padding(
mainAxisSize: MainAxisSize.min, padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
crossAxisAlignment: CrossAxisAlignment.stretch, child: Column(
children: [ mainAxisSize: MainAxisSize.min,
Center( crossAxisAlignment: CrossAxisAlignment.stretch,
child: Container(
width: 44,
height: 4,
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
Row(
children: [ children: [
const Icon(Icons.campaign, color: AppColors.cyberCyan, size: 24), Center(
const SizedBox(width: 10), child: Container(
Expanded( width: 44,
child: Text( height: 4,
'REPORT ROAD INCIDENT', decoration: BoxDecoration(
style: TextStyle( color: Colors.white24,
fontFamily: AppTypography.fontHeadline, borderRadius: BorderRadius.circular(2),
fontSize: 16,
fontWeight: FontWeight.bold,
letterSpacing: 0.8,
color: Colors.white,
), ),
), ),
), ),
IconButton( const SizedBox(height: 12),
icon: const Icon(Icons.close, color: Colors.white60), Row(
onPressed: () => Navigator.of(context).pop(), 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({ Widget _buildGridCard(_ReportItem item) {
required BuildContext context,
required RoadAlertCategory category,
required String title,
required String subtitle,
required IconData icon,
required Color color,
}) {
return ElevatedButton( return ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: color.withValues(alpha: 0.15), backgroundColor: item.color.withValues(alpha: 0.12),
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), 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, elevation: 0,
), ),
onPressed: _submitting ? null : () => _handleReport(category), onPressed: _submitting ? null : () => _handleReport(item.category, subtype: item.subtype),
child: Row( child: Row(
children: [ children: [
Container( Container(
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(7),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withValues(alpha: 0.25), color: item.color.withValues(alpha: 0.22),
borderRadius: BorderRadius.circular(8), 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( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text( Text(
title, item.title,
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 14, fontSize: 13,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.white, color: Colors.white,
letterSpacing: 0.5,
), ),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
subtitle, item.subtitle,
style: const TextStyle(fontSize: 11, color: Colors.white70), 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<void> _handleReport(RoadAlertCategory category) async { Future<void> _handleReport(RoadAlertCategory category, {String? subtype}) async {
if (_submitting) return; if (_submitting) return;
setState(() => _submitting = true); setState(() => _submitting = true);
HapticFeedback.heavyImpact(); HapticFeedback.heavyImpact();
@ -193,6 +330,7 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
final activeRide = ref.read(activeUserGroupRideProvider); final activeRide = ref.read(activeUserGroupRideProvider);
final swarmService = ref.read(swarmRoadAlertServiceProvider); final swarmService = ref.read(swarmRoadAlertServiceProvider);
final alertService = ref.read(roadAlertServiceProvider); final alertService = ref.read(roadAlertServiceProvider);
// 1. Acquire current position // 1. Acquire current position
Position? pos; Position? pos;
try { try {
@ -209,7 +347,6 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
); );
} }
// Closing the sheet before location resolves cancels the report.
if (!mounted || ModalRoute.of(context)?.isCurrent != true) return; if (!mounted || ModalRoute.of(context)?.isCurrent != true) return;
final lat = pos?.latitude ?? 0.0; final lat = pos?.latitude ?? 0.0;
@ -226,11 +363,11 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
return; return;
} }
// 2. Check active group ride // 2. Dispatch to Swarm Service
// 3. Dispatch to Swarm Service
var cloudStatus = SwarmCloudStatus.unavailable; var cloudStatus = SwarmCloudStatus.unavailable;
final report = await swarmService.reportAlert( final report = await swarmService.reportAlert(
category: category, category: category,
subtype: subtype,
latitude: lat, latitude: lat,
longitude: lng, longitude: lng,
heading: heading, heading: heading,
@ -238,7 +375,6 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
onCloudStatus: (status) => cloudStatus = status, onCloudStatus: (status) => cloudStatus = status,
); );
// The sheet or its ProviderScope may have gone away during submission.
if (!mounted || ModalRoute.of(context)?.isCurrent != true) return; if (!mounted || ModalRoute.of(context)?.isCurrent != true) return;
alertService.simulateAlert(report.toRoadAlert()); alertService.simulateAlert(report.toRoadAlert());
Navigator.of(context).pop(); Navigator.of(context).pop();
@ -252,13 +388,13 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
Expanded( Expanded(
child: Text(switch (cloudStatus) { child: Text(switch (cloudStatus) {
SwarmCloudStatus.confirmed => SwarmCloudStatus.confirmed =>
'${category.displayName} added to local alerts and saved to cloud.', '${category.displayName} reported to community and saved to cloud.',
SwarmCloudStatus.pending => SwarmCloudStatus.pending =>
'${category.displayName} added locally; cloud delivery pending.', '${category.displayName} reported locally; cloud delivery pending.',
SwarmCloudStatus.failed => SwarmCloudStatus.failed =>
'${category.displayName} added locally; cloud delivery failed.', '${category.displayName} reported locally; cloud delivery failed.',
SwarmCloudStatus.unavailable => SwarmCloudStatus.unavailable =>
'${category.displayName} added locally; cloud unavailable.', '${category.displayName} reported locally; operating offline.',
}, style: const TextStyle(fontWeight: FontWeight.bold)), }, style: const TextStyle(fontWeight: FontWeight.bold)),
), ),
], ],
@ -267,6 +403,15 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
duration: const Duration(seconds: 3), 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) { } catch (e) {
if (mounted && ModalRoute.of(context)?.isCurrent == true) { if (mounted && ModalRoute.of(context)?.isCurrent == true) {
messenger.showSnackBar( messenger.showSnackBar(
@ -296,7 +441,7 @@ class QuickSwarmReportButton extends StatelessWidget {
icon: const Icon(Icons.campaign, color: Colors.orangeAccent, size: 20), icon: const Icon(Icons.campaign, color: Colors.orangeAccent, size: 20),
tooltip: 'Report Road Hazard / Police', tooltip: 'Report Road Hazard / Police',
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32), constraints: const BoxConstraints(minWidth: 48, minHeight: 48),
onPressed: () => QuickSwarmReportModal.show(context), onPressed: () => QuickSwarmReportModal.show(context),
); );
} }
@ -304,19 +449,18 @@ class QuickSwarmReportButton extends StatelessWidget {
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.orangeAccent.withValues(alpha: 0.15), backgroundColor: Colors.orangeAccent.withValues(alpha: 0.15),
foregroundColor: Colors.orangeAccent, 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)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
minimumSize: Size.zero, minimumSize: const Size(48, 48),
tapTargetSize: MaterialTapTargetSize.shrinkWrap, tapTargetSize: MaterialTapTargetSize.shrinkWrap,
), ),
icon: const Icon(Icons.campaign, size: 16), icon: const Icon(Icons.campaign, size: 18),
label: const Text( label: const Text(
'REPORT', 'REPORT',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold), style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
), ),
onPressed: () => QuickSwarmReportModal.show(context), onPressed: () => QuickSwarmReportModal.show(context),
); );
} }
} }

View File

@ -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);
});
});
}

View File

@ -231,12 +231,12 @@ void main() {
); );
// 1st clear: increments dismissals to 1 // 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(); var doc = await fakeFirestore.collection('road_alerts').doc(report.id).get();
expect(doc.data()!['dismissals'], 1); expect(doc.data()!['dismissals'], 1);
// 2nd clear: dismissals becomes 2, which is >= confirmations (1) -> expires immediately // 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(); doc = await fakeFirestore.collection('road_alerts').doc(report.id).get();
expect(doc.data()!['dismissals'], 2); expect(doc.data()!['dismissals'], 2);
final expiresAt = doc.data()!['expiresAt'] as int; final expiresAt = doc.data()!['expiresAt'] as int;
@ -340,4 +340,238 @@ void main() {
expect(activeProviders.any((p) => p.id == 'swarm_alerts'), isTrue); 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<RoadAlertRateLimitException>().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);
});
});
} }

View File

@ -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);
});
});
}