298 lines
8.7 KiB
Dart
298 lines
8.7 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 (_) {}
|
|
|
|
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 distanceMeters =
|
|
(data['distanceMeters'] as num?)?.toDouble() ?? 0.0;
|
|
final durationSeconds =
|
|
(data['durationSeconds'] as num?)?.toInt();
|
|
final warningsRaw = data['warnings'] as List<dynamic>? ?? [];
|
|
|
|
final geometry = 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',
|
|
);
|
|
}
|
|
}
|
|
}
|