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
|
||||
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} {
|
||||
function validAlertKeys() {
|
||||
return request.resource.data.keys().hasOnly([
|
||||
'id', 'category', 'latitude', 'longitude', 'heading',
|
||||
'id', 'category', 'subtype', 'latitude', 'longitude', 'heading',
|
||||
'roadName', 'description', 'reporterUid', 'reportedAt',
|
||||
'expiresAt', 'confirmations', 'dismissals', 'groupRideId'
|
||||
'expiresAt', 'confirmations', 'dismissals', 'groupRideId', 'source', 'status'
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -1518,7 +1518,8 @@ service cloud.firestore {
|
|||
&& validAlertKeys()
|
||||
&& request.resource.data.category in [
|
||||
'police', 'hazard', 'accident', 'speedCamera',
|
||||
'roadClosure', 'construction', 'traffic', 'other'
|
||||
'roadClosure', 'construction', 'traffic', 'pothole',
|
||||
'debris', 'disabledVehicle', 'weatherHazard', 'other'
|
||||
]
|
||||
&& (request.resource.data.latitude is int || request.resource.data.latitude is float)
|
||||
&& (request.resource.data.longitude is int || request.resource.data.longitude is float)
|
||||
|
|
@ -1530,7 +1531,10 @@ service cloud.firestore {
|
|||
&& request.resource.data.expiresAt is int
|
||||
&& request.resource.data.expiresAt > request.resource.data.reportedAt
|
||||
&& request.resource.data.description is string
|
||||
&& request.resource.data.description.size() <= 256;
|
||||
&& request.resource.data.description.size() <= 256
|
||||
&& (!('subtype' in request.resource.data) || (request.resource.data.subtype is string && request.resource.data.subtype.size() <= 64))
|
||||
&& (!('source' in request.resource.data) || (request.resource.data.source is string && request.resource.data.source.size() <= 64))
|
||||
&& (!('status' in request.resource.data) || request.resource.data.status in ['active', 'confirmed', 'cleared', 'expired']);
|
||||
}
|
||||
|
||||
function validAlertUpdate() {
|
||||
|
|
@ -1542,7 +1546,8 @@ service cloud.firestore {
|
|||
&& request.resource.data.latitude == resource.data.latitude
|
||||
&& request.resource.data.longitude == resource.data.longitude
|
||||
&& request.resource.data.reportedAt == resource.data.reportedAt
|
||||
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['confirmations', 'dismissals', 'expiresAt'])
|
||||
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['confirmations', 'dismissals', 'expiresAt', 'status'])
|
||||
&& (!('status' in request.resource.data) || request.resource.data.status in ['active', 'confirmed', 'cleared', 'expired'])
|
||||
&& (
|
||||
(
|
||||
request.resource.data.confirmations == resource.data.confirmations + 1
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import '../../services/map/map_provider_interface.dart';
|
|||
import '../../services/map/map_providers.dart';
|
||||
import '../../services/map/unified_map_view.dart';
|
||||
import '../../services/alerts/road_alert_models.dart';
|
||||
import '../../services/alerts/road_alert_providers.dart';
|
||||
import '../../services/alerts/road_alert_service.dart';
|
||||
import '../../services/alerts/widgets/quick_swarm_report_modal.dart';
|
||||
import '../../services/map/map_layer_settings.dart';
|
||||
|
|
@ -636,30 +637,240 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
}
|
||||
|
||||
void _showRoadAlertDetails(RoadAlert alert) {
|
||||
final swarmService = ref.read(swarmRoadAlertServiceProvider);
|
||||
final canVote = alert.sourceType == RoadAlertSourceType.userReported &&
|
||||
swarmService.canUserVote(alert.id);
|
||||
final hasVoted = swarmService.hasUserVoted(alert.id);
|
||||
final isConfirmed = alert.isCommunityConfirmed;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context, backgroundColor: Colors.grey[900],
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
|
||||
context: context,
|
||||
backgroundColor: Colors.grey[900],
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Container(padding: const EdgeInsets.all(8), decoration: BoxDecoration(color: alert.category.color.withValues(alpha: 0.2), shape: BoxShape.circle), child: Icon(alert.category.icon, color: alert.category.color, size: 28)),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: alert.category.color.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(alert.category.icon,
|
||||
color: alert.category.color, size: 28),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(alert.category.displayName.toUpperCase(), style: TextStyle(color: alert.category.color, fontWeight: FontWeight.bold, fontSize: 12, letterSpacing: 1.1)),
|
||||
if (alert.roadName != null && alert.roadName!.isNotEmpty) Text(alert.roadName!, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
])),
|
||||
Container(padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration(color: Colors.white10, borderRadius: BorderRadius.circular(12)), child: Text(alert.sourceType == RoadAlertSourceType.userReported ? 'User Reported' : 'Verified Provider', style: const TextStyle(fontSize: 10, color: Colors.white70))),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
Text(alert.description, style: const TextStyle(fontSize: 15, height: 1.3)),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
alert.category.displayName.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: alert.category.color,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
letterSpacing: 1.1,
|
||||
),
|
||||
),
|
||||
if (alert.roadName != null && alert.roadName!.isNotEmpty)
|
||||
Text(alert.roadName!,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isConfirmed
|
||||
? Colors.greenAccent.withValues(alpha: 0.2)
|
||||
: (alert.sourceType == RoadAlertSourceType.userReported
|
||||
? Colors.orangeAccent.withValues(alpha: 0.2)
|
||||
: Colors.white10),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isConfirmed
|
||||
? Colors.greenAccent
|
||||
: (alert.sourceType ==
|
||||
RoadAlertSourceType.userReported
|
||||
? Colors.orangeAccent
|
||||
: Colors.transparent),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isConfirmed
|
||||
? Icons.verified
|
||||
: (alert.sourceType ==
|
||||
RoadAlertSourceType.userReported
|
||||
? Icons.schedule
|
||||
: Icons.shield_outlined),
|
||||
size: 12,
|
||||
color: isConfirmed
|
||||
? Colors.greenAccent
|
||||
: (alert.sourceType ==
|
||||
RoadAlertSourceType.userReported
|
||||
? Colors.orangeAccent
|
||||
: Colors.white70),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
isConfirmed
|
||||
? 'COMMUNITY CONFIRMED'
|
||||
: (alert.sourceType ==
|
||||
RoadAlertSourceType.userReported
|
||||
? 'UNVERIFIED'
|
||||
: 'VERIFIED'),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isConfirmed
|
||||
? Colors.greenAccent
|
||||
: (alert.sourceType ==
|
||||
RoadAlertSourceType.userReported
|
||||
? Colors.orangeAccent
|
||||
: Colors.white70),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(alert.description,
|
||||
style: const TextStyle(fontSize: 15, height: 1.3)),
|
||||
const SizedBox(height: 12),
|
||||
if (alert.sourceType == RoadAlertSourceType.userReported)
|
||||
Text(
|
||||
'Trust & Verification: ${alert.confirmations} confirmed · ${alert.dismissals} dismissed',
|
||||
style: const TextStyle(fontSize: 12, color: Colors.white70),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Divider(color: Colors.white24),
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
||||
if (alert.distanceFromUser != null) Text('Distance: ${(alert.distanceFromUser! * 0.000621371).toStringAsFixed(2)} mi', style: const TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold)),
|
||||
Text('Source: ${alert.source}', style: const TextStyle(color: Colors.white54, fontSize: 12)),
|
||||
]),
|
||||
]),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (alert.distanceFromUser != null)
|
||||
Text(
|
||||
'Distance: ${(alert.distanceFromUser! * 0.000621371).toStringAsFixed(2)} mi',
|
||||
style: const TextStyle(
|
||||
color: Colors.cyanAccent,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Source: ${alert.source}',
|
||||
style:
|
||||
const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (alert.sourceType == RoadAlertSourceType.userReported) ...[
|
||||
const SizedBox(height: 16),
|
||||
if (hasVoted)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white10,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Text(
|
||||
'You have already verified this report.',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
)
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor:
|
||||
Colors.greenAccent.withValues(alpha: 0.2),
|
||||
foregroundColor: Colors.greenAccent,
|
||||
side: const BorderSide(
|
||||
color: Colors.greenAccent, width: 1.5),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.thumb_up_alt_outlined, size: 18),
|
||||
label: const Text('STILL THERE',
|
||||
style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
onPressed: canVote
|
||||
? () async {
|
||||
final messenger =
|
||||
ScaffoldMessenger.of(context);
|
||||
Navigator.of(ctx).pop();
|
||||
await swarmService.confirmAlert(alert.id);
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Alert confirmed! Thank you for verifying.'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor:
|
||||
Colors.redAccent.withValues(alpha: 0.2),
|
||||
foregroundColor: Colors.redAccent,
|
||||
side: const BorderSide(
|
||||
color: Colors.redAccent, width: 1.5),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
icon:
|
||||
const Icon(Icons.thumb_down_alt_outlined, size: 18),
|
||||
label: const Text('NOT THERE',
|
||||
style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
onPressed: canVote
|
||||
? () async {
|
||||
final messenger =
|
||||
ScaffoldMessenger.of(context);
|
||||
Navigator.of(ctx).pop();
|
||||
await swarmService.clearAlert(alert.id);
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Report marked as clear. Thank you!'),
|
||||
backgroundColor: Colors.orange,
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1026,8 +1026,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
|
||||
Widget _buildWazeProviderCard(BuildContext context) {
|
||||
final colors = context.motorColors;
|
||||
final wazeStatus = ref.watch(wazeStatusProvider);
|
||||
final wazeEnabled = ref.watch(wazeRoadAlertsEnabledProvider);
|
||||
final wazeOsintStatus = ref.watch(wazeOsintStatusProvider);
|
||||
final wazeOsintEnabled = ref.watch(wazeOsintEnabledProvider);
|
||||
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
|
|
@ -1041,50 +1041,57 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildProviderHeader(
|
||||
icon: Icons.local_police,
|
||||
icon: Icons.alt_route,
|
||||
iconColor: colors.velocityBlue,
|
||||
title: 'Waze Police Alerts',
|
||||
title: 'Waze Community (Experimental OSINT)',
|
||||
subtitle:
|
||||
'Real-time Waze crowd-sourced police speed traps & enforcement via OpenWebNinja.',
|
||||
status: wazeStatus,
|
||||
'Optional community hazard & traffic feed inspired by ArgeliusLabs/waze_osint. Disabled by default; zero paid subscriptions.',
|
||||
status: wazeOsintStatus,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile.adaptive(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text(
|
||||
'Enable Waze Police Alerts',
|
||||
'Enable Experimental Waze Feed',
|
||||
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Live speed traps and police alerts during ride navigation (Type: Police only).',
|
||||
'Streams public community road alerts, hazards, and traffic (Disabled by default).',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
value: wazeEnabled,
|
||||
value: wazeOsintEnabled,
|
||||
onChanged: (val) {
|
||||
ref
|
||||
.read(wazeRoadAlertsEnabledProvider.notifier)
|
||||
.read(wazeOsintEnabledProvider.notifier)
|
||||
.setEnabled(val);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _wazeKeyController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'OpenWebNinja / Waze API Key',
|
||||
hintText: 'Enter your OpenWebNinja x-api-key',
|
||||
helperText:
|
||||
'Supplies real-time Waze police traps and enforcement.',
|
||||
helperMaxLines: 2,
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: colors.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 16, color: colors.cyberCyan),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Community-sourced experimental data. No API key required.',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
onChanged: (val) {
|
||||
ref.read(wazeApiKeyProvider.notifier).setApiKey(val);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
|
|
@ -1101,35 +1108,17 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
: () async {
|
||||
setState(() => _isTestingWaze = true);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final provider = ref.read(wazeProviderInstance);
|
||||
final status = await provider.testConnection(
|
||||
_wazeKeyController.text,
|
||||
);
|
||||
ref.read(wazeStatusProvider.notifier).state = status;
|
||||
final provider = ref.read(wazeOsintProviderInstance);
|
||||
final status = await provider.testConnection('');
|
||||
ref.read(wazeOsintStatusProvider.notifier).state = status;
|
||||
setState(() => _isTestingWaze = false);
|
||||
|
||||
if (mounted) {
|
||||
String msg = '';
|
||||
if (status == RoadAlertProviderStatus.connected) {
|
||||
msg = 'Waze API Connected successfully!';
|
||||
} else if (status ==
|
||||
RoadAlertProviderStatus.authError) {
|
||||
msg =
|
||||
'Authentication Error: Invalid or inactive OpenWebNinja API key.';
|
||||
} else if (status ==
|
||||
RoadAlertProviderStatus.notConfigured) {
|
||||
msg = 'API Key is empty.';
|
||||
} else if (status ==
|
||||
RoadAlertProviderStatus.networkError) {
|
||||
msg =
|
||||
'Network Error: Failed to connect to OpenWebNinja API.';
|
||||
} else if (status ==
|
||||
RoadAlertProviderStatus.rateLimited) {
|
||||
msg = 'Rate Limited: Plan quota exceeded.';
|
||||
} else {
|
||||
msg = 'Status: ${status.name}';
|
||||
}
|
||||
messenger.showSnackBar(SnackBar(content: Text(msg)));
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Waze OSINT adapter configured successfully!'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: _isTestingWaze
|
||||
|
|
@ -1297,7 +1286,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
controller: _communityKeyController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Community / RapidAPI Key',
|
||||
hintText: 'Enter OpenWebNinja, RapidAPI, or Community API Key',
|
||||
hintText: 'Enter RapidAPI or Custom Community Key',
|
||||
helperText:
|
||||
'Supplies Police, Speed Cameras, and Crowd Hazards.',
|
||||
helperMaxLines: 2,
|
||||
|
|
@ -1317,7 +1306,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
hintText:
|
||||
'e.g. https://google-map-places.p.rapidapi.com or custom URL',
|
||||
helperText:
|
||||
'Leave blank for automatic OpenWebNinja / RapidAPI detection.',
|
||||
'Leave blank for default endpoint detection.',
|
||||
helperMaxLines: 2,
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
|
|
@ -1356,7 +1345,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
String msg = '';
|
||||
if (status == RoadAlertProviderStatus.connected) {
|
||||
msg =
|
||||
'Community / OpenWebNinja API Connected successfully!';
|
||||
'Community Traffic API Connected successfully!';
|
||||
} else if (status ==
|
||||
RoadAlertProviderStatus.authError) {
|
||||
msg =
|
||||
|
|
|
|||
|
|
@ -5,9 +5,13 @@ enum RoadAlertCategory {
|
|||
speedCamera,
|
||||
accident,
|
||||
hazard,
|
||||
pothole,
|
||||
debris,
|
||||
disabledVehicle,
|
||||
roadClosure,
|
||||
construction,
|
||||
traffic,
|
||||
weatherHazard,
|
||||
other,
|
||||
}
|
||||
|
||||
|
|
@ -22,12 +26,20 @@ extension RoadAlertCategoryExtension on RoadAlertCategory {
|
|||
return 'Accident';
|
||||
case RoadAlertCategory.hazard:
|
||||
return 'Hazard';
|
||||
case RoadAlertCategory.pothole:
|
||||
return 'Pothole';
|
||||
case RoadAlertCategory.debris:
|
||||
return 'Road Debris';
|
||||
case RoadAlertCategory.disabledVehicle:
|
||||
return 'Disabled Vehicle';
|
||||
case RoadAlertCategory.roadClosure:
|
||||
return 'Road Closure';
|
||||
case RoadAlertCategory.construction:
|
||||
return 'Construction';
|
||||
case RoadAlertCategory.traffic:
|
||||
return 'Traffic Delay';
|
||||
return 'Heavy Traffic';
|
||||
case RoadAlertCategory.weatherHazard:
|
||||
return 'Weather Hazard';
|
||||
case RoadAlertCategory.other:
|
||||
return 'Other';
|
||||
}
|
||||
|
|
@ -43,12 +55,20 @@ extension RoadAlertCategoryExtension on RoadAlertCategory {
|
|||
return Icons.car_crash;
|
||||
case RoadAlertCategory.hazard:
|
||||
return Icons.warning_amber;
|
||||
case RoadAlertCategory.pothole:
|
||||
return Icons.radio_button_checked;
|
||||
case RoadAlertCategory.debris:
|
||||
return Icons.scatter_plot;
|
||||
case RoadAlertCategory.disabledVehicle:
|
||||
return Icons.car_repair;
|
||||
case RoadAlertCategory.roadClosure:
|
||||
return Icons.do_not_disturb_on;
|
||||
case RoadAlertCategory.construction:
|
||||
return Icons.engineering;
|
||||
case RoadAlertCategory.traffic:
|
||||
return Icons.traffic;
|
||||
case RoadAlertCategory.weatherHazard:
|
||||
return Icons.thunderstorm;
|
||||
case RoadAlertCategory.other:
|
||||
return Icons.report_problem;
|
||||
}
|
||||
|
|
@ -64,12 +84,20 @@ extension RoadAlertCategoryExtension on RoadAlertCategory {
|
|||
return Colors.redAccent;
|
||||
case RoadAlertCategory.hazard:
|
||||
return Colors.orangeAccent;
|
||||
case RoadAlertCategory.pothole:
|
||||
return const Color(0xFFFFB300); // Amber 700
|
||||
case RoadAlertCategory.debris:
|
||||
return const Color(0xFFFF7043); // Deep Orange 400
|
||||
case RoadAlertCategory.disabledVehicle:
|
||||
return const Color(0xFFFF5252); // Red accent 200
|
||||
case RoadAlertCategory.roadClosure:
|
||||
return Colors.deepOrange;
|
||||
case RoadAlertCategory.construction:
|
||||
return Colors.amber;
|
||||
case RoadAlertCategory.traffic:
|
||||
return Colors.yellowAccent;
|
||||
case RoadAlertCategory.weatherHazard:
|
||||
return const Color(0xFF4FC3F7); // Light Blue 300
|
||||
case RoadAlertCategory.other:
|
||||
return Colors.cyanAccent;
|
||||
}
|
||||
|
|
@ -80,6 +108,8 @@ enum RoadAlertSeverity { low, medium, high, critical }
|
|||
|
||||
enum RoadAlertSourceType { providerReported, userReported }
|
||||
|
||||
enum RoadAlertStatus { active, confirmed, cleared, expired }
|
||||
|
||||
enum RoadAlertProviderStatus {
|
||||
notConfigured,
|
||||
configured,
|
||||
|
|
@ -93,6 +123,7 @@ class RoadAlert {
|
|||
final String id;
|
||||
final String providerId;
|
||||
final RoadAlertCategory category;
|
||||
final String? subtype;
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
final double? heading;
|
||||
|
|
@ -107,11 +138,16 @@ class RoadAlert {
|
|||
final String? sourceUrl;
|
||||
final double? distanceFromUser; // meters
|
||||
final RoadAlertSourceType sourceType;
|
||||
final RoadAlertStatus status;
|
||||
final int confirmations;
|
||||
final int dismissals;
|
||||
final String? reporterUid;
|
||||
|
||||
const RoadAlert({
|
||||
required this.id,
|
||||
required this.providerId,
|
||||
required this.category,
|
||||
this.subtype,
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
this.heading,
|
||||
|
|
@ -126,12 +162,19 @@ class RoadAlert {
|
|||
this.sourceUrl,
|
||||
this.distanceFromUser,
|
||||
this.sourceType = RoadAlertSourceType.providerReported,
|
||||
this.status = RoadAlertStatus.active,
|
||||
this.confirmations = 1,
|
||||
this.dismissals = 0,
|
||||
this.reporterUid,
|
||||
});
|
||||
|
||||
bool get isCommunityConfirmed => confirmations >= 2;
|
||||
|
||||
RoadAlert copyWith({
|
||||
String? id,
|
||||
String? providerId,
|
||||
RoadAlertCategory? category,
|
||||
String? subtype,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
double? heading,
|
||||
|
|
@ -146,11 +189,16 @@ class RoadAlert {
|
|||
String? sourceUrl,
|
||||
double? distanceFromUser,
|
||||
RoadAlertSourceType? sourceType,
|
||||
RoadAlertStatus? status,
|
||||
int? confirmations,
|
||||
int? dismissals,
|
||||
String? reporterUid,
|
||||
}) {
|
||||
return RoadAlert(
|
||||
id: id ?? this.id,
|
||||
providerId: providerId ?? this.providerId,
|
||||
category: category ?? this.category,
|
||||
subtype: subtype ?? this.subtype,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
heading: heading ?? this.heading,
|
||||
|
|
@ -165,6 +213,10 @@ class RoadAlert {
|
|||
sourceUrl: sourceUrl ?? this.sourceUrl,
|
||||
distanceFromUser: distanceFromUser ?? this.distanceFromUser,
|
||||
sourceType: sourceType ?? this.sourceType,
|
||||
status: status ?? this.status,
|
||||
confirmations: confirmations ?? this.confirmations,
|
||||
dismissals: dismissals ?? this.dismissals,
|
||||
reporterUid: reporterUid ?? this.reporterUid,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,13 +10,20 @@ import 'swarm_road_alert_service.dart';
|
|||
import 'swarm_road_alert_provider.dart';
|
||||
import 'here_road_alert_provider.dart';
|
||||
import 'waze_road_alert_provider.dart';
|
||||
import 'waze_osint_road_alert_provider.dart';
|
||||
|
||||
final wazeProviderInstance = Provider<WazeRoadAlertProvider>((ref) {
|
||||
return WazeRoadAlertProvider();
|
||||
});
|
||||
|
||||
final wazeOsintProviderInstance = Provider<WazeOsintRoadAlertProvider>((ref) {
|
||||
return WazeOsintRoadAlertProvider();
|
||||
});
|
||||
|
||||
final swarmRoadAlertServiceProvider = Provider<SwarmRoadAlertService>((ref) {
|
||||
return SwarmRoadAlertService();
|
||||
return SwarmRoadAlertService(
|
||||
minReportInterval: const Duration(seconds: 15),
|
||||
);
|
||||
});
|
||||
|
||||
final swarmRoadAlertProviderInstance = Provider<SwarmRoadAlertProvider>((ref) {
|
||||
|
|
@ -118,9 +125,13 @@ class EnabledCategoriesNotifier extends StateNotifier<Set<RoadAlertCategory>> {
|
|||
RoadAlertCategory.speedCamera,
|
||||
RoadAlertCategory.accident,
|
||||
RoadAlertCategory.hazard,
|
||||
RoadAlertCategory.pothole,
|
||||
RoadAlertCategory.debris,
|
||||
RoadAlertCategory.disabledVehicle,
|
||||
RoadAlertCategory.roadClosure,
|
||||
RoadAlertCategory.construction,
|
||||
RoadAlertCategory.traffic,
|
||||
RoadAlertCategory.weatherHazard,
|
||||
RoadAlertCategory.other,
|
||||
});
|
||||
|
||||
|
|
@ -404,9 +415,43 @@ final wazeStatusProvider = StateProvider<RoadAlertProviderStatus>((ref) {
|
|||
return RoadAlertProviderStatus.configured;
|
||||
});
|
||||
|
||||
// Experimental Waze OSINT Community Adapter Toggle (Disabled by default)
|
||||
class WazeOsintEnabledNotifier extends StateNotifier<bool> {
|
||||
static const _key = 'waze_osint_enabled';
|
||||
WazeOsintEnabledNotifier() : super(false) {
|
||||
_load();
|
||||
}
|
||||
Future<void> _load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (!mounted) return;
|
||||
final saved = prefs.getBool(_key);
|
||||
if (saved != null) {
|
||||
state = saved;
|
||||
}
|
||||
}
|
||||
Future<void> setEnabled(bool enabled) async {
|
||||
state = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_key, enabled);
|
||||
if (mounted) {
|
||||
state = enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
final wazeOsintEnabledProvider =
|
||||
StateNotifierProvider<WazeOsintEnabledNotifier, bool>((ref) {
|
||||
return WazeOsintEnabledNotifier();
|
||||
});
|
||||
|
||||
final wazeOsintStatusProvider = StateProvider<RoadAlertProviderStatus>((ref) {
|
||||
final enabled = ref.watch(wazeOsintEnabledProvider);
|
||||
return enabled ? RoadAlertProviderStatus.connected : RoadAlertProviderStatus.notConfigured;
|
||||
});
|
||||
|
||||
// Active Providers List
|
||||
final activeRoadAlertProviders = Provider<List<RoadAlertProvider>>((ref) {
|
||||
final swarmEnabled = ref.watch(swarmAlertsEnabledProvider);
|
||||
final wazeOsintEnabled = ref.watch(wazeOsintEnabledProvider);
|
||||
final usCamerasEnabled = ref.watch(usSpeedCamerasEnabledProvider);
|
||||
final tomtomKey = ref.watch(tomtomApiKeyProvider);
|
||||
final communityKey = ref.watch(communityApiKeyProvider);
|
||||
|
|
@ -419,6 +464,9 @@ final activeRoadAlertProviders = Provider<List<RoadAlertProvider>>((ref) {
|
|||
if (swarmEnabled) {
|
||||
list.add(ref.watch(swarmRoadAlertProviderInstance));
|
||||
}
|
||||
if (wazeOsintEnabled) {
|
||||
list.add(ref.watch(wazeOsintProviderInstance));
|
||||
}
|
||||
if (wazeEnabled && wazeKey.isNotEmpty) {
|
||||
list.add(ref.watch(wazeProviderInstance));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
|
@ -7,6 +8,8 @@ import 'package:geolocator/geolocator.dart';
|
|||
import 'package:rxdart/rxdart.dart';
|
||||
import '../location_service.dart';
|
||||
import '../sensor_fusion_service.dart';
|
||||
import '../map/map_provider_interface.dart';
|
||||
import '../navigation/navigation_session_service.dart';
|
||||
import 'road_alert_models.dart';
|
||||
import 'road_alert_providers.dart';
|
||||
import 'road_alert_compliance.dart';
|
||||
|
|
@ -238,7 +241,35 @@ class RoadAlertService {
|
|||
userLat: position.latitude,
|
||||
userLng: position.longitude,
|
||||
complianceEnabled: complianceEnabled,
|
||||
)..sort((a, b) => (a.distanceFromUser ?? 0).compareTo(b.distanceFromUser ?? 0));
|
||||
);
|
||||
|
||||
final navSession = _ref.read(navigationSessionProvider);
|
||||
final hasActiveRoute = navSession.isNavigating &&
|
||||
navSession.calculationResult != null &&
|
||||
navSession.calculationResult!.geometry.length >= 2;
|
||||
|
||||
compliantList.sort((a, b) {
|
||||
if (hasActiveRoute) {
|
||||
final geom = navSession.calculationResult!.geometry;
|
||||
final aOnRoute = RouteCorridorAlertFilter.isAlertAheadOnRoute(
|
||||
userLat: position.latitude,
|
||||
userLng: position.longitude,
|
||||
alertLat: a.latitude,
|
||||
alertLng: a.longitude,
|
||||
geometry: geom,
|
||||
);
|
||||
final bOnRoute = RouteCorridorAlertFilter.isAlertAheadOnRoute(
|
||||
userLat: position.latitude,
|
||||
userLng: position.longitude,
|
||||
alertLat: b.latitude,
|
||||
alertLng: b.longitude,
|
||||
geometry: geom,
|
||||
);
|
||||
if (aOnRoute && !bOnRoute) return -1;
|
||||
if (!aOnRoute && bOnRoute) return 1;
|
||||
}
|
||||
return (a.distanceFromUser ?? 0).compareTo(b.distanceFromUser ?? 0);
|
||||
});
|
||||
|
||||
_cachedAlerts = compliantList;
|
||||
_alertsController.add(compliantList);
|
||||
|
|
@ -395,6 +426,18 @@ class RoadAlertService {
|
|||
case RoadAlertCategory.traffic:
|
||||
phrase = "Traffic delay ahead.";
|
||||
break;
|
||||
case RoadAlertCategory.pothole:
|
||||
phrase = "Severe pothole ahead.";
|
||||
break;
|
||||
case RoadAlertCategory.debris:
|
||||
phrase = "Road debris ahead.";
|
||||
break;
|
||||
case RoadAlertCategory.disabledVehicle:
|
||||
phrase = "Disabled vehicle reported ahead.";
|
||||
break;
|
||||
case RoadAlertCategory.weatherHazard:
|
||||
phrase = "Weather hazard reported ahead.";
|
||||
break;
|
||||
case RoadAlertCategory.other:
|
||||
phrase = "Traffic alert ahead.";
|
||||
break;
|
||||
|
|
@ -588,3 +631,93 @@ class RoadAlertService {
|
|||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to evaluate route corridor proximity and ahead-of-rider geometry.
|
||||
class RouteCorridorAlertFilter {
|
||||
/// Computes minimum cross-track perpendicular distance in meters from point to route polyline.
|
||||
static double minDistanceToPolyline(
|
||||
double alertLat,
|
||||
double alertLng,
|
||||
List<AppLatLng> geometry,
|
||||
) {
|
||||
if (geometry.isEmpty) return double.infinity;
|
||||
if (geometry.length == 1) {
|
||||
return Geolocator.distanceBetween(
|
||||
alertLat,
|
||||
alertLng,
|
||||
geometry.first.latitude,
|
||||
geometry.first.longitude,
|
||||
);
|
||||
}
|
||||
|
||||
double minDistance = double.infinity;
|
||||
final cosLat = math.cos(alertLat * math.pi / 180.0);
|
||||
|
||||
for (int i = 0; i < geometry.length - 1; i++) {
|
||||
final p1 = geometry[i];
|
||||
final p2 = geometry[i + 1];
|
||||
|
||||
final dx = (p2.longitude - p1.longitude) * 111320.0 * cosLat;
|
||||
final dy = (p2.latitude - p1.latitude) * 111320.0;
|
||||
final segLengthSq = dx * dx + dy * dy;
|
||||
|
||||
final ax = (alertLng - p1.longitude) * 111320.0 * cosLat;
|
||||
final ay = (alertLat - p1.latitude) * 111320.0;
|
||||
|
||||
if (segLengthSq == 0.0) {
|
||||
final dist = math.sqrt(ax * ax + ay * ay);
|
||||
if (dist < minDistance) minDistance = dist;
|
||||
continue;
|
||||
}
|
||||
|
||||
final t = ((ax * dx + ay * dy) / segLengthSq).clamp(0.0, 1.0);
|
||||
final projX = t * dx;
|
||||
final projY = t * dy;
|
||||
|
||||
final dist = math.sqrt((ax - projX) * (ax - projX) + (ay - projY) * (ay - projY));
|
||||
if (dist < minDistance) {
|
||||
minDistance = dist;
|
||||
}
|
||||
}
|
||||
return minDistance;
|
||||
}
|
||||
|
||||
/// Evaluates whether an alert is along and ahead of the user's active route corridor.
|
||||
static bool isAlertAheadOnRoute({
|
||||
required double userLat,
|
||||
required double userLng,
|
||||
required double alertLat,
|
||||
required double alertLng,
|
||||
required List<AppLatLng> geometry,
|
||||
double maxCrossTrackMeters = 80.0,
|
||||
double maxAheadMeters = 1200.0,
|
||||
}) {
|
||||
if (geometry.length < 2) return false;
|
||||
|
||||
final crossTrack = minDistanceToPolyline(alertLat, alertLng, geometry);
|
||||
if (crossTrack > maxCrossTrackMeters) return false;
|
||||
|
||||
int userSeg = 0;
|
||||
double minUserDist = double.infinity;
|
||||
int alertSeg = 0;
|
||||
double minAlertDist = double.infinity;
|
||||
|
||||
for (int i = 0; i < geometry.length; i++) {
|
||||
final du = Geolocator.distanceBetween(userLat, userLng, geometry[i].latitude, geometry[i].longitude);
|
||||
if (du < minUserDist) {
|
||||
minUserDist = du;
|
||||
userSeg = i;
|
||||
}
|
||||
final da = Geolocator.distanceBetween(alertLat, alertLng, geometry[i].latitude, geometry[i].longitude);
|
||||
if (da < minAlertDist) {
|
||||
minAlertDist = da;
|
||||
alertSeg = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (alertSeg < userSeg) return false;
|
||||
|
||||
final directDist = Geolocator.distanceBetween(userLat, userLng, alertLat, alertLng);
|
||||
return directDist <= maxAheadMeters;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'road_alert_models.dart';
|
|||
class SwarmReport {
|
||||
final String id;
|
||||
final RoadAlertCategory category;
|
||||
final String? subtype;
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
final double? heading;
|
||||
|
|
@ -16,10 +17,13 @@ class SwarmReport {
|
|||
final int confirmations;
|
||||
final int dismissals;
|
||||
final String? groupRideId;
|
||||
final String? source;
|
||||
final RoadAlertStatus status;
|
||||
|
||||
const SwarmReport({
|
||||
required this.id,
|
||||
required this.category,
|
||||
this.subtype,
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
this.heading,
|
||||
|
|
@ -31,6 +35,8 @@ class SwarmReport {
|
|||
this.confirmations = 1,
|
||||
this.dismissals = 0,
|
||||
this.groupRideId,
|
||||
this.source,
|
||||
this.status = RoadAlertStatus.active,
|
||||
});
|
||||
|
||||
double get confidence {
|
||||
|
|
@ -41,9 +47,12 @@ class SwarmReport {
|
|||
|
||||
bool get isExpired => DateTime.now().isAfter(expiresAt);
|
||||
|
||||
bool get isCommunityConfirmed => confirmations >= 2;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'category': category.name,
|
||||
if (subtype != null) 'subtype': subtype,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
if (heading != null) 'heading': heading,
|
||||
|
|
@ -55,6 +64,8 @@ class SwarmReport {
|
|||
'confirmations': confirmations,
|
||||
'dismissals': dismissals,
|
||||
if (groupRideId != null) 'groupRideId': groupRideId,
|
||||
if (source != null) 'source': source,
|
||||
'status': status.name,
|
||||
};
|
||||
|
||||
factory SwarmReport.fromJson(Map<String, dynamic> json) {
|
||||
|
|
@ -75,9 +86,21 @@ class SwarmReport {
|
|||
: (DateTime.tryParse(rawExpiresAt?.toString() ?? '') ??
|
||||
reportedAt.add(const Duration(hours: 1)));
|
||||
|
||||
final confirmations = (json['confirmations'] as num?)?.toInt() ?? 1;
|
||||
final dismissals = (json['dismissals'] as num?)?.toInt() ?? 0;
|
||||
|
||||
final rawStatus = json['status']?.toString();
|
||||
final status = RoadAlertStatus.values.firstWhere(
|
||||
(s) => s.name == rawStatus,
|
||||
orElse: () => confirmations >= 2
|
||||
? RoadAlertStatus.confirmed
|
||||
: RoadAlertStatus.active,
|
||||
);
|
||||
|
||||
return SwarmReport(
|
||||
id: json['id']?.toString() ?? '',
|
||||
category: category,
|
||||
subtype: json['subtype']?.toString(),
|
||||
latitude: (json['latitude'] as num).toDouble(),
|
||||
longitude: (json['longitude'] as num).toDouble(),
|
||||
heading: json['heading'] != null ? (json['heading'] as num).toDouble() : null,
|
||||
|
|
@ -86,21 +109,26 @@ class SwarmReport {
|
|||
reporterUid: json['reporterUid']?.toString() ?? 'anonymous',
|
||||
reportedAt: reportedAt,
|
||||
expiresAt: expiresAt,
|
||||
confirmations: (json['confirmations'] as num?)?.toInt() ?? 1,
|
||||
dismissals: (json['dismissals'] as num?)?.toInt() ?? 0,
|
||||
confirmations: confirmations,
|
||||
dismissals: dismissals,
|
||||
groupRideId: json['groupRideId']?.toString(),
|
||||
source: json['source']?.toString(),
|
||||
status: status,
|
||||
);
|
||||
}
|
||||
|
||||
RoadAlert toRoadAlert({double? distanceFromUser}) {
|
||||
final isPoliceOrAccident =
|
||||
category == RoadAlertCategory.police || category == RoadAlertCategory.accident;
|
||||
final sourceName = groupRideId != null ? 'Pack Rider Report' : 'MotorSignal Swarm';
|
||||
final isHighSeverity = category == RoadAlertCategory.police ||
|
||||
category == RoadAlertCategory.accident ||
|
||||
category == RoadAlertCategory.weatherHazard;
|
||||
final sourceName = source ??
|
||||
(groupRideId != null ? 'Pack Rider Report' : 'MotorSignal Swarm');
|
||||
|
||||
return RoadAlert(
|
||||
id: id,
|
||||
providerId: 'swarm_alerts',
|
||||
category: category,
|
||||
subtype: subtype,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
heading: heading,
|
||||
|
|
@ -110,9 +138,13 @@ class SwarmReport {
|
|||
expiresAt: expiresAt,
|
||||
lastUpdated: DateTime.now(),
|
||||
confidence: confidence,
|
||||
severity: isPoliceOrAccident ? RoadAlertSeverity.high : RoadAlertSeverity.medium,
|
||||
severity: isHighSeverity ? RoadAlertSeverity.high : RoadAlertSeverity.medium,
|
||||
source: sourceName,
|
||||
sourceType: RoadAlertSourceType.userReported,
|
||||
status: status,
|
||||
confirmations: confirmations,
|
||||
dismissals: dismissals,
|
||||
reporterUid: reporterUid,
|
||||
distanceFromUser: distanceFromUser,
|
||||
);
|
||||
}
|
||||
|
|
@ -120,6 +152,7 @@ class SwarmReport {
|
|||
SwarmReport copyWith({
|
||||
String? id,
|
||||
RoadAlertCategory? category,
|
||||
String? subtype,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
double? heading,
|
||||
|
|
@ -131,10 +164,13 @@ class SwarmReport {
|
|||
int? confirmations,
|
||||
int? dismissals,
|
||||
String? groupRideId,
|
||||
String? source,
|
||||
RoadAlertStatus? status,
|
||||
}) {
|
||||
return SwarmReport(
|
||||
id: id ?? this.id,
|
||||
category: category ?? this.category,
|
||||
subtype: subtype ?? this.subtype,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
heading: heading ?? this.heading,
|
||||
|
|
@ -146,6 +182,8 @@ class SwarmReport {
|
|||
confirmations: confirmations ?? this.confirmations,
|
||||
dismissals: dismissals ?? this.dismissals,
|
||||
groupRideId: groupRideId ?? this.groupRideId,
|
||||
source: source ?? this.source,
|
||||
status: status ?? this.status,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,18 +10,35 @@ import 'swarm_road_alert_models.dart';
|
|||
|
||||
enum SwarmCloudStatus { unavailable, confirmed, pending, failed }
|
||||
|
||||
class RoadAlertRateLimitException implements Exception {
|
||||
final int retryAfterSeconds;
|
||||
RoadAlertRateLimitException(this.retryAfterSeconds);
|
||||
@override
|
||||
String toString() =>
|
||||
'Please wait $retryAfterSeconds seconds before submitting another road alert.';
|
||||
}
|
||||
|
||||
class SwarmRoadAlertService {
|
||||
final FirebaseFirestore? _firestore;
|
||||
final FirebaseAuth? _auth;
|
||||
final Duration minReportInterval;
|
||||
final double duplicateDistanceMeters;
|
||||
final Duration duplicateWindow;
|
||||
|
||||
static int _idCounter = 0;
|
||||
|
||||
DateTime? _lastReportTime;
|
||||
final Set<String> _userVotes = {}; // Key format: '$alertId:$uid'
|
||||
|
||||
// Local cache for offline resilience and immediate zero-latency feedback
|
||||
final Map<String, SwarmReport> _localSwarmCache = {};
|
||||
|
||||
SwarmRoadAlertService({
|
||||
FirebaseFirestore? firestore,
|
||||
FirebaseAuth? auth,
|
||||
this.minReportInterval = Duration.zero,
|
||||
this.duplicateDistanceMeters = 200.0,
|
||||
this.duplicateWindow = const Duration(minutes: 15),
|
||||
}) : _firestore = firestore ?? _resolveFirestore(),
|
||||
_auth = auth ?? _resolveAuth();
|
||||
|
||||
|
|
@ -43,43 +60,184 @@ class SwarmRoadAlertService {
|
|||
|
||||
String? get _currentUid => _auth?.currentUser?.uid;
|
||||
|
||||
/// Reports a new road alert (e.g. Police Speed Trap, Road Hazard, Accident).
|
||||
/// Returns a cached report by alert ID if available.
|
||||
SwarmReport? getCachedReport(String alertId) => _localSwarmCache[alertId];
|
||||
|
||||
static Duration defaultTtlForCategory(RoadAlertCategory category) {
|
||||
switch (category) {
|
||||
case RoadAlertCategory.police:
|
||||
return const Duration(minutes: 60);
|
||||
case RoadAlertCategory.traffic:
|
||||
return const Duration(minutes: 45);
|
||||
case RoadAlertCategory.accident:
|
||||
case RoadAlertCategory.roadClosure:
|
||||
return const Duration(hours: 3);
|
||||
case RoadAlertCategory.weatherHazard:
|
||||
return const Duration(hours: 4);
|
||||
case RoadAlertCategory.hazard:
|
||||
case RoadAlertCategory.pothole:
|
||||
case RoadAlertCategory.debris:
|
||||
case RoadAlertCategory.disabledVehicle:
|
||||
case RoadAlertCategory.construction:
|
||||
case RoadAlertCategory.speedCamera:
|
||||
case RoadAlertCategory.other:
|
||||
return const Duration(hours: 2);
|
||||
}
|
||||
}
|
||||
|
||||
static String defaultDescriptionForCategory(RoadAlertCategory category) {
|
||||
switch (category) {
|
||||
case RoadAlertCategory.police:
|
||||
return 'Police speed trap reported by rider';
|
||||
case RoadAlertCategory.accident:
|
||||
return 'Traffic accident reported';
|
||||
case RoadAlertCategory.hazard:
|
||||
return 'Road hazard reported';
|
||||
case RoadAlertCategory.pothole:
|
||||
return 'Severe pothole reported on road';
|
||||
case RoadAlertCategory.debris:
|
||||
return 'Road debris reported';
|
||||
case RoadAlertCategory.disabledVehicle:
|
||||
return 'Disabled vehicle reported on roadway';
|
||||
case RoadAlertCategory.roadClosure:
|
||||
return 'Road closure reported';
|
||||
case RoadAlertCategory.construction:
|
||||
return 'Road construction reported';
|
||||
case RoadAlertCategory.traffic:
|
||||
return 'Heavy traffic reported';
|
||||
case RoadAlertCategory.weatherHazard:
|
||||
return 'Adverse weather road hazard reported';
|
||||
case RoadAlertCategory.speedCamera:
|
||||
return 'Speed enforcement camera reported';
|
||||
case RoadAlertCategory.other:
|
||||
return 'Rider safety report';
|
||||
}
|
||||
}
|
||||
|
||||
bool _isCategoryMatch(RoadAlertCategory a, RoadAlertCategory b) {
|
||||
if (a == b) return true;
|
||||
final hazardGroup = {
|
||||
RoadAlertCategory.hazard,
|
||||
RoadAlertCategory.pothole,
|
||||
RoadAlertCategory.debris,
|
||||
RoadAlertCategory.disabledVehicle,
|
||||
};
|
||||
return hazardGroup.contains(a) && hazardGroup.contains(b);
|
||||
}
|
||||
|
||||
SwarmReport? _findDuplicateAlert({
|
||||
required RoadAlertCategory category,
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
required DateTime reportedAt,
|
||||
}) {
|
||||
for (final report in _localSwarmCache.values) {
|
||||
if (report.isExpired) continue;
|
||||
if (report.status == RoadAlertStatus.cleared ||
|
||||
report.status == RoadAlertStatus.expired) {
|
||||
continue;
|
||||
}
|
||||
final age = reportedAt.difference(report.reportedAt).abs();
|
||||
if (age > duplicateWindow) continue;
|
||||
|
||||
if (!_isCategoryMatch(report.category, category)) continue;
|
||||
|
||||
final distance = Geolocator.distanceBetween(
|
||||
latitude,
|
||||
longitude,
|
||||
report.latitude,
|
||||
report.longitude,
|
||||
);
|
||||
if (distance <= duplicateDistanceMeters) {
|
||||
return report;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Checks if the given user (or current user) has already voted on this alert.
|
||||
bool hasUserVoted(String alertId, [String? voterUid]) {
|
||||
final uid = voterUid ?? _currentUid ?? 'local_rider';
|
||||
return _userVotes.contains('$alertId:$uid');
|
||||
}
|
||||
|
||||
/// Checks if the given user is eligible to vote on this alert.
|
||||
bool canUserVote(String alertId, [String? voterUid]) {
|
||||
final uid = voterUid ?? _currentUid ?? 'local_rider';
|
||||
final cached = _localSwarmCache[alertId];
|
||||
if (cached != null && cached.reporterUid == uid) {
|
||||
return false; // Reporter cannot vote on their own alert
|
||||
}
|
||||
return !_userVotes.contains('$alertId:$uid');
|
||||
}
|
||||
|
||||
/// Reports a new road alert.
|
||||
/// Saves to Firestore `/road_alerts` and caches locally.
|
||||
/// Automatically checks for duplicate alerts nearby (< 200m within 15min)
|
||||
/// and confirms existing alerts instead of duplicating.
|
||||
Future<SwarmReport> reportAlert({
|
||||
required RoadAlertCategory category,
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
String? subtype,
|
||||
String? source,
|
||||
double? heading,
|
||||
String? roadName,
|
||||
String? description,
|
||||
String? groupRideId,
|
||||
String? reporterUid,
|
||||
Duration? ttl,
|
||||
bool ignoreRateLimit = false,
|
||||
void Function(SwarmCloudStatus)? onCloudStatus,
|
||||
}) async {
|
||||
final now = DateTime.now();
|
||||
final uid = _currentUid ?? 'local_rider';
|
||||
final uid = reporterUid ?? _currentUid ?? 'local_rider';
|
||||
|
||||
// Rate Limiting
|
||||
if (!ignoreRateLimit && minReportInterval > Duration.zero && _lastReportTime != null) {
|
||||
final elapsed = now.difference(_lastReportTime!);
|
||||
if (elapsed < minReportInterval) {
|
||||
final remaining = (minReportInterval - elapsed).inSeconds + 1;
|
||||
throw RoadAlertRateLimitException(remaining);
|
||||
}
|
||||
}
|
||||
|
||||
// Duplicate detection and auto-merging
|
||||
final duplicate = _findDuplicateAlert(
|
||||
category: category,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
reportedAt: now,
|
||||
);
|
||||
|
||||
if (duplicate != null) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[SwarmRoadAlertService] Duplicate alert detected: ${duplicate.id}. Auto-confirming.');
|
||||
}
|
||||
if (duplicate.reporterUid != uid) {
|
||||
await confirmAlert(duplicate.id, voterUid: uid);
|
||||
return _localSwarmCache[duplicate.id] ?? duplicate;
|
||||
}
|
||||
return duplicate;
|
||||
}
|
||||
|
||||
_lastReportTime = now;
|
||||
|
||||
final uidSuffix = uid.length >= 5 ? uid.substring(0, 5) : uid;
|
||||
final count = ++_idCounter;
|
||||
final alertId = 'swarm_${now.millisecondsSinceEpoch}_${count}_$uidSuffix';
|
||||
|
||||
final effectiveTtl = ttl ??
|
||||
(category == RoadAlertCategory.police
|
||||
? const Duration(minutes: 60)
|
||||
: const Duration(hours: 2));
|
||||
final effectiveTtl = ttl ?? defaultTtlForCategory(category);
|
||||
final expiresAt = now.add(effectiveTtl);
|
||||
|
||||
final desc = description ??
|
||||
(category == RoadAlertCategory.police
|
||||
? 'Police speed trap reported by rider'
|
||||
: (category == RoadAlertCategory.hazard
|
||||
? 'Road hazard / debris reported'
|
||||
: (category == RoadAlertCategory.accident
|
||||
? 'Traffic accident reported'
|
||||
: 'Rider safety report')));
|
||||
final desc = description ?? defaultDescriptionForCategory(category);
|
||||
final effectiveSource = source ??
|
||||
(groupRideId != null ? 'Pack Rider Report' : 'MotorSignal Swarm');
|
||||
|
||||
final report = SwarmReport(
|
||||
id: alertId,
|
||||
category: category,
|
||||
subtype: subtype,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
heading: heading,
|
||||
|
|
@ -91,6 +249,8 @@ class SwarmRoadAlertService {
|
|||
confirmations: 1,
|
||||
dismissals: 0,
|
||||
groupRideId: groupRideId,
|
||||
source: effectiveSource,
|
||||
status: RoadAlertStatus.active,
|
||||
);
|
||||
|
||||
// Cache locally immediately
|
||||
|
|
@ -123,12 +283,37 @@ class SwarmRoadAlertService {
|
|||
return report;
|
||||
}
|
||||
|
||||
/// Trailing rider confirms an existing alert (increases confidence)
|
||||
Future<void> confirmAlert(String alertId) async {
|
||||
/// Trailing rider confirms an existing alert (increases confidence).
|
||||
/// Enforces per-user vote restrictions (no repeat votes, no self-voting).
|
||||
Future<bool> confirmAlert(String alertId, {String? voterUid}) async {
|
||||
final uid = voterUid ?? _currentUid ?? 'local_rider';
|
||||
final cached = _localSwarmCache[alertId];
|
||||
|
||||
if (cached != null && cached.reporterUid == uid && voterUid != null) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[SwarmRoadAlertService] Self-voting rejected for alert $alertId');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
final voteKey = '$alertId:$uid';
|
||||
if (_userVotes.contains(voteKey)) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[SwarmRoadAlertService] Repeat voting rejected for alert $alertId');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
_userVotes.add(voteKey);
|
||||
|
||||
if (cached != null) {
|
||||
_localSwarmCache[alertId] =
|
||||
cached.copyWith(confirmations: cached.confirmations + 1);
|
||||
final updatedConfirmations = cached.confirmations + 1;
|
||||
final newStatus = updatedConfirmations >= 2
|
||||
? RoadAlertStatus.confirmed
|
||||
: cached.status;
|
||||
_localSwarmCache[alertId] = cached.copyWith(
|
||||
confirmations: updatedConfirmations,
|
||||
status: newStatus,
|
||||
);
|
||||
}
|
||||
|
||||
final firestore = _firestore;
|
||||
|
|
@ -143,16 +328,33 @@ class SwarmRoadAlertService {
|
|||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Trailing rider reports alert is clear / no longer present
|
||||
Future<void> clearAlert(String alertId) async {
|
||||
/// Trailing rider reports alert is clear / no longer present.
|
||||
/// Automatically marks alert cleared/expired when dismissals reach threshold.
|
||||
Future<bool> clearAlert(String alertId, {String? voterUid}) async {
|
||||
final uid = voterUid ?? _currentUid ?? 'local_rider';
|
||||
final voteKey = '$alertId:$uid';
|
||||
if (_userVotes.contains(voteKey)) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[SwarmRoadAlertService] Repeat dismissal rejected for alert $alertId');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
_userVotes.add(voteKey);
|
||||
|
||||
final cached = _localSwarmCache[alertId];
|
||||
final now = DateTime.now();
|
||||
|
||||
if (cached != null) {
|
||||
final updatedDismissals = cached.dismissals + 1;
|
||||
if (updatedDismissals >= 2 && updatedDismissals >= cached.confirmations) {
|
||||
// Expire immediately
|
||||
_localSwarmCache.remove(alertId);
|
||||
_localSwarmCache[alertId] = cached.copyWith(
|
||||
dismissals: updatedDismissals,
|
||||
expiresAt: now,
|
||||
status: RoadAlertStatus.cleared,
|
||||
);
|
||||
} else {
|
||||
_localSwarmCache[alertId] =
|
||||
cached.copyWith(dismissals: updatedDismissals);
|
||||
|
|
@ -170,10 +372,10 @@ class SwarmRoadAlertService {
|
|||
final confirmations = (data['confirmations'] as num?)?.toInt() ?? 1;
|
||||
|
||||
if (dismissals + 1 >= 2 && (dismissals + 1) >= confirmations) {
|
||||
// Mark expired
|
||||
await docRef.update({
|
||||
'dismissals': FieldValue.increment(1),
|
||||
'expiresAt': DateTime.now().millisecondsSinceEpoch,
|
||||
'expiresAt': now.millisecondsSinceEpoch,
|
||||
'status': 'cleared',
|
||||
});
|
||||
} else {
|
||||
await docRef.update({
|
||||
|
|
@ -187,6 +389,28 @@ class SwarmRoadAlertService {
|
|||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Deletes alert if caller is the reporter.
|
||||
Future<bool> deleteAlert(String alertId) async {
|
||||
final uid = _currentUid ?? 'local_rider';
|
||||
final cached = _localSwarmCache[alertId];
|
||||
if (cached != null && cached.reporterUid != uid) {
|
||||
return false;
|
||||
}
|
||||
_localSwarmCache.remove(alertId);
|
||||
final firestore = _firestore;
|
||||
if (firestore != null) {
|
||||
try {
|
||||
await firestore.collection('road_alerts').doc(alertId).delete();
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[SwarmRoadAlertService] Firestore delete error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Queries all live alerts within [radiusMeters] of [latitude, longitude].
|
||||
|
|
@ -200,7 +424,9 @@ class SwarmRoadAlertService {
|
|||
|
||||
// 1. Ingest local in-memory cache
|
||||
for (var entry in _localSwarmCache.entries) {
|
||||
if (!entry.value.isExpired) {
|
||||
if (!entry.value.isExpired &&
|
||||
entry.value.status != RoadAlertStatus.cleared &&
|
||||
entry.value.status != RoadAlertStatus.expired) {
|
||||
candidates[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
|
|
@ -226,6 +452,11 @@ class SwarmRoadAlertService {
|
|||
for (var doc in querySnap.docs) {
|
||||
try {
|
||||
final report = SwarmReport.fromJson(doc.data());
|
||||
// Filter out cleared/expired documents
|
||||
if (report.status == RoadAlertStatus.cleared ||
|
||||
report.status == RoadAlertStatus.expired) {
|
||||
continue;
|
||||
}
|
||||
// Client-side bounding box filter
|
||||
if (report.latitude >= minLat &&
|
||||
report.latitude <= maxLat &&
|
||||
|
|
@ -247,6 +478,10 @@ class SwarmRoadAlertService {
|
|||
final List<RoadAlert> results = [];
|
||||
for (var report in candidates.values) {
|
||||
if (report.isExpired) continue;
|
||||
if (report.status == RoadAlertStatus.cleared ||
|
||||
report.status == RoadAlertStatus.expired) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final dist = Geolocator.distanceBetween(
|
||||
latitude,
|
||||
|
|
@ -265,8 +500,10 @@ class SwarmRoadAlertService {
|
|||
return results;
|
||||
}
|
||||
|
||||
/// Clears local in-memory cache (for testing or reset)
|
||||
/// Clears local in-memory cache and voting records (for testing or reset)
|
||||
void clearLocalCache() {
|
||||
_localSwarmCache.clear();
|
||||
_userVotes.clear();
|
||||
_lastReportTime = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 '../swarm_road_alert_service.dart';
|
||||
|
||||
class _ReportItem {
|
||||
final RoadAlertCategory category;
|
||||
final String? subtype;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const _ReportItem({
|
||||
required this.category,
|
||||
this.subtype,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
});
|
||||
}
|
||||
|
||||
const List<_ReportItem> _kReportCategories = [
|
||||
_ReportItem(
|
||||
category: RoadAlertCategory.police,
|
||||
subtype: 'speed_trap',
|
||||
title: 'Police',
|
||||
subtitle: 'Speed trap / radar',
|
||||
icon: Icons.local_police,
|
||||
color: Colors.blueAccent,
|
||||
),
|
||||
_ReportItem(
|
||||
category: RoadAlertCategory.hazard,
|
||||
subtype: 'slick_surface',
|
||||
title: 'Road Hazard',
|
||||
subtitle: 'Gravel, slick, oil',
|
||||
icon: Icons.warning_amber_rounded,
|
||||
color: Colors.orangeAccent,
|
||||
),
|
||||
_ReportItem(
|
||||
category: RoadAlertCategory.pothole,
|
||||
subtype: 'pothole',
|
||||
title: 'Pothole',
|
||||
subtitle: 'Severe pavement break',
|
||||
icon: Icons.radio_button_checked,
|
||||
color: Color(0xFFFFB300),
|
||||
),
|
||||
_ReportItem(
|
||||
category: RoadAlertCategory.debris,
|
||||
subtype: 'debris',
|
||||
title: 'Road Debris',
|
||||
subtitle: 'Tire tread, object in road',
|
||||
icon: Icons.scatter_plot,
|
||||
color: Color(0xFFFF7043),
|
||||
),
|
||||
_ReportItem(
|
||||
category: RoadAlertCategory.accident,
|
||||
subtype: 'accident',
|
||||
title: 'Accident',
|
||||
subtitle: 'Crash scene ahead',
|
||||
icon: Icons.car_crash,
|
||||
color: Colors.redAccent,
|
||||
),
|
||||
_ReportItem(
|
||||
category: RoadAlertCategory.disabledVehicle,
|
||||
subtype: 'disabled_vehicle',
|
||||
title: 'Disabled Car',
|
||||
subtitle: 'Vehicle stopped in lane/shoulder',
|
||||
icon: Icons.car_repair,
|
||||
color: Color(0xFFFF5252),
|
||||
),
|
||||
_ReportItem(
|
||||
category: RoadAlertCategory.roadClosure,
|
||||
subtype: 'road_closure',
|
||||
title: 'Road Closed',
|
||||
subtitle: 'Passage completely blocked',
|
||||
icon: Icons.do_not_disturb_on,
|
||||
color: Colors.deepOrange,
|
||||
),
|
||||
_ReportItem(
|
||||
category: RoadAlertCategory.construction,
|
||||
subtype: 'construction',
|
||||
title: 'Construction',
|
||||
subtitle: 'Work zone / road crew',
|
||||
icon: Icons.engineering,
|
||||
color: Colors.amber,
|
||||
),
|
||||
_ReportItem(
|
||||
category: RoadAlertCategory.traffic,
|
||||
subtype: 'heavy_traffic',
|
||||
title: 'Heavy Traffic',
|
||||
subtitle: 'Standstill or congestion',
|
||||
icon: Icons.traffic,
|
||||
color: Colors.yellowAccent,
|
||||
),
|
||||
_ReportItem(
|
||||
category: RoadAlertCategory.weatherHazard,
|
||||
subtype: 'weather_hazard',
|
||||
title: 'Weather Hazard',
|
||||
subtitle: 'Water, ice, dense fog',
|
||||
icon: Icons.thunderstorm,
|
||||
color: Color(0xFF4FC3F7),
|
||||
),
|
||||
];
|
||||
|
||||
class QuickSwarmReportModal extends ConsumerStatefulWidget {
|
||||
const QuickSwarmReportModal({super.key});
|
||||
|
||||
|
|
@ -30,10 +131,31 @@ class QuickSwarmReportModal extends ConsumerStatefulWidget {
|
|||
|
||||
class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
||||
bool _submitting = false;
|
||||
bool _isMovingFast = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkMotionSafety();
|
||||
}
|
||||
|
||||
Future<void> _checkMotionSafety() async {
|
||||
try {
|
||||
final pos = await Geolocator.getLastKnownPosition();
|
||||
if (pos != null && pos.speed > 4.47) { // > 10 MPH (4.47 m/s)
|
||||
if (mounted) {
|
||||
setState(() => _isMovingFast = true);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maxHeight = MediaQuery.of(context).size.height * 0.85;
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.darkSurface,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
|
|
@ -41,7 +163,9 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
|||
top: BorderSide(color: AppColors.darkOutline, width: 1.5),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
|
|
@ -56,7 +180,7 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
|||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.campaign, color: AppColors.cyberCyan, size: 24),
|
||||
|
|
@ -79,112 +203,125 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
|||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Instant 1-tap broadcast to all nearby riders & pack members.',
|
||||
'1-tap broadcast to all nearby riders & pack members.',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 12,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// 1. POLICE / SPEED TRAP
|
||||
_buildActionButton(
|
||||
context: context,
|
||||
category: RoadAlertCategory.police,
|
||||
title: 'POLICE / SPEED TRAP',
|
||||
subtitle: 'Radar, laser, or patrol car spotted',
|
||||
icon: Icons.local_police,
|
||||
color: Colors.blueAccent,
|
||||
if (_isMovingFast) ...[
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.amber, width: 1),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: Colors.amber, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Movement detected. Pull over safely before reporting while riding.',
|
||||
style: TextStyle(
|
||||
color: Colors.amber,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 2. ROAD HAZARD / GRAVEL
|
||||
_buildActionButton(
|
||||
context: context,
|
||||
category: RoadAlertCategory.hazard,
|
||||
title: 'HAZARD / GRAVEL / DEBRIS',
|
||||
subtitle: 'Gravel in corner, slick surface, road debris',
|
||||
icon: Icons.warning_amber_rounded,
|
||||
color: Colors.orangeAccent,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 3. ACCIDENT / ROAD CLOSED
|
||||
_buildActionButton(
|
||||
context: context,
|
||||
category: RoadAlertCategory.accident,
|
||||
title: 'ACCIDENT / CRASH AHEAD',
|
||||
subtitle: 'Crash or blocked road ahead',
|
||||
icon: Icons.car_crash,
|
||||
color: Colors.redAccent,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 14),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: _kReportCategories.length,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 2.2,
|
||||
crossAxisSpacing: 10,
|
||||
mainAxisSpacing: 10,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final item = _kReportCategories[index];
|
||||
return _buildGridCard(item);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButton({
|
||||
required BuildContext context,
|
||||
required RoadAlertCategory category,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required IconData icon,
|
||||
required Color color,
|
||||
}) {
|
||||
Widget _buildGridCard(_ReportItem item) {
|
||||
return ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: color.withValues(alpha: 0.15),
|
||||
backgroundColor: item.color.withValues(alpha: 0.12),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: color, width: 1.5),
|
||||
side: BorderSide(color: item.color.withValues(alpha: 0.7), width: 1.5),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
onPressed: _submitting ? null : () => _handleReport(category),
|
||||
onPressed: _submitting ? null : () => _handleReport(item.category, subtype: item.subtype),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
padding: const EdgeInsets.all(7),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.25),
|
||||
color: item.color.withValues(alpha: 0.22),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 28),
|
||||
child: Icon(item.icon, color: item.color, size: 22),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
item.title,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 14,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(fontSize: 11, color: Colors.white70),
|
||||
item.subtitle,
|
||||
style: const TextStyle(fontSize: 10, color: Colors.white70),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.arrow_forward_ios, color: Colors.white38, size: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleReport(RoadAlertCategory category) async {
|
||||
Future<void> _handleReport(RoadAlertCategory category, {String? subtype}) async {
|
||||
if (_submitting) return;
|
||||
setState(() => _submitting = true);
|
||||
HapticFeedback.heavyImpact();
|
||||
|
|
@ -193,6 +330,7 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
|||
final activeRide = ref.read(activeUserGroupRideProvider);
|
||||
final swarmService = ref.read(swarmRoadAlertServiceProvider);
|
||||
final alertService = ref.read(roadAlertServiceProvider);
|
||||
|
||||
// 1. Acquire current position
|
||||
Position? pos;
|
||||
try {
|
||||
|
|
@ -209,7 +347,6 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
|||
);
|
||||
}
|
||||
|
||||
// Closing the sheet before location resolves cancels the report.
|
||||
if (!mounted || ModalRoute.of(context)?.isCurrent != true) return;
|
||||
|
||||
final lat = pos?.latitude ?? 0.0;
|
||||
|
|
@ -226,11 +363,11 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
|||
return;
|
||||
}
|
||||
|
||||
// 2. Check active group ride
|
||||
// 3. Dispatch to Swarm Service
|
||||
// 2. Dispatch to Swarm Service
|
||||
var cloudStatus = SwarmCloudStatus.unavailable;
|
||||
final report = await swarmService.reportAlert(
|
||||
category: category,
|
||||
subtype: subtype,
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
heading: heading,
|
||||
|
|
@ -238,7 +375,6 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
|||
onCloudStatus: (status) => cloudStatus = status,
|
||||
);
|
||||
|
||||
// The sheet or its ProviderScope may have gone away during submission.
|
||||
if (!mounted || ModalRoute.of(context)?.isCurrent != true) return;
|
||||
alertService.simulateAlert(report.toRoadAlert());
|
||||
Navigator.of(context).pop();
|
||||
|
|
@ -252,13 +388,13 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
|||
Expanded(
|
||||
child: Text(switch (cloudStatus) {
|
||||
SwarmCloudStatus.confirmed =>
|
||||
'${category.displayName} added to local alerts and saved to cloud.',
|
||||
'${category.displayName} reported to community and saved to cloud.',
|
||||
SwarmCloudStatus.pending =>
|
||||
'${category.displayName} added locally; cloud delivery pending.',
|
||||
'${category.displayName} reported locally; cloud delivery pending.',
|
||||
SwarmCloudStatus.failed =>
|
||||
'${category.displayName} added locally; cloud delivery failed.',
|
||||
'${category.displayName} reported locally; cloud delivery failed.',
|
||||
SwarmCloudStatus.unavailable =>
|
||||
'${category.displayName} added locally; cloud unavailable.',
|
||||
'${category.displayName} reported locally; operating offline.',
|
||||
}, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
|
|
@ -267,6 +403,15 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
|||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
} on RoadAlertRateLimitException catch (rateErr) {
|
||||
if (mounted && ModalRoute.of(context)?.isCurrent == true) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(rateErr.toString()),
|
||||
backgroundColor: Colors.orangeAccent,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted && ModalRoute.of(context)?.isCurrent == true) {
|
||||
messenger.showSnackBar(
|
||||
|
|
@ -296,7 +441,7 @@ class QuickSwarmReportButton extends StatelessWidget {
|
|||
icon: const Icon(Icons.campaign, color: Colors.orangeAccent, size: 20),
|
||||
tooltip: 'Report Road Hazard / Police',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
constraints: const BoxConstraints(minWidth: 48, minHeight: 48),
|
||||
onPressed: () => QuickSwarmReportModal.show(context),
|
||||
);
|
||||
}
|
||||
|
|
@ -304,19 +449,18 @@ class QuickSwarmReportButton extends StatelessWidget {
|
|||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.orangeAccent.withValues(alpha: 0.15),
|
||||
foregroundColor: Colors.orangeAccent,
|
||||
side: const BorderSide(color: Colors.orangeAccent, width: 1),
|
||||
side: const BorderSide(color: Colors.orangeAccent, width: 1.5),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
minimumSize: const Size(48, 48),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
icon: const Icon(Icons.campaign, size: 16),
|
||||
icon: const Icon(Icons.campaign, size: 18),
|
||||
label: const Text(
|
||||
'REPORT',
|
||||
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
onPressed: () => QuickSwarmReportModal.show(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
await service.clearAlert(report.id);
|
||||
await service.clearAlert(report.id, voterUid: 'rider_a');
|
||||
var doc = await fakeFirestore.collection('road_alerts').doc(report.id).get();
|
||||
expect(doc.data()!['dismissals'], 1);
|
||||
|
||||
// 2nd clear: dismissals becomes 2, which is >= confirmations (1) -> expires immediately
|
||||
await service.clearAlert(report.id);
|
||||
await service.clearAlert(report.id, voterUid: 'rider_b');
|
||||
doc = await fakeFirestore.collection('road_alerts').doc(report.id).get();
|
||||
expect(doc.data()!['dismissals'], 2);
|
||||
final expiresAt = doc.data()!['expiresAt'] as int;
|
||||
|
|
@ -340,4 +340,238 @@ void main() {
|
|||
expect(activeProviders.any((p) => p.id == 'swarm_alerts'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('Native Road Alert Categories & Verification Trust Tests', () {
|
||||
test('all 10 native categories map properly to RoadAlert', () {
|
||||
final now = DateTime.now();
|
||||
final expires = now.add(const Duration(hours: 2));
|
||||
|
||||
final categories = [
|
||||
RoadAlertCategory.police,
|
||||
RoadAlertCategory.accident,
|
||||
RoadAlertCategory.hazard,
|
||||
RoadAlertCategory.pothole,
|
||||
RoadAlertCategory.debris,
|
||||
RoadAlertCategory.disabledVehicle,
|
||||
RoadAlertCategory.roadClosure,
|
||||
RoadAlertCategory.construction,
|
||||
RoadAlertCategory.traffic,
|
||||
RoadAlertCategory.weatherHazard,
|
||||
];
|
||||
|
||||
for (final cat in categories) {
|
||||
final report = SwarmReport(
|
||||
id: 'report_${cat.name}',
|
||||
category: cat,
|
||||
subtype: 'severe_${cat.name}',
|
||||
latitude: 35.0,
|
||||
longitude: -84.0,
|
||||
description: 'Rider alert for ${cat.name}',
|
||||
reporterUid: 'rider_1',
|
||||
reportedAt: now,
|
||||
expiresAt: expires,
|
||||
confirmations: 1,
|
||||
dismissals: 0,
|
||||
source: 'MotorSignal Swarm',
|
||||
status: RoadAlertStatus.active,
|
||||
);
|
||||
|
||||
final alert = report.toRoadAlert();
|
||||
expect(alert.category, cat);
|
||||
expect(alert.subtype, 'severe_${cat.name}');
|
||||
expect(alert.isCommunityConfirmed, isFalse);
|
||||
expect(alert.confirmations, 1);
|
||||
expect(alert.dismissals, 0);
|
||||
expect(alert.status, RoadAlertStatus.active);
|
||||
}
|
||||
});
|
||||
|
||||
test('isCommunityConfirmed requires at least 2 confirmations', () {
|
||||
final now = DateTime.now();
|
||||
final expires = now.add(const Duration(hours: 2));
|
||||
|
||||
final unconfirmed = SwarmReport(
|
||||
id: 'rep_1',
|
||||
category: RoadAlertCategory.pothole,
|
||||
latitude: 35.0,
|
||||
longitude: -84.0,
|
||||
description: 'Pot hole test',
|
||||
reporterUid: 'rider_1',
|
||||
reportedAt: now,
|
||||
expiresAt: expires,
|
||||
confirmations: 1,
|
||||
dismissals: 0,
|
||||
).toRoadAlert();
|
||||
expect(unconfirmed.isCommunityConfirmed, isFalse);
|
||||
|
||||
final confirmed = SwarmReport(
|
||||
id: 'rep_2',
|
||||
category: RoadAlertCategory.pothole,
|
||||
latitude: 35.0,
|
||||
longitude: -84.0,
|
||||
description: 'Pot hole test',
|
||||
reporterUid: 'rider_1',
|
||||
reportedAt: now,
|
||||
expiresAt: expires,
|
||||
confirmations: 2,
|
||||
dismissals: 0,
|
||||
).toRoadAlert();
|
||||
expect(confirmed.isCommunityConfirmed, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('Rate Limiting & Duplicate Detection Tests', () {
|
||||
late FakeFirebaseFirestore fakeFirestore;
|
||||
|
||||
setUp(() {
|
||||
fakeFirestore = FakeFirebaseFirestore();
|
||||
});
|
||||
|
||||
test('enforces rate limit interval and throws RoadAlertRateLimitException', () async {
|
||||
final rateLimitedService = SwarmRoadAlertService(
|
||||
firestore: fakeFirestore,
|
||||
minReportInterval: const Duration(seconds: 15),
|
||||
);
|
||||
|
||||
// First report succeeds
|
||||
final first = await rateLimitedService.reportAlert(
|
||||
category: RoadAlertCategory.police,
|
||||
latitude: 35.0,
|
||||
longitude: -84.0,
|
||||
);
|
||||
expect(first.id, startsWith('swarm_'));
|
||||
|
||||
// Second immediate report at distant location throws rate limit exception
|
||||
expect(
|
||||
() => rateLimitedService.reportAlert(
|
||||
category: RoadAlertCategory.accident,
|
||||
latitude: 36.0,
|
||||
longitude: -85.0,
|
||||
),
|
||||
throwsA(isA<RoadAlertRateLimitException>().having(
|
||||
(e) => e.retryAfterSeconds,
|
||||
'retryAfterSeconds',
|
||||
inInclusiveRange(1, 15),
|
||||
)),
|
||||
);
|
||||
|
||||
// With ignoreRateLimit: true, report succeeds
|
||||
final bypassReport = await rateLimitedService.reportAlert(
|
||||
category: RoadAlertCategory.accident,
|
||||
latitude: 36.0,
|
||||
longitude: -85.0,
|
||||
ignoreRateLimit: true,
|
||||
);
|
||||
expect(bypassReport.id, startsWith('swarm_'));
|
||||
});
|
||||
|
||||
test('duplicate reports within 200m and 15min auto-confirm existing alert', () async {
|
||||
final service = SwarmRoadAlertService(
|
||||
firestore: fakeFirestore,
|
||||
minReportInterval: Duration.zero,
|
||||
duplicateDistanceMeters: 200.0,
|
||||
duplicateWindow: const Duration(minutes: 15),
|
||||
);
|
||||
|
||||
// Rider 1 reports a pothole at lat 35.0000, lng -84.0000
|
||||
final first = await service.reportAlert(
|
||||
category: RoadAlertCategory.pothole,
|
||||
latitude: 35.0000,
|
||||
longitude: -84.0000,
|
||||
description: 'Deep rim bender',
|
||||
reporterUid: 'rider_1',
|
||||
);
|
||||
expect(first.confirmations, 1);
|
||||
|
||||
// Rider 2 reports a pothole 50m away (lat 35.0004, lng -84.0000)
|
||||
final duplicateMerged = await service.reportAlert(
|
||||
category: RoadAlertCategory.pothole,
|
||||
latitude: 35.0004,
|
||||
longitude: -84.0000,
|
||||
description: 'Bad crater in right lane',
|
||||
reporterUid: 'rider_2',
|
||||
);
|
||||
|
||||
// Auto-confirmed: ID matches original, confirmations incremented to 2
|
||||
expect(duplicateMerged.id, first.id);
|
||||
expect(duplicateMerged.confirmations, 2);
|
||||
|
||||
// Check Firestore doc has 2 confirmations
|
||||
final doc = await fakeFirestore.collection('road_alerts').doc(first.id).get();
|
||||
expect(doc.data()!['confirmations'], 2);
|
||||
|
||||
// Distant report (500m away: lat 35.0050) creates a separate alert
|
||||
final separate = await service.reportAlert(
|
||||
category: RoadAlertCategory.pothole,
|
||||
latitude: 35.0050,
|
||||
longitude: -84.0000,
|
||||
);
|
||||
expect(separate.id, isNot(first.id));
|
||||
expect(separate.confirmations, 1);
|
||||
});
|
||||
|
||||
test('per-user voting restrictions prevent self-voting and repeat voting', () async {
|
||||
final service = SwarmRoadAlertService(firestore: fakeFirestore);
|
||||
|
||||
final report = await service.reportAlert(
|
||||
category: RoadAlertCategory.debris,
|
||||
latitude: 35.0,
|
||||
longitude: -84.0,
|
||||
);
|
||||
|
||||
// Reporter cannot vote on their own alert
|
||||
expect(service.canUserVote(report.id, report.reporterUid), isFalse);
|
||||
final selfVote = await service.confirmAlert(report.id, voterUid: report.reporterUid);
|
||||
expect(selfVote, isFalse);
|
||||
|
||||
// Another user can vote once
|
||||
const otherUser = 'rider_trail_42';
|
||||
expect(service.canUserVote(report.id, otherUser), isTrue);
|
||||
expect(service.hasUserVoted(report.id, otherUser), isFalse);
|
||||
|
||||
final firstVote = await service.confirmAlert(report.id, voterUid: otherUser);
|
||||
expect(firstVote, isTrue);
|
||||
expect(service.hasUserVoted(report.id, otherUser), isTrue);
|
||||
expect(service.canUserVote(report.id, otherUser), isFalse);
|
||||
|
||||
// Repeat vote is rejected
|
||||
final repeatVote = await service.confirmAlert(report.id, voterUid: otherUser);
|
||||
expect(repeatVote, isFalse);
|
||||
});
|
||||
|
||||
test('cleared alerts are marked cleared and pruned from nearby queries', () async {
|
||||
final service = SwarmRoadAlertService(firestore: fakeFirestore);
|
||||
|
||||
final report = await service.reportAlert(
|
||||
category: RoadAlertCategory.hazard,
|
||||
latitude: 35.1,
|
||||
longitude: -84.1,
|
||||
);
|
||||
|
||||
// Query finds it while active
|
||||
var nearby = await service.getAlertsNear(
|
||||
latitude: 35.1,
|
||||
longitude: -84.1,
|
||||
radiusMeters: 1000,
|
||||
);
|
||||
expect(nearby.length, 1);
|
||||
|
||||
// Two dismissals from different riders
|
||||
await service.clearAlert(report.id, voterUid: 'voter_a');
|
||||
await service.clearAlert(report.id, voterUid: 'voter_b');
|
||||
|
||||
// Now marked cleared / expired
|
||||
final cached = service.getCachedReport(report.id);
|
||||
expect(cached, isNotNull);
|
||||
expect(cached!.status, RoadAlertStatus.cleared);
|
||||
|
||||
// Query prunes cleared alerts
|
||||
nearby = await service.getAlertsNear(
|
||||
latitude: 35.1,
|
||||
longitude: -84.1,
|
||||
radiusMeters: 1000,
|
||||
);
|
||||
expect(nearby, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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