import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; import '../map/map_provider_interface.dart'; import 'route_models.dart'; import 'route_provider.dart'; /// Route and places provider backed by Amazon Location Service (AWS Location V2). /// /// Supports: /// - Places Autocomplete & SearchText (address search and geocoding) /// - Place details resolution (coordinates, formatted address) /// - CalculateRoutes (road distance, duration, and GeoJSON route geometry) class AwsLocationRouteProvider implements RouteProvider { static const String defaultApiKey = 'v1.public.eyJqdGkiOiI4NzQ3MjZlNC02ZDM2LTQ3YTQtYmIwOS1hYWQ2MmFiOTJlMGIifQho8OQ3u9I2r7jPLEUeGDkQBfhOdN-9eYKdANXwRPejSIjNEy52eZoMOUS__KFWeuzE8Cp2Bb9enWUUl_1yINS5ErfdHFVQSF8IfQC429DftD5fJZm4sneorRU5ZBqWIh0VPjKhtLcR3pa9zHEMY-0ngum4MtEKINnfOwglCsX_7ohkpZIKdvLNO6rxjks4bGKQezQOLow5wxlbF89jzSUN__qqP9sJY6jq17M5LGi6x6Frh-IsObKsDR96rFmBcNSwBUy1Cy8hZSuDDlj-SOP4fu16pXE_0l0Qzy7oSjtmRHIp110mDPCXP8ffZqzVARb5IdMA7vMGezgrB3z8XPc.NjAyMWJkZWUtMGMyOS00NmRkLThjZTMtODEyOTkzZTUyMTBi'; static const String defaultRegion = 'us-east-2'; final String apiKey; final String region; final http.Client _client; AwsLocationRouteProvider({ String? apiKey, String? region, http.Client? client, }) : apiKey = apiKey ?? const String.fromEnvironment( 'AWS_MAPS_API_KEY', defaultValue: defaultApiKey, ), region = region ?? const String.fromEnvironment( 'AWS_MAPS_REGION', defaultValue: defaultRegion, ), _client = client ?? http.Client(); @override String get name => 'Amazon AWS Location'; @override Future> searchPlaces( String query, { AppLatLng? nearLocation, }) async { final clean = query.trim(); if (clean.length < 2) return const []; final bias = nearLocation != null ? [nearLocation.longitude, nearLocation.latitude] : const [-98.5795, 39.8283]; try { final autoFuture = _searchAutocomplete(clean, bias); final textFuture = _searchText(clean, bias); final results = await Future.wait([autoFuture, textFuture]); final autoItems = results[0]; final textItems = results[1]; final seenIds = {}; final combined = []; // Prefer text items first because coordinates are already populated for (final item in textItems) { if (seenIds.add(item.providerId)) { combined.add(item); } } for (final item in autoItems) { if (seenIds.add(item.providerId)) { combined.add(item); } } return combined; } catch (e) { if (e is RouteProviderException) rethrow; throw RouteProviderException( 'placeSearchUnavailable', 'AWS Location Service search failed: $e', ); } } Future> _searchAutocomplete( String clean, List bias, ) async { try { final uri = Uri.parse( 'https://places.geo.$region.amazonaws.com/v2/autocomplete?key=$apiKey', ); final response = await _client .post( uri, headers: {'Content-Type': 'application/json'}, body: jsonEncode({ 'QueryText': clean, 'BiasPosition': bias, }), ) .timeout(const Duration(seconds: 8)); if (response.statusCode != 200) return const []; final data = jsonDecode(response.body); final items = data['ResultItems'] as List? ?? []; return items.whereType>().map((item) { final title = item['Title']?.toString() ?? ''; final address = item['Address'] as Map?; final label = address?['Label']?.toString() ?? ''; return PlaceSearchResult( providerId: item['PlaceId']?.toString() ?? '', primaryText: title.isNotEmpty ? title : label, secondaryText: label != title ? label : '', formattedAddress: label.isNotEmpty ? label : title, attribution: '© AWS, HERE', ); }).toList(); } catch (_) { return const []; } } Future> _searchText( String clean, List bias, ) async { try { final uri = Uri.parse( 'https://places.geo.$region.amazonaws.com/v2/search-text?key=$apiKey', ); final response = await _client .post( uri, headers: {'Content-Type': 'application/json'}, body: jsonEncode({ 'QueryText': clean, 'BiasPosition': bias, }), ) .timeout(const Duration(seconds: 8)); if (response.statusCode != 200) return const []; final data = jsonDecode(response.body); final items = data['ResultItems'] as List? ?? []; return items.whereType>().map((item) { final title = item['Title']?.toString() ?? ''; final address = item['Address'] as Map?; final label = address?['Label']?.toString() ?? ''; final pos = item['Position'] as List?; double? lat; double? lng; if (pos != null && pos.length >= 2) { lng = (pos[0] as num).toDouble(); lat = (pos[1] as num).toDouble(); } return PlaceSearchResult( providerId: item['PlaceId']?.toString() ?? '', primaryText: title.isNotEmpty ? title : label, secondaryText: label != title ? label : '', latitude: lat, longitude: lng, formattedAddress: label.isNotEmpty ? label : title, attribution: '© AWS, HERE', ); }).toList(); } catch (_) { return const []; } } @override Future resolvePlace(String placeId) async { final cleanId = placeId.trim(); if (cleanId.isEmpty) { throw RouteProviderException( 'invalidRequest', 'Place ID cannot be empty.', ); } try { final uri = Uri.parse( 'https://places.geo.$region.amazonaws.com/v2/place/${Uri.encodeComponent(cleanId)}?key=$apiKey', ); final response = await _client.get(uri).timeout(const Duration(seconds: 8)); if (response.statusCode != 200) { throw RouteProviderException( 'placeNotFound', 'Could not resolve AWS place details (status ${response.statusCode}).', ); } final data = jsonDecode(response.body); final title = data['Title']?.toString() ?? 'Unknown Place'; final address = data['Address'] as Map?; final label = address?['Label']?.toString() ?? title; final pos = data['Position'] as List?; if (pos == null || pos.length < 2) { throw RouteProviderException( 'invalidCoordinates', 'Place position is missing.', ); } final lng = (pos[0] as num).toDouble(); final lat = (pos[1] as num).toDouble(); return ResolvedPlace( providerId: data['PlaceId']?.toString() ?? cleanId, displayName: title, formattedAddress: label, latitude: lat, longitude: lng, attribution: '© AWS, HERE', ); } catch (e) { if (e is RouteProviderException) rethrow; throw RouteProviderException( 'placeSearchUnavailable', 'Failed to resolve place via AWS Location: $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.', ); } try { final uri = Uri.parse( 'https://routes.geo.$region.amazonaws.com/v2/routes?key=$apiKey', ); final origin = [ordered.first.longitude, ordered.first.latitude]; final destination = [ordered.last.longitude, ordered.last.latitude]; final waypoints = ordered.length > 2 ? ordered .sublist(1, ordered.length - 1) .map((s) => {'Position': [s.longitude, s.latitude]}) .toList() : null; final bodyMap = { 'Origin': origin, 'Destination': destination, 'TravelMode': 'Car', 'LegGeometryFormat': 'Simple', }; if (waypoints != null && waypoints.isNotEmpty) { bodyMap['Waypoints'] = waypoints; } final response = await _client .post( uri, headers: {'Content-Type': 'application/json'}, body: jsonEncode(bodyMap), ) .timeout(const Duration(seconds: 15)); if (response.statusCode != 200) { throw RouteProviderException( 'routeUnavailable', 'AWS route calculation returned status ${response.statusCode}', ); } final data = jsonDecode(response.body); final routes = data['Routes'] as List?; if (routes == null || routes.isEmpty) { throw RouteProviderException( 'routeNotFound', 'No route returned by AWS.', ); } final route = routes[0]; final summary = route['Summary'] as Map?; final distanceMeters = (summary?['Distance'] as num?)?.toDouble() ?? 0.0; final durationSeconds = (summary?['Duration'] as num?)?.toInt() ?? 0; final legs = route['Legs'] as List? ?? []; final geometry = []; for (final leg in legs) { final geom = leg['Geometry'] as Map?; final lineString = geom?['LineString'] as List?; if (lineString != null) { for (final pt in lineString) { if (pt is List && pt.length >= 2) { geometry.add( AppLatLng((pt[1] as num).toDouble(), (pt[0] as num).toDouble()), ); } } } } final straightLine = MotorSignalRoute.calculateTotalDistanceMeters(ordered); return RouteCalculationResult( straightLineDistanceMeters: straightLine, estimatedDistanceMeters: distanceMeters > 0 ? distanceMeters : straightLine, estimatedDurationSeconds: durationSeconds > 0 ? durationSeconds : null, geometry: geometry.isNotEmpty ? geometry : ordered.map((s) => s.latLng).toList(), isStraightLine: geometry.isEmpty, providerName: name, ); } catch (e) { if (e is RouteProviderException) rethrow; throw RouteProviderException( 'routeCalculationFailed', 'AWS route calculation failed: $e', ); } } }