import 'dart:async'; import 'dart:convert'; import 'dart:math' as math; 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, RoutingOptions? options, }) 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.', ); } final effectiveAvoidHighways = options?.avoidHighways ?? (options?.preference == RoutingPreference.twisty || options?.preference == RoutingPreference.scenic || avoidHighways); final effectiveAvoidTolls = options?.avoidTolls ?? avoidTolls; final effectiveAvoidFerries = options?.avoidFerries ?? avoidFerries; final optimizeFor = options?.preference == RoutingPreference.shortest ? 'ShortestRoute' : 'FastestRoute'; 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 travelOptions = { 'Car': { 'Avoid': { if (effectiveAvoidHighways) 'Highways': true, if (effectiveAvoidTolls) 'Tolls': true, if (effectiveAvoidFerries) 'Ferries': true, }, }, }; final bodyMap = { 'Origin': origin, 'Destination': destination, 'TravelMode': 'Car', 'TravelModeOptions': travelOptions, 'OptimizeRoutingFor': optimizeFor, 'LegGeometryFormat': 'Simple', 'LegAdditionalInputs': ['TravelSteps'], 'Instructions': true, }; 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 effectiveGeometry = geometry.isNotEmpty ? geometry : ordered.map((s) => s.latLng).toList(); final straightLine = MotorSignalRoute.calculateTotalDistanceMeters(ordered); final effectiveDist = distanceMeters > 0 ? distanceMeters : straightLine; final distMiles = effectiveDist * 0.000621371; // Extract maneuvers from TravelSteps if available final maneuvers = _extractManeuvers(legs, effectiveGeometry, ordered); // Compute geometric curvature metric final curvature = _calculateCurvatureMetric(effectiveGeometry, distMiles); return RouteCalculationResult( straightLineDistanceMeters: straightLine, estimatedDistanceMeters: effectiveDist, estimatedDurationSeconds: durationSeconds > 0 ? durationSeconds : null, geometry: effectiveGeometry, isStraightLine: geometry.isEmpty, providerName: name, maneuvers: maneuvers, options: options, curvatureMetric: curvature, ); } catch (e) { if (e is RouteProviderException) rethrow; throw RouteProviderException( 'routeCalculationFailed', 'AWS route calculation failed: $e', ); } } static List _extractManeuvers( List legs, List geometry, List orderedStops, ) { final maneuvers = []; for (final leg in legs) { if (leg is! Map) continue; final legDetails = leg['VehicleLegDetails'] as Map?; final travelSteps = (legDetails?['TravelSteps'] ?? leg['TravelSteps']) as List? ?? []; for (final rawStep in travelSteps) { if (rawStep is! Map) continue; final instruction = rawStep['Instruction']?.toString() ?? 'Continue'; final stepType = rawStep['Type']?.toString(); final turn = rawStep['Turn']?.toString(); final dist = (rawStep['Distance'] as num?)?.toDouble() ?? 0.0; final dur = (rawStep['Duration'] as num?)?.toInt() ?? 0; final road = rawStep['NextRoad']?.toString() ?? rawStep['CurrentRoad']?.toString(); final offset = (rawStep['GeometryOffset'] as num?)?.toInt(); AppLatLng loc; if (offset != null && offset >= 0 && offset < geometry.length) { loc = geometry[offset]; } else if (geometry.isNotEmpty) { loc = geometry.first; } else { loc = orderedStops.first.latLng; } maneuvers.add( RouteManeuver( index: maneuvers.length, instruction: instruction, type: _mapManeuverType(stepType, turn), distanceMeters: dist, durationSeconds: dur, streetName: road, location: loc, geometryOffset: offset, ), ); } } // If no provider steps were supplied, synthesize maneuvers from geometry if (maneuvers.isEmpty && geometry.length >= 2) { maneuvers.addAll(_synthesizeManeuvers(geometry, orderedStops)); } return maneuvers; } static ManeuverType _mapManeuverType(String? typeStr, String? turnStr) { final t = (typeStr ?? '').toLowerCase(); final turn = (turnStr ?? '').toLowerCase(); if (t == 'arrive') return ManeuverType.arrive; if (t == 'depart') return ManeuverType.depart; if (t == 'uturn' || turn == 'uturn') return ManeuverType.uTurn; if (t.contains('roundabout') || turn.contains('roundabout')) { if (t.contains('exit')) return ManeuverType.exitRoundabout; return ManeuverType.enterRoundabout; } if (t == 'ramp') return ManeuverType.ramp; if (turn == 'sharpright') return ManeuverType.turnSharpRight; if (turn == 'sharpleft') return ManeuverType.turnSharpLeft; if (turn == 'slightright') return ManeuverType.turnSlightRight; if (turn == 'slightleft') return ManeuverType.turnSlightLeft; if (turn == 'right' || t == 'turnright') return ManeuverType.turnRight; if (turn == 'left' || t == 'turnleft') return ManeuverType.turnLeft; if (turn == 'keepright') return ManeuverType.keepRight; if (turn == 'keepleft') return ManeuverType.keepLeft; if (t == 'continue') return ManeuverType.continueStraight; return ManeuverType.continueStraight; } static List _synthesizeManeuvers( List geometry, List orderedStops, ) { final list = []; final destinationName = orderedStops.isNotEmpty ? orderedStops.last.displayName : 'Destination'; list.add( RouteManeuver( index: 0, instruction: 'Head towards $destinationName', type: ManeuverType.depart, distanceMeters: 0.0, durationSeconds: 0, location: geometry.first, geometryOffset: 0, ), ); for (int i = 0; i < geometry.length - 2; i++) { final b1 = _bearing(geometry[i], geometry[i + 1]); final b2 = _bearing(geometry[i + 1], geometry[i + 2]); double diff = (b2 - b1); while (diff < -180.0) { diff += 360.0; } while (diff > 180.0) { diff -= 360.0; } if (diff.abs() >= 35.0) { final ManeuverType type; final String dirText; if (diff > 120.0) { type = ManeuverType.turnSharpRight; dirText = 'Sharp right'; } else if (diff > 60.0) { type = ManeuverType.turnRight; dirText = 'Turn right'; } else if (diff > 35.0) { type = ManeuverType.turnSlightRight; dirText = 'Slight right'; } else if (diff < -120.0) { type = ManeuverType.turnSharpLeft; dirText = 'Sharp left'; } else if (diff < -60.0) { type = ManeuverType.turnLeft; dirText = 'Turn left'; } else { type = ManeuverType.turnSlightLeft; dirText = 'Slight left'; } list.add( RouteManeuver( index: list.length, instruction: '$dirText onto road', type: type, distanceMeters: 0.0, durationSeconds: 0, location: geometry[i + 1], geometryOffset: i + 1, ), ); } } list.add( RouteManeuver( index: list.length, instruction: 'Arrive at $destinationName', type: ManeuverType.arrive, distanceMeters: 0.0, durationSeconds: 0, location: geometry.last, geometryOffset: geometry.length - 1, ), ); return list; } static double _calculateCurvatureMetric( List points, double distanceMiles, ) { if (points.length < 3 || distanceMiles <= 0.05) return 0.0; double totalTurnDeg = 0.0; for (int i = 0; i < points.length - 2; i++) { final b1 = _bearing(points[i], points[i + 1]); final b2 = _bearing(points[i + 1], points[i + 2]); double diff = (b2 - b1).abs(); if (diff > 180.0) diff = 360.0 - diff; totalTurnDeg += diff; } return totalTurnDeg / distanceMiles; } static double _bearing(AppLatLng from, AppLatLng to) { final lat1 = from.latitude * (math.pi / 180.0); final lat2 = to.latitude * (math.pi / 180.0); final dLon = (to.longitude - from.longitude) * (math.pi / 180.0); final y = math.sin(dLon) * math.cos(lat2); final x = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dLon); final brng = math.atan2(y, x) * (180.0 / math.pi); return (brng + 360.0) % 360.0; } }