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:
parent
082b22fe00
commit
7bd5a0e585
|
|
@ -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',
|
||||||
|
}));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue