MotorSignal-App/lib/services/routes/valhalla_route_provider.dart

309 lines
9.2 KiB
Dart

import 'package:flutter/foundation.dart';
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:firebase_auth/firebase_auth.dart';
import '../map/map_provider_interface.dart';
import 'route_models.dart';
import 'route_provider.dart';
import 'valhalla_polyline_decoder.dart';
/// Provider integrating with the MotorSignal Node.js routing and places relay.
class ValhallaRouteProvider implements RouteProvider {
final String backendUrl;
final http.Client _client;
final Future<String?> Function()? tokenProvider;
ValhallaRouteProvider({
String? backendUrl,
http.Client? client,
this.tokenProvider,
}) : _client = client ?? http.Client(),
backendUrl = backendUrl ?? _getEffectiveBackendUrl();
static String _getEffectiveBackendUrl() {
const definedUrl = String.fromEnvironment('MOTORSIGNAL_API_BASE_URL');
if (definedUrl.isNotEmpty) {
return definedUrl;
}
if (kReleaseMode) {
throw StateError(
'MOTORSIGNAL_API_BASE_URL is not configured for production build.',
);
}
return 'http://10.0.2.2:3000';
}
@override
String get name => 'Stadia Maps (Valhalla)';
Future<Map<String, String>> _getAuthHeaders() async {
String? token;
final customTokenProvider = tokenProvider;
if (customTokenProvider != null) {
token = await customTokenProvider();
} else {
final user = FirebaseAuth.instance.currentUser;
if (user == null) {
throw RouteProviderException(
'authenticationRequired',
'User must be authenticated (or signed in as guest) to use cloud routing.',
);
}
try {
token = await user.getIdToken();
} catch (e) {
throw RouteProviderException(
'authenticationRequired',
'Failed to acquire authentication token: $e',
);
}
}
if (token == null || token.isEmpty) {
throw RouteProviderException(
'authenticationRequired',
'User must be authenticated to use cloud routing.',
);
}
return {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
};
}
void _handleHttpError(http.Response response) {
if (response.statusCode >= 200 && response.statusCode < 300) return;
String code = 'providerError';
String msg = 'An unexpected provider error occurred.';
try {
final body = jsonDecode(response.body);
if (body is Map<String, dynamic>) {
code = body['error']?.toString() ?? code;
msg = body['message']?.toString() ?? msg;
}
} catch (_) {}
if (code == 'providerUnavailable') {
msg =
'Places service is not configured. Set STADIA_API_KEY on the routing server.';
}
throw RouteProviderException(code, msg);
}
@override
Future<List<PlaceSearchResult>> searchPlaces(
String query, {
AppLatLng? nearLocation,
}) async {
final clean = query.trim();
if (clean.length < 2) return const [];
final headers = await _getAuthHeaders();
try {
final queryParams = <String, String>{'query': clean};
if (nearLocation != null) {
queryParams['lat'] = nearLocation.latitude.toString();
queryParams['lng'] = nearLocation.longitude.toString();
}
final uri = Uri.parse(
'$backendUrl/api/places/search',
).replace(queryParameters: queryParams);
final response = await _client
.get(uri, headers: headers)
.timeout(const Duration(seconds: 8));
_handleHttpError(response);
final data = jsonDecode(response.body);
if (data is! Map<String, dynamic>) return const [];
final results = data['results'] as List<dynamic>? ?? [];
final attribution = data['attribution'] as String?;
return results.whereType<Map<String, dynamic>>().map((r) {
final lat = (r['latitude'] as num?)?.toDouble();
final lng = (r['longitude'] as num?)?.toDouble();
return PlaceSearchResult(
providerId: r['providerId']?.toString() ?? '',
primaryText: r['primaryText']?.toString() ?? '',
secondaryText: r['secondaryText']?.toString() ?? '',
latitude: lat,
longitude: lng,
formattedAddress: r['formattedAddress']?.toString(),
attribution: attribution,
);
}).toList();
} on TimeoutException {
throw RouteProviderException(
'providerTimeout',
'Place search timed out.',
);
} catch (e) {
if (e is RouteProviderException) rethrow;
throw RouteProviderException(
'placeSearchUnavailable',
'Failed to reach places service: $e',
);
}
}
@override
Future<ResolvedPlace> resolvePlace(String placeId) async {
final cleanId = placeId.trim();
if (cleanId.isEmpty) {
throw RouteProviderException(
'invalidRequest',
'Place ID cannot be empty.',
);
}
final headers = await _getAuthHeaders();
try {
final uri = Uri.parse(
'$backendUrl/api/places/resolve',
).replace(queryParameters: {'placeId': cleanId});
final response = await _client
.get(uri, headers: headers)
.timeout(const Duration(seconds: 8));
_handleHttpError(response);
final data = jsonDecode(response.body);
if (data is! Map<String, dynamic>) {
throw RouteProviderException(
'placeNotFound',
'Invalid response from place resolution.',
);
}
return ResolvedPlace(
providerId: data['providerId']?.toString() ?? cleanId,
displayName: data['displayName']?.toString() ?? 'Unknown Place',
formattedAddress: data['formattedAddress']?.toString(),
latitude: (data['latitude'] as num).toDouble(),
longitude: (data['longitude'] as num).toDouble(),
attribution: data['attribution']?.toString(),
);
} on TimeoutException {
throw RouteProviderException(
'providerTimeout',
'Place resolution timed out.',
);
} catch (e) {
if (e is RouteProviderException) rethrow;
throw RouteProviderException(
'placeSearchUnavailable',
'Failed to resolve place: $e',
);
}
}
@override
Future<RouteCalculationResult> calculateRoute(
List<RouteStop> stops, {
bool avoidHighways = false,
bool avoidTolls = false,
bool avoidFerries = false,
}) async {
final ordered = List<RouteStop>.from(stops);
ordered.sort((a, b) => a.order.compareTo(b.order));
if (ordered.length < 2) {
throw RouteProviderException(
'invalidRequest',
'At least 2 stops are required to calculate a route.',
);
}
if (ordered.length > 25) {
throw RouteProviderException(
'invalidRequest',
'A maximum of 25 stops is allowed.',
);
}
final headers = await _getAuthHeaders();
final locations = ordered
.map((s) => {'lat': s.latitude, 'lon': s.longitude, 'type': 'break'})
.toList();
try {
final uri = Uri.parse('$backendUrl/api/routes/calculate');
final response = await _client
.post(
uri,
headers: headers,
body: jsonEncode({
'locations': locations,
'options': {
'avoid_highways': avoidHighways,
'avoid_tolls': avoidTolls,
'avoid_ferries': avoidFerries,
},
}),
)
.timeout(const Duration(seconds: 15));
_handleHttpError(response);
final data = jsonDecode(response.body);
if (data is! Map<String, dynamic>) {
throw RouteProviderException(
'routeUnavailable',
'Malformed response from route calculation.',
);
}
final encodedPolyline = data['encodedPolyline'] as String? ?? '';
final encodedPolylines = (data['encodedPolylines'] as List<dynamic>?)
?.whereType<String>()
.where((part) => part.isNotEmpty)
.toList(growable: false);
final distanceMeters =
(data['distanceMeters'] as num?)?.toDouble() ?? 0.0;
final durationSeconds = (data['durationSeconds'] as num?)?.toInt();
final warningsRaw = data['warnings'] as List<dynamic>? ?? [];
final geometry = encodedPolylines != null && encodedPolylines.isNotEmpty
? encodedPolylines
.expand(ValhallaPolylineDecoder.decode)
.toList(growable: false)
: ValhallaPolylineDecoder.decode(encodedPolyline);
final straightLineDist = MotorSignalRoute.calculateTotalDistanceMeters(
ordered,
);
return RouteCalculationResult(
straightLineDistanceMeters: straightLineDist,
estimatedDistanceMeters: distanceMeters,
estimatedDurationSeconds: durationSeconds,
geometry: geometry,
isStraightLine: false,
providerName: name,
warnings: warningsRaw.map((e) => e.toString()).toList(),
);
} on TimeoutException {
throw RouteProviderException(
'providerTimeout',
'Route calculation timed out.',
);
} catch (e) {
if (e is RouteProviderException) rethrow;
throw RouteProviderException(
'routeUnavailable',
'Failed to calculate route: $e',
);
}
}
}