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 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> _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) { code = body['error']?.toString() ?? code; msg = body['message']?.toString() ?? msg; } } catch (_) {} throw RouteProviderException(code, msg); } @override Future> searchPlaces( String query, { AppLatLng? nearLocation, }) async { final clean = query.trim(); if (clean.length < 2) return const []; final headers = await _getAuthHeaders(); try { final queryParams = { '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) return const []; final results = data['results'] as List? ?? []; final attribution = data['attribution'] as String?; return results.whereType>().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 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) { 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 calculateRoute( List stops, { bool avoidHighways = false, bool avoidTolls = false, bool avoidFerries = false, }) async { final ordered = List.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) { 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? ?? []; 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', ); } } }