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
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} {
function validAlertKeys() {
return request.resource.data.keys().hasOnly([
'id', 'category', 'latitude', 'longitude', 'heading',
'id', 'category', 'subtype', 'latitude', 'longitude', 'heading',
'roadName', 'description', 'reporterUid', 'reportedAt',
'expiresAt', 'confirmations', 'dismissals', 'groupRideId'
'expiresAt', 'confirmations', 'dismissals', 'groupRideId', 'source', 'status'
]);
}
@ -1518,7 +1518,8 @@ service cloud.firestore {
&& validAlertKeys()
&& request.resource.data.category in [
'police', 'hazard', 'accident', 'speedCamera',
'roadClosure', 'construction', 'traffic', 'other'
'roadClosure', 'construction', 'traffic', 'pothole',
'debris', 'disabledVehicle', 'weatherHazard', 'other'
]
&& (request.resource.data.latitude is int || request.resource.data.latitude is float)
&& (request.resource.data.longitude is int || request.resource.data.longitude is float)
@ -1530,7 +1531,10 @@ service cloud.firestore {
&& request.resource.data.expiresAt is int
&& request.resource.data.expiresAt > request.resource.data.reportedAt
&& request.resource.data.description is string
&& request.resource.data.description.size() <= 256;
&& request.resource.data.description.size() <= 256
&& (!('subtype' in request.resource.data) || (request.resource.data.subtype is string && request.resource.data.subtype.size() <= 64))
&& (!('source' in request.resource.data) || (request.resource.data.source is string && request.resource.data.source.size() <= 64))
&& (!('status' in request.resource.data) || request.resource.data.status in ['active', 'confirmed', 'cleared', 'expired']);
}
function validAlertUpdate() {
@ -1542,7 +1546,8 @@ service cloud.firestore {
&& request.resource.data.latitude == resource.data.latitude
&& request.resource.data.longitude == resource.data.longitude
&& request.resource.data.reportedAt == resource.data.reportedAt
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['confirmations', 'dismissals', 'expiresAt'])
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['confirmations', 'dismissals', 'expiresAt', 'status'])
&& (!('status' in request.resource.data) || request.resource.data.status in ['active', 'confirmed', 'cleared', 'expired'])
&& (
(
request.resource.data.confirmations == resource.data.confirmations + 1

View File

@ -12,6 +12,7 @@ import '../../services/map/map_provider_interface.dart';
import '../../services/map/map_providers.dart';
import '../../services/map/unified_map_view.dart';
import '../../services/alerts/road_alert_models.dart';
import '../../services/alerts/road_alert_providers.dart';
import '../../services/alerts/road_alert_service.dart';
import '../../services/alerts/widgets/quick_swarm_report_modal.dart';
import '../../services/map/map_layer_settings.dart';
@ -636,30 +637,240 @@ class _MapScreenState extends ConsumerState<MapScreen> {
}
void _showRoadAlertDetails(RoadAlert alert) {
final swarmService = ref.read(swarmRoadAlertServiceProvider);
final canVote = alert.sourceType == RoadAlertSourceType.userReported &&
swarmService.canUserVote(alert.id);
final hasVoted = swarmService.hasUserVoted(alert.id);
final isConfirmed = alert.isCommunityConfirmed;
showModalBottomSheet(
context: context, backgroundColor: Colors.grey[900],
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
context: context,
backgroundColor: Colors.grey[900],
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => Padding(
padding: const EdgeInsets.all(20),
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Container(padding: const EdgeInsets.all(8), decoration: BoxDecoration(color: alert.category.color.withValues(alpha: 0.2), shape: BoxShape.circle), child: Icon(alert.category.icon, color: alert.category.color, size: 28)),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: alert.category.color.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: Icon(alert.category.icon,
color: alert.category.color, size: 28),
),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(alert.category.displayName.toUpperCase(), style: TextStyle(color: alert.category.color, fontWeight: FontWeight.bold, fontSize: 12, letterSpacing: 1.1)),
if (alert.roadName != null && alert.roadName!.isNotEmpty) Text(alert.roadName!, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
])),
Container(padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration(color: Colors.white10, borderRadius: BorderRadius.circular(12)), child: Text(alert.sourceType == RoadAlertSourceType.userReported ? 'User Reported' : 'Verified Provider', style: const TextStyle(fontSize: 10, color: Colors.white70))),
]),
const SizedBox(height: 16),
Text(alert.description, style: const TextStyle(fontSize: 15, height: 1.3)),
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)),
]),
]),
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) {
final colors = context.motorColors;
final wazeStatus = ref.watch(wazeStatusProvider);
final wazeEnabled = ref.watch(wazeRoadAlertsEnabledProvider);
final wazeOsintStatus = ref.watch(wazeOsintStatusProvider);
final wazeOsintEnabled = ref.watch(wazeOsintEnabledProvider);
return Card(
clipBehavior: Clip.antiAlias,
@ -1041,50 +1041,57 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildProviderHeader(
icon: Icons.local_police,
icon: Icons.alt_route,
iconColor: colors.velocityBlue,
title: 'Waze Police Alerts',
title: 'Waze Community (Experimental OSINT)',
subtitle:
'Real-time Waze crowd-sourced police speed traps & enforcement via OpenWebNinja.',
status: wazeStatus,
'Optional community hazard & traffic feed inspired by ArgeliusLabs/waze_osint. Disabled by default; zero paid subscriptions.',
status: wazeOsintStatus,
),
const SizedBox(height: 12),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text(
'Enable Waze Police Alerts',
'Enable Experimental Waze Feed',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
),
subtitle: Text(
'Live speed traps and police alerts during ride navigation (Type: Police only).',
'Streams public community road alerts, hazards, and traffic (Disabled by default).',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
value: wazeEnabled,
value: wazeOsintEnabled,
onChanged: (val) {
ref
.read(wazeRoadAlertsEnabledProvider.notifier)
.read(wazeOsintEnabledProvider.notifier)
.setEnabled(val);
},
),
const SizedBox(height: 12),
TextField(
controller: _wazeKeyController,
decoration: const InputDecoration(
labelText: 'OpenWebNinja / Waze API Key',
hintText: 'Enter your OpenWebNinja x-api-key',
helperText:
'Supplies real-time Waze police traps and enforcement.',
helperMaxLines: 2,
border: OutlineInputBorder(),
isDense: true,
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colors.outlineVariant),
),
child: Row(
children: [
Icon(Icons.info_outline, size: 16, color: colors.cyberCyan),
const SizedBox(width: 8),
Expanded(
child: Text(
'Community-sourced experimental data. No API key required.',
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
],
),
style: const TextStyle(fontSize: 14),
onChanged: (val) {
ref.read(wazeApiKeyProvider.notifier).setApiKey(val);
},
),
const SizedBox(height: 12),
Align(
@ -1101,35 +1108,17 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
: () async {
setState(() => _isTestingWaze = true);
final messenger = ScaffoldMessenger.of(context);
final provider = ref.read(wazeProviderInstance);
final status = await provider.testConnection(
_wazeKeyController.text,
);
ref.read(wazeStatusProvider.notifier).state = status;
final provider = ref.read(wazeOsintProviderInstance);
final status = await provider.testConnection('');
ref.read(wazeOsintStatusProvider.notifier).state = status;
setState(() => _isTestingWaze = false);
if (mounted) {
String msg = '';
if (status == RoadAlertProviderStatus.connected) {
msg = 'Waze API Connected successfully!';
} else if (status ==
RoadAlertProviderStatus.authError) {
msg =
'Authentication Error: Invalid or inactive OpenWebNinja API key.';
} else if (status ==
RoadAlertProviderStatus.notConfigured) {
msg = 'API Key is empty.';
} else if (status ==
RoadAlertProviderStatus.networkError) {
msg =
'Network Error: Failed to connect to OpenWebNinja API.';
} else if (status ==
RoadAlertProviderStatus.rateLimited) {
msg = 'Rate Limited: Plan quota exceeded.';
} else {
msg = 'Status: ${status.name}';
}
messenger.showSnackBar(SnackBar(content: Text(msg)));
messenger.showSnackBar(
const SnackBar(
content: Text('Waze OSINT adapter configured successfully!'),
),
);
}
},
icon: _isTestingWaze
@ -1297,7 +1286,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
controller: _communityKeyController,
decoration: const InputDecoration(
labelText: 'Community / RapidAPI Key',
hintText: 'Enter OpenWebNinja, RapidAPI, or Community API Key',
hintText: 'Enter RapidAPI or Custom Community Key',
helperText:
'Supplies Police, Speed Cameras, and Crowd Hazards.',
helperMaxLines: 2,
@ -1317,7 +1306,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
hintText:
'e.g. https://google-map-places.p.rapidapi.com or custom URL',
helperText:
'Leave blank for automatic OpenWebNinja / RapidAPI detection.',
'Leave blank for default endpoint detection.',
helperMaxLines: 2,
border: OutlineInputBorder(),
isDense: true,
@ -1356,7 +1345,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
String msg = '';
if (status == RoadAlertProviderStatus.connected) {
msg =
'Community / OpenWebNinja API Connected successfully!';
'Community Traffic API Connected successfully!';
} else if (status ==
RoadAlertProviderStatus.authError) {
msg =

View File

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

View File

@ -10,13 +10,20 @@ import 'swarm_road_alert_service.dart';
import 'swarm_road_alert_provider.dart';
import 'here_road_alert_provider.dart';
import 'waze_road_alert_provider.dart';
import 'waze_osint_road_alert_provider.dart';
final wazeProviderInstance = Provider<WazeRoadAlertProvider>((ref) {
return WazeRoadAlertProvider();
});
final wazeOsintProviderInstance = Provider<WazeOsintRoadAlertProvider>((ref) {
return WazeOsintRoadAlertProvider();
});
final swarmRoadAlertServiceProvider = Provider<SwarmRoadAlertService>((ref) {
return SwarmRoadAlertService();
return SwarmRoadAlertService(
minReportInterval: const Duration(seconds: 15),
);
});
final swarmRoadAlertProviderInstance = Provider<SwarmRoadAlertProvider>((ref) {
@ -118,9 +125,13 @@ class EnabledCategoriesNotifier extends StateNotifier<Set<RoadAlertCategory>> {
RoadAlertCategory.speedCamera,
RoadAlertCategory.accident,
RoadAlertCategory.hazard,
RoadAlertCategory.pothole,
RoadAlertCategory.debris,
RoadAlertCategory.disabledVehicle,
RoadAlertCategory.roadClosure,
RoadAlertCategory.construction,
RoadAlertCategory.traffic,
RoadAlertCategory.weatherHazard,
RoadAlertCategory.other,
});
@ -404,9 +415,43 @@ final wazeStatusProvider = StateProvider<RoadAlertProviderStatus>((ref) {
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
final activeRoadAlertProviders = Provider<List<RoadAlertProvider>>((ref) {
final swarmEnabled = ref.watch(swarmAlertsEnabledProvider);
final wazeOsintEnabled = ref.watch(wazeOsintEnabledProvider);
final usCamerasEnabled = ref.watch(usSpeedCamerasEnabledProvider);
final tomtomKey = ref.watch(tomtomApiKeyProvider);
final communityKey = ref.watch(communityApiKeyProvider);
@ -419,6 +464,9 @@ final activeRoadAlertProviders = Provider<List<RoadAlertProvider>>((ref) {
if (swarmEnabled) {
list.add(ref.watch(swarmRoadAlertProviderInstance));
}
if (wazeOsintEnabled) {
list.add(ref.watch(wazeOsintProviderInstance));
}
if (wazeEnabled && wazeKey.isNotEmpty) {
list.add(ref.watch(wazeProviderInstance));
}

View File

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -7,6 +8,8 @@ import 'package:geolocator/geolocator.dart';
import 'package:rxdart/rxdart.dart';
import '../location_service.dart';
import '../sensor_fusion_service.dart';
import '../map/map_provider_interface.dart';
import '../navigation/navigation_session_service.dart';
import 'road_alert_models.dart';
import 'road_alert_providers.dart';
import 'road_alert_compliance.dart';
@ -238,7 +241,35 @@ class RoadAlertService {
userLat: position.latitude,
userLng: position.longitude,
complianceEnabled: complianceEnabled,
)..sort((a, b) => (a.distanceFromUser ?? 0).compareTo(b.distanceFromUser ?? 0));
);
final navSession = _ref.read(navigationSessionProvider);
final hasActiveRoute = navSession.isNavigating &&
navSession.calculationResult != null &&
navSession.calculationResult!.geometry.length >= 2;
compliantList.sort((a, b) {
if (hasActiveRoute) {
final geom = navSession.calculationResult!.geometry;
final aOnRoute = RouteCorridorAlertFilter.isAlertAheadOnRoute(
userLat: position.latitude,
userLng: position.longitude,
alertLat: a.latitude,
alertLng: a.longitude,
geometry: geom,
);
final bOnRoute = RouteCorridorAlertFilter.isAlertAheadOnRoute(
userLat: position.latitude,
userLng: position.longitude,
alertLat: b.latitude,
alertLng: b.longitude,
geometry: geom,
);
if (aOnRoute && !bOnRoute) return -1;
if (!aOnRoute && bOnRoute) return 1;
}
return (a.distanceFromUser ?? 0).compareTo(b.distanceFromUser ?? 0);
});
_cachedAlerts = compliantList;
_alertsController.add(compliantList);
@ -395,6 +426,18 @@ class RoadAlertService {
case RoadAlertCategory.traffic:
phrase = "Traffic delay ahead.";
break;
case RoadAlertCategory.pothole:
phrase = "Severe pothole ahead.";
break;
case RoadAlertCategory.debris:
phrase = "Road debris ahead.";
break;
case RoadAlertCategory.disabledVehicle:
phrase = "Disabled vehicle reported ahead.";
break;
case RoadAlertCategory.weatherHazard:
phrase = "Weather hazard reported ahead.";
break;
case RoadAlertCategory.other:
phrase = "Traffic alert ahead.";
break;
@ -588,3 +631,93 @@ class RoadAlertService {
} catch (_) {}
}
}
/// Helper to evaluate route corridor proximity and ahead-of-rider geometry.
class RouteCorridorAlertFilter {
/// Computes minimum cross-track perpendicular distance in meters from point to route polyline.
static double minDistanceToPolyline(
double alertLat,
double alertLng,
List<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 {
final String id;
final RoadAlertCategory category;
final String? subtype;
final double latitude;
final double longitude;
final double? heading;
@ -16,10 +17,13 @@ class SwarmReport {
final int confirmations;
final int dismissals;
final String? groupRideId;
final String? source;
final RoadAlertStatus status;
const SwarmReport({
required this.id,
required this.category,
this.subtype,
required this.latitude,
required this.longitude,
this.heading,
@ -31,6 +35,8 @@ class SwarmReport {
this.confirmations = 1,
this.dismissals = 0,
this.groupRideId,
this.source,
this.status = RoadAlertStatus.active,
});
double get confidence {
@ -41,9 +47,12 @@ class SwarmReport {
bool get isExpired => DateTime.now().isAfter(expiresAt);
bool get isCommunityConfirmed => confirmations >= 2;
Map<String, dynamic> toJson() => {
'id': id,
'category': category.name,
if (subtype != null) 'subtype': subtype,
'latitude': latitude,
'longitude': longitude,
if (heading != null) 'heading': heading,
@ -55,6 +64,8 @@ class SwarmReport {
'confirmations': confirmations,
'dismissals': dismissals,
if (groupRideId != null) 'groupRideId': groupRideId,
if (source != null) 'source': source,
'status': status.name,
};
factory SwarmReport.fromJson(Map<String, dynamic> json) {
@ -75,9 +86,21 @@ class SwarmReport {
: (DateTime.tryParse(rawExpiresAt?.toString() ?? '') ??
reportedAt.add(const Duration(hours: 1)));
final confirmations = (json['confirmations'] as num?)?.toInt() ?? 1;
final dismissals = (json['dismissals'] as num?)?.toInt() ?? 0;
final rawStatus = json['status']?.toString();
final status = RoadAlertStatus.values.firstWhere(
(s) => s.name == rawStatus,
orElse: () => confirmations >= 2
? RoadAlertStatus.confirmed
: RoadAlertStatus.active,
);
return SwarmReport(
id: json['id']?.toString() ?? '',
category: category,
subtype: json['subtype']?.toString(),
latitude: (json['latitude'] as num).toDouble(),
longitude: (json['longitude'] as num).toDouble(),
heading: json['heading'] != null ? (json['heading'] as num).toDouble() : null,
@ -86,21 +109,26 @@ class SwarmReport {
reporterUid: json['reporterUid']?.toString() ?? 'anonymous',
reportedAt: reportedAt,
expiresAt: expiresAt,
confirmations: (json['confirmations'] as num?)?.toInt() ?? 1,
dismissals: (json['dismissals'] as num?)?.toInt() ?? 0,
confirmations: confirmations,
dismissals: dismissals,
groupRideId: json['groupRideId']?.toString(),
source: json['source']?.toString(),
status: status,
);
}
RoadAlert toRoadAlert({double? distanceFromUser}) {
final isPoliceOrAccident =
category == RoadAlertCategory.police || category == RoadAlertCategory.accident;
final sourceName = groupRideId != null ? 'Pack Rider Report' : 'MotorSignal Swarm';
final isHighSeverity = category == RoadAlertCategory.police ||
category == RoadAlertCategory.accident ||
category == RoadAlertCategory.weatherHazard;
final sourceName = source ??
(groupRideId != null ? 'Pack Rider Report' : 'MotorSignal Swarm');
return RoadAlert(
id: id,
providerId: 'swarm_alerts',
category: category,
subtype: subtype,
latitude: latitude,
longitude: longitude,
heading: heading,
@ -110,9 +138,13 @@ class SwarmReport {
expiresAt: expiresAt,
lastUpdated: DateTime.now(),
confidence: confidence,
severity: isPoliceOrAccident ? RoadAlertSeverity.high : RoadAlertSeverity.medium,
severity: isHighSeverity ? RoadAlertSeverity.high : RoadAlertSeverity.medium,
source: sourceName,
sourceType: RoadAlertSourceType.userReported,
status: status,
confirmations: confirmations,
dismissals: dismissals,
reporterUid: reporterUid,
distanceFromUser: distanceFromUser,
);
}
@ -120,6 +152,7 @@ class SwarmReport {
SwarmReport copyWith({
String? id,
RoadAlertCategory? category,
String? subtype,
double? latitude,
double? longitude,
double? heading,
@ -131,10 +164,13 @@ class SwarmReport {
int? confirmations,
int? dismissals,
String? groupRideId,
String? source,
RoadAlertStatus? status,
}) {
return SwarmReport(
id: id ?? this.id,
category: category ?? this.category,
subtype: subtype ?? this.subtype,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
heading: heading ?? this.heading,
@ -146,6 +182,8 @@ class SwarmReport {
confirmations: confirmations ?? this.confirmations,
dismissals: dismissals ?? this.dismissals,
groupRideId: groupRideId ?? this.groupRideId,
source: source ?? this.source,
status: status ?? this.status,
);
}
}

View File

@ -10,18 +10,35 @@ import 'swarm_road_alert_models.dart';
enum SwarmCloudStatus { unavailable, confirmed, pending, failed }
class RoadAlertRateLimitException implements Exception {
final int retryAfterSeconds;
RoadAlertRateLimitException(this.retryAfterSeconds);
@override
String toString() =>
'Please wait $retryAfterSeconds seconds before submitting another road alert.';
}
class SwarmRoadAlertService {
final FirebaseFirestore? _firestore;
final FirebaseAuth? _auth;
final Duration minReportInterval;
final double duplicateDistanceMeters;
final Duration duplicateWindow;
static int _idCounter = 0;
DateTime? _lastReportTime;
final Set<String> _userVotes = {}; // Key format: '$alertId:$uid'
// Local cache for offline resilience and immediate zero-latency feedback
final Map<String, SwarmReport> _localSwarmCache = {};
SwarmRoadAlertService({
FirebaseFirestore? firestore,
FirebaseAuth? auth,
this.minReportInterval = Duration.zero,
this.duplicateDistanceMeters = 200.0,
this.duplicateWindow = const Duration(minutes: 15),
}) : _firestore = firestore ?? _resolveFirestore(),
_auth = auth ?? _resolveAuth();
@ -43,43 +60,184 @@ class SwarmRoadAlertService {
String? get _currentUid => _auth?.currentUser?.uid;
/// Reports a new road alert (e.g. Police Speed Trap, Road Hazard, Accident).
/// Returns a cached report by alert ID if available.
SwarmReport? getCachedReport(String alertId) => _localSwarmCache[alertId];
static Duration defaultTtlForCategory(RoadAlertCategory category) {
switch (category) {
case RoadAlertCategory.police:
return const Duration(minutes: 60);
case RoadAlertCategory.traffic:
return const Duration(minutes: 45);
case RoadAlertCategory.accident:
case RoadAlertCategory.roadClosure:
return const Duration(hours: 3);
case RoadAlertCategory.weatherHazard:
return const Duration(hours: 4);
case RoadAlertCategory.hazard:
case RoadAlertCategory.pothole:
case RoadAlertCategory.debris:
case RoadAlertCategory.disabledVehicle:
case RoadAlertCategory.construction:
case RoadAlertCategory.speedCamera:
case RoadAlertCategory.other:
return const Duration(hours: 2);
}
}
static String defaultDescriptionForCategory(RoadAlertCategory category) {
switch (category) {
case RoadAlertCategory.police:
return 'Police speed trap reported by rider';
case RoadAlertCategory.accident:
return 'Traffic accident reported';
case RoadAlertCategory.hazard:
return 'Road hazard reported';
case RoadAlertCategory.pothole:
return 'Severe pothole reported on road';
case RoadAlertCategory.debris:
return 'Road debris reported';
case RoadAlertCategory.disabledVehicle:
return 'Disabled vehicle reported on roadway';
case RoadAlertCategory.roadClosure:
return 'Road closure reported';
case RoadAlertCategory.construction:
return 'Road construction reported';
case RoadAlertCategory.traffic:
return 'Heavy traffic reported';
case RoadAlertCategory.weatherHazard:
return 'Adverse weather road hazard reported';
case RoadAlertCategory.speedCamera:
return 'Speed enforcement camera reported';
case RoadAlertCategory.other:
return 'Rider safety report';
}
}
bool _isCategoryMatch(RoadAlertCategory a, RoadAlertCategory b) {
if (a == b) return true;
final hazardGroup = {
RoadAlertCategory.hazard,
RoadAlertCategory.pothole,
RoadAlertCategory.debris,
RoadAlertCategory.disabledVehicle,
};
return hazardGroup.contains(a) && hazardGroup.contains(b);
}
SwarmReport? _findDuplicateAlert({
required RoadAlertCategory category,
required double latitude,
required double longitude,
required DateTime reportedAt,
}) {
for (final report in _localSwarmCache.values) {
if (report.isExpired) continue;
if (report.status == RoadAlertStatus.cleared ||
report.status == RoadAlertStatus.expired) {
continue;
}
final age = reportedAt.difference(report.reportedAt).abs();
if (age > duplicateWindow) continue;
if (!_isCategoryMatch(report.category, category)) continue;
final distance = Geolocator.distanceBetween(
latitude,
longitude,
report.latitude,
report.longitude,
);
if (distance <= duplicateDistanceMeters) {
return report;
}
}
return null;
}
/// Checks if the given user (or current user) has already voted on this alert.
bool hasUserVoted(String alertId, [String? voterUid]) {
final uid = voterUid ?? _currentUid ?? 'local_rider';
return _userVotes.contains('$alertId:$uid');
}
/// Checks if the given user is eligible to vote on this alert.
bool canUserVote(String alertId, [String? voterUid]) {
final uid = voterUid ?? _currentUid ?? 'local_rider';
final cached = _localSwarmCache[alertId];
if (cached != null && cached.reporterUid == uid) {
return false; // Reporter cannot vote on their own alert
}
return !_userVotes.contains('$alertId:$uid');
}
/// Reports a new road alert.
/// Saves to Firestore `/road_alerts` and caches locally.
/// Automatically checks for duplicate alerts nearby (< 200m within 15min)
/// and confirms existing alerts instead of duplicating.
Future<SwarmReport> reportAlert({
required RoadAlertCategory category,
required double latitude,
required double longitude,
String? subtype,
String? source,
double? heading,
String? roadName,
String? description,
String? groupRideId,
String? reporterUid,
Duration? ttl,
bool ignoreRateLimit = false,
void Function(SwarmCloudStatus)? onCloudStatus,
}) async {
final now = DateTime.now();
final uid = _currentUid ?? 'local_rider';
final uid = reporterUid ?? _currentUid ?? 'local_rider';
// Rate Limiting
if (!ignoreRateLimit && minReportInterval > Duration.zero && _lastReportTime != null) {
final elapsed = now.difference(_lastReportTime!);
if (elapsed < minReportInterval) {
final remaining = (minReportInterval - elapsed).inSeconds + 1;
throw RoadAlertRateLimitException(remaining);
}
}
// Duplicate detection and auto-merging
final duplicate = _findDuplicateAlert(
category: category,
latitude: latitude,
longitude: longitude,
reportedAt: now,
);
if (duplicate != null) {
if (kDebugMode) {
debugPrint('[SwarmRoadAlertService] Duplicate alert detected: ${duplicate.id}. Auto-confirming.');
}
if (duplicate.reporterUid != uid) {
await confirmAlert(duplicate.id, voterUid: uid);
return _localSwarmCache[duplicate.id] ?? duplicate;
}
return duplicate;
}
_lastReportTime = now;
final uidSuffix = uid.length >= 5 ? uid.substring(0, 5) : uid;
final count = ++_idCounter;
final alertId = 'swarm_${now.millisecondsSinceEpoch}_${count}_$uidSuffix';
final effectiveTtl = ttl ??
(category == RoadAlertCategory.police
? const Duration(minutes: 60)
: const Duration(hours: 2));
final effectiveTtl = ttl ?? defaultTtlForCategory(category);
final expiresAt = now.add(effectiveTtl);
final desc = description ??
(category == RoadAlertCategory.police
? 'Police speed trap reported by rider'
: (category == RoadAlertCategory.hazard
? 'Road hazard / debris reported'
: (category == RoadAlertCategory.accident
? 'Traffic accident reported'
: 'Rider safety report')));
final desc = description ?? defaultDescriptionForCategory(category);
final effectiveSource = source ??
(groupRideId != null ? 'Pack Rider Report' : 'MotorSignal Swarm');
final report = SwarmReport(
id: alertId,
category: category,
subtype: subtype,
latitude: latitude,
longitude: longitude,
heading: heading,
@ -91,6 +249,8 @@ class SwarmRoadAlertService {
confirmations: 1,
dismissals: 0,
groupRideId: groupRideId,
source: effectiveSource,
status: RoadAlertStatus.active,
);
// Cache locally immediately
@ -123,12 +283,37 @@ class SwarmRoadAlertService {
return report;
}
/// Trailing rider confirms an existing alert (increases confidence)
Future<void> confirmAlert(String alertId) async {
/// Trailing rider confirms an existing alert (increases confidence).
/// 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];
if (cached != null && cached.reporterUid == uid && voterUid != null) {
if (kDebugMode) {
debugPrint('[SwarmRoadAlertService] Self-voting rejected for alert $alertId');
}
return false;
}
final voteKey = '$alertId:$uid';
if (_userVotes.contains(voteKey)) {
if (kDebugMode) {
debugPrint('[SwarmRoadAlertService] Repeat voting rejected for alert $alertId');
}
return false;
}
_userVotes.add(voteKey);
if (cached != null) {
_localSwarmCache[alertId] =
cached.copyWith(confirmations: cached.confirmations + 1);
final updatedConfirmations = cached.confirmations + 1;
final newStatus = updatedConfirmations >= 2
? RoadAlertStatus.confirmed
: cached.status;
_localSwarmCache[alertId] = cached.copyWith(
confirmations: updatedConfirmations,
status: newStatus,
);
}
final firestore = _firestore;
@ -143,16 +328,33 @@ class SwarmRoadAlertService {
}
}
}
return true;
}
/// Trailing rider reports alert is clear / no longer present
Future<void> clearAlert(String alertId) async {
/// Trailing rider reports alert is clear / no longer present.
/// 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 now = DateTime.now();
if (cached != null) {
final updatedDismissals = cached.dismissals + 1;
if (updatedDismissals >= 2 && updatedDismissals >= cached.confirmations) {
// Expire immediately
_localSwarmCache.remove(alertId);
_localSwarmCache[alertId] = cached.copyWith(
dismissals: updatedDismissals,
expiresAt: now,
status: RoadAlertStatus.cleared,
);
} else {
_localSwarmCache[alertId] =
cached.copyWith(dismissals: updatedDismissals);
@ -170,10 +372,10 @@ class SwarmRoadAlertService {
final confirmations = (data['confirmations'] as num?)?.toInt() ?? 1;
if (dismissals + 1 >= 2 && (dismissals + 1) >= confirmations) {
// Mark expired
await docRef.update({
'dismissals': FieldValue.increment(1),
'expiresAt': DateTime.now().millisecondsSinceEpoch,
'expiresAt': now.millisecondsSinceEpoch,
'status': 'cleared',
});
} else {
await docRef.update({
@ -187,6 +389,28 @@ class SwarmRoadAlertService {
}
}
}
return true;
}
/// Deletes alert if caller is the reporter.
Future<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].
@ -200,7 +424,9 @@ class SwarmRoadAlertService {
// 1. Ingest local in-memory cache
for (var entry in _localSwarmCache.entries) {
if (!entry.value.isExpired) {
if (!entry.value.isExpired &&
entry.value.status != RoadAlertStatus.cleared &&
entry.value.status != RoadAlertStatus.expired) {
candidates[entry.key] = entry.value;
}
}
@ -226,6 +452,11 @@ class SwarmRoadAlertService {
for (var doc in querySnap.docs) {
try {
final report = SwarmReport.fromJson(doc.data());
// Filter out cleared/expired documents
if (report.status == RoadAlertStatus.cleared ||
report.status == RoadAlertStatus.expired) {
continue;
}
// Client-side bounding box filter
if (report.latitude >= minLat &&
report.latitude <= maxLat &&
@ -247,6 +478,10 @@ class SwarmRoadAlertService {
final List<RoadAlert> results = [];
for (var report in candidates.values) {
if (report.isExpired) continue;
if (report.status == RoadAlertStatus.cleared ||
report.status == RoadAlertStatus.expired) {
continue;
}
final dist = Geolocator.distanceBetween(
latitude,
@ -265,8 +500,10 @@ class SwarmRoadAlertService {
return results;
}
/// Clears local in-memory cache (for testing or reset)
/// Clears local in-memory cache and voting records (for testing or reset)
void clearLocalCache() {
_localSwarmCache.clear();
_userVotes.clear();
_lastReportTime = null;
}
}

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 '../swarm_road_alert_service.dart';
class _ReportItem {
final RoadAlertCategory category;
final String? subtype;
final String title;
final String subtitle;
final IconData icon;
final Color color;
const _ReportItem({
required this.category,
this.subtype,
required this.title,
required this.subtitle,
required this.icon,
required this.color,
});
}
const List<_ReportItem> _kReportCategories = [
_ReportItem(
category: RoadAlertCategory.police,
subtype: 'speed_trap',
title: 'Police',
subtitle: 'Speed trap / radar',
icon: Icons.local_police,
color: Colors.blueAccent,
),
_ReportItem(
category: RoadAlertCategory.hazard,
subtype: 'slick_surface',
title: 'Road Hazard',
subtitle: 'Gravel, slick, oil',
icon: Icons.warning_amber_rounded,
color: Colors.orangeAccent,
),
_ReportItem(
category: RoadAlertCategory.pothole,
subtype: 'pothole',
title: 'Pothole',
subtitle: 'Severe pavement break',
icon: Icons.radio_button_checked,
color: Color(0xFFFFB300),
),
_ReportItem(
category: RoadAlertCategory.debris,
subtype: 'debris',
title: 'Road Debris',
subtitle: 'Tire tread, object in road',
icon: Icons.scatter_plot,
color: Color(0xFFFF7043),
),
_ReportItem(
category: RoadAlertCategory.accident,
subtype: 'accident',
title: 'Accident',
subtitle: 'Crash scene ahead',
icon: Icons.car_crash,
color: Colors.redAccent,
),
_ReportItem(
category: RoadAlertCategory.disabledVehicle,
subtype: 'disabled_vehicle',
title: 'Disabled Car',
subtitle: 'Vehicle stopped in lane/shoulder',
icon: Icons.car_repair,
color: Color(0xFFFF5252),
),
_ReportItem(
category: RoadAlertCategory.roadClosure,
subtype: 'road_closure',
title: 'Road Closed',
subtitle: 'Passage completely blocked',
icon: Icons.do_not_disturb_on,
color: Colors.deepOrange,
),
_ReportItem(
category: RoadAlertCategory.construction,
subtype: 'construction',
title: 'Construction',
subtitle: 'Work zone / road crew',
icon: Icons.engineering,
color: Colors.amber,
),
_ReportItem(
category: RoadAlertCategory.traffic,
subtype: 'heavy_traffic',
title: 'Heavy Traffic',
subtitle: 'Standstill or congestion',
icon: Icons.traffic,
color: Colors.yellowAccent,
),
_ReportItem(
category: RoadAlertCategory.weatherHazard,
subtype: 'weather_hazard',
title: 'Weather Hazard',
subtitle: 'Water, ice, dense fog',
icon: Icons.thunderstorm,
color: Color(0xFF4FC3F7),
),
];
class QuickSwarmReportModal extends ConsumerStatefulWidget {
const QuickSwarmReportModal({super.key});
@ -30,10 +131,31 @@ class QuickSwarmReportModal extends ConsumerStatefulWidget {
class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
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
Widget build(BuildContext context) {
final maxHeight = MediaQuery.of(context).size.height * 0.85;
return Container(
constraints: BoxConstraints(maxHeight: maxHeight),
decoration: const BoxDecoration(
color: AppColors.darkSurface,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
@ -41,7 +163,9 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
top: BorderSide(color: AppColors.darkOutline, width: 1.5),
),
),
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -56,7 +180,7 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
),
),
),
const SizedBox(height: 16),
const SizedBox(height: 12),
Row(
children: [
const Icon(Icons.campaign, color: AppColors.cyberCyan, size: 24),
@ -79,112 +203,125 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
),
],
),
const SizedBox(height: 4),
Text(
'Instant 1-tap broadcast to all nearby riders & pack members.',
'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,
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: 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,
),
],
),
),
],
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);
},
),
),
),
],
),
),
),
);
}
Widget _buildActionButton({
required BuildContext context,
required RoadAlertCategory category,
required String title,
required String subtitle,
required IconData icon,
required Color color,
}) {
Widget _buildGridCard(_ReportItem item) {
return ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: color.withValues(alpha: 0.15),
backgroundColor: item.color.withValues(alpha: 0.12),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: color, width: 1.5),
side: BorderSide(color: item.color.withValues(alpha: 0.7), width: 1.5),
),
elevation: 0,
),
onPressed: _submitting ? null : () => _handleReport(category),
onPressed: _submitting ? null : () => _handleReport(item.category, subtype: item.subtype),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
padding: const EdgeInsets.all(7),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.25),
color: item.color.withValues(alpha: 0.22),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: color, size: 28),
child: Icon(item.icon, color: item.color, size: 22),
),
const SizedBox(width: 14),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
title,
item.title,
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 14,
fontSize: 13,
fontWeight: FontWeight.bold,
color: Colors.white,
letterSpacing: 0.5,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
subtitle,
style: const TextStyle(fontSize: 11, color: Colors.white70),
item.subtitle,
style: const TextStyle(fontSize: 10, color: Colors.white70),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const Icon(Icons.arrow_forward_ios, color: Colors.white38, size: 16),
],
),
);
}
Future<void> _handleReport(RoadAlertCategory category) async {
Future<void> _handleReport(RoadAlertCategory category, {String? subtype}) async {
if (_submitting) return;
setState(() => _submitting = true);
HapticFeedback.heavyImpact();
@ -193,6 +330,7 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
final activeRide = ref.read(activeUserGroupRideProvider);
final swarmService = ref.read(swarmRoadAlertServiceProvider);
final alertService = ref.read(roadAlertServiceProvider);
// 1. Acquire current position
Position? pos;
try {
@ -209,7 +347,6 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
);
}
// Closing the sheet before location resolves cancels the report.
if (!mounted || ModalRoute.of(context)?.isCurrent != true) return;
final lat = pos?.latitude ?? 0.0;
@ -226,11 +363,11 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
return;
}
// 2. Check active group ride
// 3. Dispatch to Swarm Service
// 2. Dispatch to Swarm Service
var cloudStatus = SwarmCloudStatus.unavailable;
final report = await swarmService.reportAlert(
category: category,
subtype: subtype,
latitude: lat,
longitude: lng,
heading: heading,
@ -238,7 +375,6 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
onCloudStatus: (status) => cloudStatus = status,
);
// The sheet or its ProviderScope may have gone away during submission.
if (!mounted || ModalRoute.of(context)?.isCurrent != true) return;
alertService.simulateAlert(report.toRoadAlert());
Navigator.of(context).pop();
@ -252,13 +388,13 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
Expanded(
child: Text(switch (cloudStatus) {
SwarmCloudStatus.confirmed =>
'${category.displayName} added to local alerts and saved to cloud.',
'${category.displayName} reported to community and saved to cloud.',
SwarmCloudStatus.pending =>
'${category.displayName} added locally; cloud delivery pending.',
'${category.displayName} reported locally; cloud delivery pending.',
SwarmCloudStatus.failed =>
'${category.displayName} added locally; cloud delivery failed.',
'${category.displayName} reported locally; cloud delivery failed.',
SwarmCloudStatus.unavailable =>
'${category.displayName} added locally; cloud unavailable.',
'${category.displayName} reported locally; operating offline.',
}, style: const TextStyle(fontWeight: FontWeight.bold)),
),
],
@ -267,6 +403,15 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
duration: const Duration(seconds: 3),
),
);
} on RoadAlertRateLimitException catch (rateErr) {
if (mounted && ModalRoute.of(context)?.isCurrent == true) {
messenger.showSnackBar(
SnackBar(
content: Text(rateErr.toString()),
backgroundColor: Colors.orangeAccent,
),
);
}
} catch (e) {
if (mounted && ModalRoute.of(context)?.isCurrent == true) {
messenger.showSnackBar(
@ -296,7 +441,7 @@ class QuickSwarmReportButton extends StatelessWidget {
icon: const Icon(Icons.campaign, color: Colors.orangeAccent, size: 20),
tooltip: 'Report Road Hazard / Police',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
constraints: const BoxConstraints(minWidth: 48, minHeight: 48),
onPressed: () => QuickSwarmReportModal.show(context),
);
}
@ -304,19 +449,18 @@ class QuickSwarmReportButton extends StatelessWidget {
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orangeAccent.withValues(alpha: 0.15),
foregroundColor: Colors.orangeAccent,
side: const BorderSide(color: Colors.orangeAccent, width: 1),
side: const BorderSide(color: Colors.orangeAccent, width: 1.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
minimumSize: Size.zero,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
minimumSize: const Size(48, 48),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
icon: const Icon(Icons.campaign, size: 16),
icon: const Icon(Icons.campaign, size: 18),
label: const Text(
'REPORT',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
),
onPressed: () => QuickSwarmReportModal.show(context),
);
}
}

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
await service.clearAlert(report.id);
await service.clearAlert(report.id, voterUid: 'rider_a');
var doc = await fakeFirestore.collection('road_alerts').doc(report.id).get();
expect(doc.data()!['dismissals'], 1);
// 2nd clear: dismissals becomes 2, which is >= confirmations (1) -> expires immediately
await service.clearAlert(report.id);
await service.clearAlert(report.id, voterUid: 'rider_b');
doc = await fakeFirestore.collection('road_alerts').doc(report.id).get();
expect(doc.data()!['dismissals'], 2);
final expiresAt = doc.data()!['expiresAt'] as int;
@ -340,4 +340,238 @@ void main() {
expect(activeProviders.any((p) => p.id == 'swarm_alerts'), isTrue);
});
});
group('Native Road Alert Categories & Verification Trust Tests', () {
test('all 10 native categories map properly to RoadAlert', () {
final now = DateTime.now();
final expires = now.add(const Duration(hours: 2));
final categories = [
RoadAlertCategory.police,
RoadAlertCategory.accident,
RoadAlertCategory.hazard,
RoadAlertCategory.pothole,
RoadAlertCategory.debris,
RoadAlertCategory.disabledVehicle,
RoadAlertCategory.roadClosure,
RoadAlertCategory.construction,
RoadAlertCategory.traffic,
RoadAlertCategory.weatherHazard,
];
for (final cat in categories) {
final report = SwarmReport(
id: 'report_${cat.name}',
category: cat,
subtype: 'severe_${cat.name}',
latitude: 35.0,
longitude: -84.0,
description: 'Rider alert for ${cat.name}',
reporterUid: 'rider_1',
reportedAt: now,
expiresAt: expires,
confirmations: 1,
dismissals: 0,
source: 'MotorSignal Swarm',
status: RoadAlertStatus.active,
);
final alert = report.toRoadAlert();
expect(alert.category, cat);
expect(alert.subtype, 'severe_${cat.name}');
expect(alert.isCommunityConfirmed, isFalse);
expect(alert.confirmations, 1);
expect(alert.dismissals, 0);
expect(alert.status, RoadAlertStatus.active);
}
});
test('isCommunityConfirmed requires at least 2 confirmations', () {
final now = DateTime.now();
final expires = now.add(const Duration(hours: 2));
final unconfirmed = SwarmReport(
id: 'rep_1',
category: RoadAlertCategory.pothole,
latitude: 35.0,
longitude: -84.0,
description: 'Pot hole test',
reporterUid: 'rider_1',
reportedAt: now,
expiresAt: expires,
confirmations: 1,
dismissals: 0,
).toRoadAlert();
expect(unconfirmed.isCommunityConfirmed, isFalse);
final confirmed = SwarmReport(
id: 'rep_2',
category: RoadAlertCategory.pothole,
latitude: 35.0,
longitude: -84.0,
description: 'Pot hole test',
reporterUid: 'rider_1',
reportedAt: now,
expiresAt: expires,
confirmations: 2,
dismissals: 0,
).toRoadAlert();
expect(confirmed.isCommunityConfirmed, isTrue);
});
});
group('Rate Limiting & Duplicate Detection Tests', () {
late FakeFirebaseFirestore fakeFirestore;
setUp(() {
fakeFirestore = FakeFirebaseFirestore();
});
test('enforces rate limit interval and throws RoadAlertRateLimitException', () async {
final rateLimitedService = SwarmRoadAlertService(
firestore: fakeFirestore,
minReportInterval: const Duration(seconds: 15),
);
// First report succeeds
final first = await rateLimitedService.reportAlert(
category: RoadAlertCategory.police,
latitude: 35.0,
longitude: -84.0,
);
expect(first.id, startsWith('swarm_'));
// Second immediate report at distant location throws rate limit exception
expect(
() => rateLimitedService.reportAlert(
category: RoadAlertCategory.accident,
latitude: 36.0,
longitude: -85.0,
),
throwsA(isA<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);
});
});
}