330 lines
10 KiB
Dart
330 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';
|
|
|
|
/// Optional custom Waze Police Alerts provider for user-configured REST endpoints.
|
|
///
|
|
/// Requires a user-provided custom `apiHost` or `WAZE_API_HOST` environment variable.
|
|
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: '',
|
|
);
|
|
|
|
@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 || apiHost.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 || apiHost.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,
|
|
);
|
|
}
|
|
}
|