MotorSignal-App/lib/services/alerts/waze_road_alert_provider.dart

334 lines
10 KiB
Dart

import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'road_alert_models.dart';
import 'road_alert_provider_interface.dart';
/// Real-time Waze Police Alerts provider via OpenWebNinja.
///
/// Queries OpenWebNinja's Waze REST endpoint exclusively for police alerts
/// (`alert_types=POLICE`, `max_jams=0`) around the rider's coordinate.
class WazeRoadAlertProvider implements RoadAlertProvider {
final http.Client _client;
final String apiHost;
WazeRoadAlertProvider({
http.Client? client,
String? apiHost,
}) : _client = client ?? http.Client(),
apiHost = apiHost ??
const String.fromEnvironment(
'WAZE_API_HOST',
defaultValue: String.fromEnvironment(
'OPENWEBNINJA_API_HOST',
defaultValue: 'api.openwebninja.com',
),
);
@override
String get id => 'waze';
@override
String get name => 'Waze Police Alerts';
@override
Set<RoadAlertCategory> get supportedCategories => const {
RoadAlertCategory.police,
};
@override
Future<RoadAlertProviderStatus> testConnection(String apiKey) async {
final key = apiKey.trim();
if (key.isEmpty) {
return RoadAlertProviderStatus.notConfigured;
}
try {
// Diagnostic check: test point around downtown Los Angeles with max_alerts=1
final uri = Uri.https(
apiHost,
'/waze/alerts-and-jams',
{
'center': '34.0522,-118.2437',
'radius': '5.0',
'radius_units': 'KM',
'max_alerts': '1',
'max_jams': '0',
'alert_types': 'POLICE',
},
);
final response = await _client.get(
uri,
headers: {'x-api-key': key},
).timeout(const Duration(seconds: 6));
if (response.statusCode == 200) {
return RoadAlertProviderStatus.connected;
} else if (response.statusCode == 401 || response.statusCode == 403) {
return RoadAlertProviderStatus.authError;
} else if (response.statusCode == 429) {
return RoadAlertProviderStatus.rateLimited;
}
return RoadAlertProviderStatus.networkError;
} catch (_) {
return RoadAlertProviderStatus.networkError;
}
}
@override
Future<List<RoadAlert>> getAlerts({
required double latitude,
required double longitude,
required double radiusMeters,
String? apiKey,
}) async {
final key = apiKey?.trim();
if (key == null || key.isEmpty) {
return [];
}
try {
final radiusKm = (radiusMeters / 1000.0).clamp(1.0, 50.0).toStringAsFixed(1);
final uri = Uri.https(
apiHost,
'/waze/alerts-and-jams',
{
'center': '$latitude,$longitude',
'radius': radiusKm,
'radius_units': 'KM',
'max_alerts': '25',
'max_jams': '0',
'alert_types': 'POLICE',
},
);
final response = await _client.get(
uri,
headers: {'x-api-key': key},
).timeout(const Duration(seconds: 8));
if (response.statusCode != 200) {
if (kDebugMode) {
debugPrint('[WazeRoadAlertProvider] API response error ${response.statusCode}: ${response.body}');
}
return [];
}
final data = json.decode(response.body);
List<dynamic> alertsJson = [];
if (data is Map<String, dynamic>) {
if (data['data'] is Map<String, dynamic> &&
data['data']['alerts'] is List) {
alertsJson = data['data']['alerts'] as List;
} else if (data['alerts'] is List) {
alertsJson = data['alerts'] as List;
}
}
final List<RoadAlert> alerts = [];
for (var item in alertsJson) {
if (item is Map<String, dynamic>) {
try {
final alert = parseWazeItem(item);
if (alert != null) {
alerts.add(alert);
}
} catch (e) {
if (kDebugMode) {
debugPrint('[WazeRoadAlertProvider] Item parse error: $e');
}
}
}
}
return alerts;
} catch (e) {
if (kDebugMode) {
debugPrint('[WazeRoadAlertProvider] Request error: $e');
}
return [];
}
}
/// Fetches real-time Waze police alerts inside a geographic bounding box
/// (e.g. for map viewport queries using bottom_left and top_right).
Future<List<RoadAlert>> getAlertsInBoundingBox({
required double bottomLat,
required double bottomLng,
required double topLat,
required double topLng,
String? apiKey,
int maxAlerts = 25,
}) async {
final key = apiKey?.trim();
if (key == null || key.isEmpty) {
return [];
}
try {
final uri = Uri.https(
apiHost,
'/waze/alerts-and-jams',
{
'bottom_left': '$bottomLat,$bottomLng',
'top_right': '$topLat,$topLng',
'max_alerts': '$maxAlerts',
'max_jams': '0',
'alert_types': 'POLICE',
},
);
final response = await _client.get(
uri,
headers: {'x-api-key': key},
).timeout(const Duration(seconds: 8));
if (response.statusCode != 200) {
if (kDebugMode) {
debugPrint('[WazeRoadAlertProvider] Bounding box API error ${response.statusCode}: ${response.body}');
}
return [];
}
final data = json.decode(response.body);
List<dynamic> alertsJson = [];
if (data is Map<String, dynamic>) {
if (data['data'] is Map<String, dynamic> &&
data['data']['alerts'] is List) {
alertsJson = data['data']['alerts'] as List;
} else if (data['alerts'] is List) {
alertsJson = data['alerts'] as List;
}
}
final List<RoadAlert> alerts = [];
for (var item in alertsJson) {
if (item is Map<String, dynamic>) {
try {
final alert = parseWazeItem(item);
if (alert != null) {
alerts.add(alert);
}
} catch (e) {
if (kDebugMode) {
debugPrint('[WazeRoadAlertProvider] Item parse error: $e');
}
}
}
}
return alerts;
} catch (e) {
if (kDebugMode) {
debugPrint('[WazeRoadAlertProvider] Bounding box request error: $e');
}
return [];
}
}
/// Parses a Waze alert item into a [RoadAlert] strictly for police enforcement.
RoadAlert? parseWazeItem(Map<String, dynamic> item) {
// Enforce strict category filter: TYPE: POLICE only
final typeStr = (item['type'] ?? '').toString().toUpperCase().trim();
if (typeStr != 'POLICE') {
return null;
}
final rawLat = item['latitude'];
final rawLng = item['longitude'];
if (rawLat == null || rawLng == null) return null;
final lat = (rawLat as num).toDouble();
final lng = (rawLng as num).toDouble();
final alertId = item['alert_id']?.toString() ?? 'waze_${item.hashCode}';
final subtype = item['subtype']?.toString().toUpperCase().trim();
final street = item['street']?.toString().trim();
final city = item['city']?.toString().trim();
final rawDesc = item['description']?.toString().trim();
final RoadAlertSeverity severity;
final String description;
if (subtype == 'POLICE_HIDDEN' || subtype == 'POLICE_HIDING') {
severity = RoadAlertSeverity.high;
description = (street != null && street.isNotEmpty)
? 'Hidden police speed trap reported on $street'
: 'Hidden police speed trap reported';
} else if (subtype == 'POLICE_WITH_MOBILE_CAMERA' ||
subtype == 'POLICE_WITH_CAMERA' ||
subtype == 'POLICE_CAMERA') {
severity = RoadAlertSeverity.high;
description = (street != null && street.isNotEmpty)
? 'Police speed trap with camera reported on $street'
: 'Police speed trap with camera reported';
} else if (subtype == 'POLICE_VISIBLE') {
severity = RoadAlertSeverity.medium;
description = (street != null && street.isNotEmpty)
? 'Police speed trap reported on $street'
: 'Police speed trap reported';
} else if (subtype == 'POLICE_ON_OTHER_SIDE' ||
(subtype != null && subtype.contains('OTHER_SIDE'))) {
severity = RoadAlertSeverity.medium;
description = (street != null && street.isNotEmpty)
? 'Police reported on opposite side of $street'
: 'Police reported on opposite side';
} else if (rawDesc != null && rawDesc.isNotEmpty) {
severity = RoadAlertSeverity.medium;
description = rawDesc;
} else {
severity = RoadAlertSeverity.medium;
description = (street != null && street.isNotEmpty)
? 'Police reported on $street'
: 'Police reported in area';
}
String? roadName;
if (street != null && street.isNotEmpty) {
roadName = (city != null && city.isNotEmpty) ? '$street, $city' : street;
} else if (city != null && city.isNotEmpty) {
roadName = city;
}
final timeStr = item['publish_datetime_utc']?.toString();
final reportedAt = timeStr != null
? (DateTime.tryParse(timeStr)?.toLocal() ?? DateTime.now())
: DateTime.now();
final confNum = (item['alert_confidence'] as num?)?.toDouble();
final relNum = (item['alert_reliability'] as num?)?.toDouble();
double confidence = 0.8;
if (confNum != null && confNum > 0) {
confidence = (confNum / 5.0).clamp(0.2, 1.0);
} else if (relNum != null && relNum > 0) {
confidence = (relNum / 10.0).clamp(0.2, 1.0);
}
final thumbsUp = (item['num_thumbs_up'] as num?)?.toInt() ?? 0;
if (thumbsUp > 0) {
confidence = (confidence + 0.05 * thumbsUp).clamp(0.2, 1.0);
}
return RoadAlert(
id: 'waze_$alertId',
providerId: 'waze',
category: RoadAlertCategory.police,
latitude: lat,
longitude: lng,
roadName: roadName,
description: description,
reportedAt: reportedAt,
lastUpdated: DateTime.now(),
expiresAt: reportedAt.add(const Duration(minutes: 60)),
confidence: confidence,
severity: severity,
source: 'Waze',
sourceUrl: 'https://www.waze.com',
sourceType: RoadAlertSourceType.providerReported,
);
}
}