305 lines
11 KiB
Dart
305 lines
11 KiB
Dart
import 'dart:convert';
|
|
import 'dart:math' as math;
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'road_alert_models.dart';
|
|
import 'road_alert_provider_interface.dart';
|
|
|
|
/// Client provider for Zyla Labs Waze Data API #4814 via MotorSignal FastAPI backend.
|
|
///
|
|
/// Credential isolation: The client NEVER holds or transmits the Zyla API key.
|
|
/// All requests route through the MotorSignal backend adapter.
|
|
class ZylaRoadAlertProvider implements RoadAlertProvider {
|
|
final http.Client _client;
|
|
final String backendBaseUrl;
|
|
final Future<String?> Function()? getAuthToken;
|
|
|
|
ZylaRoadAlertProvider({
|
|
http.Client? client,
|
|
String? backendBaseUrl,
|
|
this.getAuthToken,
|
|
}) : _client = client ?? http.Client(),
|
|
backendBaseUrl = backendBaseUrl ?? _resolveDefaultBaseUrl();
|
|
|
|
static String _resolveDefaultBaseUrl() {
|
|
const apiUrl = String.fromEnvironment('MOTORSIGNAL_API_URL');
|
|
if (apiUrl.isNotEmpty) return apiUrl;
|
|
const apiBaseUrl = String.fromEnvironment('MOTORSIGNAL_API_BASE_URL');
|
|
if (apiBaseUrl.isNotEmpty) return apiBaseUrl;
|
|
return 'https://api.motorsignal.app';
|
|
}
|
|
|
|
@override
|
|
String get id => 'zyla_waze_4814';
|
|
|
|
@override
|
|
String get name => 'Zyla Waze Data #4814';
|
|
|
|
@override
|
|
Set<RoadAlertCategory> get supportedCategories => const {
|
|
RoadAlertCategory.police,
|
|
RoadAlertCategory.accident,
|
|
RoadAlertCategory.hazard,
|
|
RoadAlertCategory.roadClosure,
|
|
RoadAlertCategory.construction,
|
|
RoadAlertCategory.traffic,
|
|
RoadAlertCategory.pothole,
|
|
RoadAlertCategory.debris,
|
|
RoadAlertCategory.weatherHazard,
|
|
RoadAlertCategory.other,
|
|
};
|
|
|
|
int _lastRawCount = 0;
|
|
int get lastRawCount => _lastRawCount;
|
|
|
|
RoadAlertProviderStatus _lastStatus = RoadAlertProviderStatus.notConfigured;
|
|
RoadAlertProviderStatus get lastStatus => _lastStatus;
|
|
|
|
String? _lastError;
|
|
String? get lastError => _lastError;
|
|
|
|
bool _lastWasLive = true;
|
|
bool get lastWasLive => _lastWasLive;
|
|
|
|
Future<Map<String, String>> _buildHeaders({String? apiKey}) async {
|
|
final headers = <String, String>{
|
|
'Accept': 'application/json',
|
|
};
|
|
if (getAuthToken != null) {
|
|
try {
|
|
final token = await getAuthToken!();
|
|
if (token != null && token.isNotEmpty) {
|
|
headers['Authorization'] = 'Bearer $token';
|
|
return headers;
|
|
}
|
|
} catch (e) {
|
|
if (kDebugMode) {
|
|
debugPrint('[ZylaRoadAlertProvider] Error resolving auth token: $e');
|
|
}
|
|
}
|
|
}
|
|
if (apiKey != null && apiKey.isNotEmpty) {
|
|
headers['Authorization'] = 'Bearer $apiKey';
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
@override
|
|
Future<RoadAlertProviderStatus> testConnection(String apiKey) async {
|
|
try {
|
|
final headers = await _buildHeaders(apiKey: apiKey);
|
|
if (getAuthToken != null && !headers.containsKey('Authorization')) {
|
|
if (kDebugMode) {
|
|
debugPrint('[ZylaRoadAlertProvider] testConnection: Missing Authorization token (auth token returned null/empty and no API key provided)');
|
|
}
|
|
_lastStatus = RoadAlertProviderStatus.authError;
|
|
_lastError = 'Firebase authentication required: please sign in';
|
|
return RoadAlertProviderStatus.authError;
|
|
}
|
|
final uri = Uri.parse('$backendBaseUrl/api/v1/alerts/ops/health');
|
|
final res = await _client.get(uri, headers: headers).timeout(const Duration(seconds: 4));
|
|
if (res.statusCode == 200) {
|
|
final data = json.decode(res.body);
|
|
final providers = data['providers'] as List<dynamic>? ?? [];
|
|
for (final p in providers) {
|
|
if (p['provider_id'] == id) {
|
|
final status = p['status'];
|
|
if (status == 'operational') {
|
|
_lastStatus = RoadAlertProviderStatus.connected;
|
|
_lastError = null;
|
|
return RoadAlertProviderStatus.connected;
|
|
}
|
|
if (status == 'circuit_open') {
|
|
_lastStatus = RoadAlertProviderStatus.rateLimited;
|
|
_lastError = 'Upstream circuit open / quota budget exhausted';
|
|
return RoadAlertProviderStatus.rateLimited;
|
|
}
|
|
if (status == 'auth_missing') {
|
|
_lastStatus = RoadAlertProviderStatus.authError;
|
|
_lastError = 'Upstream gateway credentials not configured on backend';
|
|
return RoadAlertProviderStatus.authError;
|
|
}
|
|
_lastStatus = RoadAlertProviderStatus.notConfigured;
|
|
_lastError = 'Provider status: $status';
|
|
return RoadAlertProviderStatus.notConfigured;
|
|
}
|
|
}
|
|
_lastStatus = RoadAlertProviderStatus.connected;
|
|
_lastError = null;
|
|
return RoadAlertProviderStatus.connected;
|
|
}
|
|
if (res.statusCode == 401 || res.statusCode == 403) {
|
|
if (kDebugMode) {
|
|
debugPrint('[ZylaRoadAlertProvider] testConnection auth rejected: ${res.statusCode} ${res.body}');
|
|
}
|
|
_lastStatus = RoadAlertProviderStatus.authError;
|
|
_lastError = 'Firebase authorization token rejected (HTTP ${res.statusCode})';
|
|
return RoadAlertProviderStatus.authError;
|
|
}
|
|
if (res.statusCode == 429) {
|
|
_lastStatus = RoadAlertProviderStatus.rateLimited;
|
|
_lastError = 'Rate limit or monthly quota exceeded (HTTP 429)';
|
|
return RoadAlertProviderStatus.rateLimited;
|
|
}
|
|
_lastStatus = RoadAlertProviderStatus.networkError;
|
|
_lastError = 'Backend returned HTTP ${res.statusCode}';
|
|
return RoadAlertProviderStatus.networkError;
|
|
} catch (e) {
|
|
if (kDebugMode) {
|
|
debugPrint('[ZylaRoadAlertProvider] testConnection error: $e');
|
|
}
|
|
_lastStatus = RoadAlertProviderStatus.networkError;
|
|
_lastError = 'Connection failed: $e';
|
|
return RoadAlertProviderStatus.networkError;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<List<RoadAlert>> getAlerts({
|
|
required double latitude,
|
|
required double longitude,
|
|
required double radiusMeters,
|
|
String? apiKey,
|
|
String? fixtureOverride,
|
|
}) async {
|
|
_lastWasLive = fixtureOverride == null || fixtureOverride.isEmpty;
|
|
|
|
// Convert center + radius to bounding box
|
|
const metersPerDegreeLat = 111320.0;
|
|
final latDelta = radiusMeters / metersPerDegreeLat;
|
|
final metersPerDegreeLng = metersPerDegreeLat * math.cos(latitude * math.pi / 180.0).abs();
|
|
final lngDelta = metersPerDegreeLng > 0 ? radiusMeters / metersPerDegreeLng : latDelta;
|
|
|
|
final minLat = (latitude - latDelta).clamp(-90.0, 90.0);
|
|
final maxLat = (latitude + latDelta).clamp(-90.0, 90.0);
|
|
final minLng = (longitude - lngDelta).clamp(-180.0, 180.0);
|
|
final maxLng = (longitude + lngDelta).clamp(-180.0, 180.0);
|
|
|
|
final queryParams = <String, String>{
|
|
'min_lat': minLat.toStringAsFixed(4),
|
|
'min_lng': minLng.toStringAsFixed(4),
|
|
'max_lat': maxLat.toStringAsFixed(4),
|
|
'max_lng': maxLng.toStringAsFixed(4),
|
|
if (fixtureOverride != null && fixtureOverride.isNotEmpty)
|
|
'fixture_override': fixtureOverride,
|
|
};
|
|
|
|
final uri = Uri.parse('$backendBaseUrl/api/v1/alerts/zyla').replace(
|
|
queryParameters: queryParams,
|
|
);
|
|
|
|
try {
|
|
final headers = await _buildHeaders(apiKey: apiKey);
|
|
if (getAuthToken != null && !headers.containsKey('Authorization')) {
|
|
_lastStatus = RoadAlertProviderStatus.authError;
|
|
_lastError = 'Firebase authentication required: please sign in';
|
|
return [];
|
|
}
|
|
|
|
final response = await _client.get(uri, headers: headers).timeout(const Duration(seconds: 5));
|
|
if (response.statusCode == 401 || response.statusCode == 403) {
|
|
if (kDebugMode) {
|
|
debugPrint('[ZylaRoadAlertProvider] Backend rejected request with auth status ${response.statusCode}');
|
|
}
|
|
_lastStatus = RoadAlertProviderStatus.authError;
|
|
_lastError = 'Firebase token expired or unauthorized (${response.statusCode})';
|
|
return [];
|
|
}
|
|
if (response.statusCode == 429) {
|
|
_lastStatus = RoadAlertProviderStatus.rateLimited;
|
|
_lastError = 'Monthly API budget (500 reqs) or rate limit exceeded (429)';
|
|
return [];
|
|
}
|
|
if (response.statusCode != 200) {
|
|
if (kDebugMode) {
|
|
debugPrint('[ZylaRoadAlertProvider] Backend returned status ${response.statusCode}');
|
|
}
|
|
_lastStatus = RoadAlertProviderStatus.networkError;
|
|
_lastError = 'Backend returned HTTP ${response.statusCode}';
|
|
return [];
|
|
}
|
|
|
|
final data = json.decode(response.body);
|
|
final rawAlerts = data['alerts'] as List<dynamic>? ?? [];
|
|
_lastRawCount = rawAlerts.length;
|
|
_lastStatus = RoadAlertProviderStatus.connected;
|
|
_lastError = null;
|
|
|
|
final List<RoadAlert> alerts = [];
|
|
for (final raw in rawAlerts) {
|
|
final alert = _parseNormalizedAlert(raw, userLat: latitude, userLng: longitude);
|
|
if (alert != null) {
|
|
alerts.add(alert);
|
|
}
|
|
}
|
|
|
|
return alerts;
|
|
} catch (e) {
|
|
if (kDebugMode) {
|
|
debugPrint('[ZylaRoadAlertProvider] Failed to fetch alerts: $e');
|
|
}
|
|
_lastStatus = RoadAlertProviderStatus.networkError;
|
|
_lastError = 'Network connection failed: $e';
|
|
return []; // Graceful degradation
|
|
}
|
|
}
|
|
|
|
RoadAlert? _parseNormalizedAlert(
|
|
dynamic raw, {
|
|
required double userLat,
|
|
required double userLng,
|
|
}) {
|
|
if (raw is! Map<String, dynamic>) return null;
|
|
|
|
final id = raw['id']?.toString() ?? '';
|
|
final catStr = raw['category']?.toString() ?? 'other';
|
|
final category = RoadAlertCategory.values.firstWhere(
|
|
(c) => c.name.toLowerCase() == catStr.toLowerCase(),
|
|
orElse: () => RoadAlertCategory.other,
|
|
);
|
|
|
|
final lat = (raw['latitude'] as num?)?.toDouble() ?? 0.0;
|
|
final lng = (raw['longitude'] as num?)?.toDouble() ?? 0.0;
|
|
if (lat < -90.0 || lat > 90.0 || lng < -180.0 || lng > 180.0) return null;
|
|
|
|
final reportedAtStr = raw['reported_at_utc']?.toString();
|
|
final reportedAt = reportedAtStr != null
|
|
? (DateTime.tryParse(reportedAtStr) ?? DateTime.now())
|
|
: DateTime.now();
|
|
|
|
final expiresAtStr = raw['expires_at_utc']?.toString();
|
|
final expiresAt = expiresAtStr != null ? DateTime.tryParse(expiresAtStr) : null;
|
|
|
|
final confidence = (raw['confidence'] as num?)?.toDouble();
|
|
|
|
// Approximate distance
|
|
final dLat = (lat - userLat) * 111320.0;
|
|
final dLng = (lng - userLng) * 111320.0 * math.cos(userLat * math.pi / 180.0);
|
|
final distance = math.sqrt(dLat * dLat + dLng * dLng);
|
|
|
|
return RoadAlert(
|
|
id: id,
|
|
providerId: this.id,
|
|
category: category,
|
|
subtype: raw['subtype']?.toString(),
|
|
latitude: lat,
|
|
longitude: lng,
|
|
roadName: raw['road_name']?.toString(),
|
|
description: raw['description']?.toString() ?? 'Waze Traffic Report',
|
|
reportedAt: reportedAt,
|
|
expiresAt: expiresAt,
|
|
lastUpdated: DateTime.now(),
|
|
confidence: confidence,
|
|
severity: category == RoadAlertCategory.police || category == RoadAlertCategory.accident
|
|
? RoadAlertSeverity.high
|
|
: RoadAlertSeverity.medium,
|
|
source: raw['source']?.toString() ?? name,
|
|
sourceType: RoadAlertSourceType.providerReported,
|
|
status: RoadAlertStatus.active,
|
|
distanceFromUser: distance,
|
|
confirmations: 1,
|
|
dismissals: 0,
|
|
);
|
|
}
|
|
}
|