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 = []; // Sort text results with coordinates by physical proximity to rider if (nearLocation != null) { textItems.sort((a, b) { final da = (a.latitude != null && a.longitude != null) ? _distanceMeters(nearLocation, AppLatLng(a.latitude!, a.longitude!)) : double.infinity; final db = (b.latitude != null && b.longitude != null) ? _distanceMeters(nearLocation, AppLatLng(b.latitude!, b.longitude!)) : double.infinity; return da.compareTo(db); }); } // 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, 'Filter': { 'IncludeCountries': ['USA'], }, }), ) .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, 'Filter': { 'IncludeCountries': ['USA'], }, }), ) .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 == true) || options?.preference == RoutingPreference.twisty || options?.preference == RoutingPreference.scenic || avoidHighways; final effectiveAvoidTolls = (options?.avoidTolls == true) || avoidTolls; final effectiveAvoidFerries = (options?.avoidFerries == true) || 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 avoidMap = { if (effectiveAvoidHighways) 'ControlledAccessHighways': true, if (effectiveAvoidTolls) 'TollRoads': true, if (effectiveAvoidFerries) 'Ferries': true, }; 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, if (avoidMap.isNotEmpty) 'Avoid': avoidMap, 'OptimizeRoutingFor': optimizeFor, 'LegGeometryFormat': 'Simple', 'LegAdditionalInputs': ['TravelSteps'], 'Instructions': true, if (options?.preference == RoutingPreference.twisty || options?.preference == RoutingPreference.scenic || options?.preference == RoutingPreference.fastest || options?.preference == RoutingPreference.shortest) 'MaxAlternatives': 2, }; 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) { String detail = 'Status ${response.statusCode}'; try { final errData = jsonDecode(response.body); if (errData is Map) { detail = (errData['message'] ?? errData['Message'] ?? errData['error'] ?? detail).toString(); } } catch (_) {} throw RouteProviderException( 'routeUnavailable', 'AWS route calculation failed ($detail)', ); } final data = jsonDecode(response.body); final routes = data['Routes'] as List?; if (routes == null || routes.isEmpty) { throw RouteProviderException( 'routeNotFound', 'No route returned by AWS Location Service.', ); } final straightLine = MotorSignalRoute.calculateTotalDistanceMeters(ordered); // Parse and score all candidate routes returned by AWS final candidates = <_CandidateRoute>[]; for (final r in routes) { if (r is! Map) continue; final summary = r['Summary'] as Map?; final distanceM = (summary?['Distance'] as num?)?.toDouble() ?? 0.0; final durationS = (summary?['Duration'] as num?)?.toInt() ?? 0; final legs = r['Legs'] as List? ?? []; final geom = []; for (final leg in legs) { final gMap = leg['Geometry'] as Map?; final lineString = gMap?['LineString'] as List?; if (lineString != null) { for (final pt in lineString) { if (pt is List && pt.length >= 2) { geom.add(AppLatLng((pt[1] as num).toDouble(), (pt[0] as num).toDouble())); } } } } if (geom.isEmpty) { final rGMap = r['Geometry'] as Map?; final rLineString = rGMap?['LineString'] as List?; if (rLineString != null) { for (final pt in rLineString) { if (pt is List && pt.length >= 2) { geom.add(AppLatLng((pt[1] as num).toDouble(), (pt[0] as num).toDouble())); } } } } final effectiveGeom = geom.isNotEmpty ? geom : ordered.map((s) => s.latLng).toList(); final effectiveDist = distanceM > 0 ? distanceM : straightLine; final distMi = effectiveDist * 0.000621371; final curvature = _calculateCurvatureMetric(effectiveGeom, distMi); final mans = _extractManeuvers(legs, effectiveGeom, ordered); candidates.add(_CandidateRoute( distanceMeters: effectiveDist, durationSeconds: durationS, geometry: effectiveGeom, curvature: curvature, maneuvers: mans, )); } if (candidates.isEmpty) { throw RouteProviderException( 'routeNotFound', 'Could not parse route candidates from AWS response.', ); } // Actively select best route alternative according to user preference _CandidateRoute selected = candidates.first; if (candidates.length > 1) { if (options?.preference == RoutingPreference.twisty) { // Actively choose route alternative with the highest curvature metric candidates.sort((a, b) => b.curvature.compareTo(a.curvature)); selected = candidates.first; } else if (options?.preference == RoutingPreference.shortest) { candidates.sort((a, b) => a.distanceMeters.compareTo(b.distanceMeters)); selected = candidates.first; } else if (options?.preference == RoutingPreference.fastest) { candidates.sort((a, b) => a.durationSeconds.compareTo(b.durationSeconds)); selected = candidates.first; } else if (options?.preference == RoutingPreference.scenic) { candidates.sort((a, b) => b.curvature.compareTo(a.curvature)); selected = candidates.first; } } return RouteCalculationResult( straightLineDistanceMeters: straightLine, estimatedDistanceMeters: selected.distanceMeters, estimatedDurationSeconds: selected.durationSeconds > 0 ? selected.durationSeconds : null, geometry: selected.geometry, isStraightLine: selected.geometry.isEmpty, providerName: name, maneuvers: selected.maneuvers, options: options, curvatureMetric: selected.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 stepType = rawStep['Type']?.toString() ?? 'Continue'; 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(); // Extract steering direction from AWS Location Routes V2 details objects final turnStep = rawStep['TurnStepDetails'] as Map?; final rampStep = rawStep['RampStepDetails'] as Map?; final keepStep = rawStep['KeepStepDetails'] as Map?; final uTurnStep = rawStep['UTurnStepDetails'] as Map?; final roundaboutStep = rawStep['RoundaboutStepDetails'] as Map?; final turn = turnStep?['SteeringDirection']?.toString() ?? rampStep?['SteeringDirection']?.toString() ?? keepStep?['SteeringDirection']?.toString() ?? uTurnStep?['SteeringDirection']?.toString() ?? roundaboutStep?['SteeringDirection']?.toString() ?? rawStep['Turn']?.toString(); final initialType = _mapManeuverType(stepType, turn); final canonical = _canonicalizeManeuver( stepType: stepType, turn: turn, road: road, rawInstruction: rawStep['Instruction']?.toString(), initialType: initialType, offset: offset, geometry: geometry, ); 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: canonical.instruction, type: canonical.type, 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 type, String instruction}) _canonicalizeManeuver({ required String stepType, required String? turn, required String? road, required String? rawInstruction, required ManeuverType initialType, required int? offset, required List geometry, }) { ManeuverType resolvedType = initialType; String resolvedInstruction = _buildManeuverInstruction( stepType: stepType, turn: turn, road: road, rawInstruction: rawInstruction, maneuverType: initialType, ); // Calculate polyline bearing delta at offset if geometry is available double? bearingDelta; if (geometry.length >= 2 && offset != null && offset >= 0 && offset < geometry.length - 1) { final prevIdx = (offset > 0) ? offset - 1 : 0; final nextIdx = (offset + 1 < geometry.length) ? offset + 1 : offset; if (prevIdx != offset && offset != nextIdx) { final b1 = _bearing(geometry[prevIdx], geometry[offset]); final b2 = _bearing(geometry[offset], geometry[nextIdx]); var d = b2 - b1; while (d < -180.0) { d += 360.0; } while (d > 180.0) { d -= 360.0; } bearingDelta = d; } } final rawLower = (rawInstruction ?? '').toLowerCase().trim(); final bool rawHasLeft = rawLower.contains('left'); final bool rawHasRight = rawLower.contains('right'); final bool rawHasUturn = rawLower.contains('u-turn') || rawLower.contains('uturn'); // 1. If raw instruction explicitly states U-turn if (rawHasUturn) { resolvedType = ManeuverType.uTurn; } // 2. If raw instruction specifies turn direction but resolvedType is continueStraight else if (resolvedType == ManeuverType.continueStraight) { if (rawHasLeft) { if (rawLower.contains('sharp')) { resolvedType = ManeuverType.turnSharpLeft; } else if (rawLower.contains('slight') || rawLower.contains('bear')) { resolvedType = ManeuverType.turnSlightLeft; } else if (rawLower.contains('keep')) { resolvedType = ManeuverType.keepLeft; } else { resolvedType = ManeuverType.turnLeft; } } else if (rawHasRight) { if (rawLower.contains('sharp')) { resolvedType = ManeuverType.turnSharpRight; } else if (rawLower.contains('slight') || rawLower.contains('bear')) { resolvedType = ManeuverType.turnSlightRight; } else if (rawLower.contains('keep')) { resolvedType = ManeuverType.keepRight; } else { resolvedType = ManeuverType.turnRight; } } else if (stepType.toLowerCase() == 'turn' && bearingDelta != null && bearingDelta.abs() >= 20.0) { // Only convert to turn if step was an explicit Turn step lacking steering direction. // Pure road bends (Type: 'Continue' / 'Follow') along the existing road remain continueStraight. if (bearingDelta > 120.0) { resolvedType = ManeuverType.turnSharpRight; } else if (bearingDelta > 45.0) { resolvedType = ManeuverType.turnRight; } else if (bearingDelta > 20.0) { resolvedType = ManeuverType.turnSlightRight; } else if (bearingDelta < -120.0) { resolvedType = ManeuverType.turnSharpLeft; } else if (bearingDelta < -45.0) { resolvedType = ManeuverType.turnLeft; } else if (bearingDelta < -20.0) { resolvedType = ManeuverType.turnSlightLeft; } resolvedInstruction = _buildManeuverInstruction( stepType: stepType, turn: resolvedType.name, road: road, rawInstruction: null, maneuverType: resolvedType, ); } } // 3. Direct conflict resolution: instruction says left but type says right (or vice versa) else if (resolvedType.isLeft && rawHasRight && !rawHasLeft) { if (bearingDelta != null && bearingDelta > 20.0) { resolvedType = ManeuverType.turnRight; } else if (bearingDelta != null && bearingDelta < -20.0) { final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : ''; resolvedInstruction = 'Turn left$roadSuffix'; } else { resolvedType = ManeuverType.turnRight; } } else if (resolvedType.isRight && rawHasLeft && !rawHasRight) { if (bearingDelta != null && bearingDelta < -20.0) { resolvedType = ManeuverType.turnLeft; } else if (bearingDelta != null && bearingDelta > 20.0) { final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : ''; resolvedInstruction = 'Turn right$roadSuffix'; } else { resolvedType = ManeuverType.turnLeft; } } // 4. Authoritative geometry override: only for explicit Turn steps where provider claimed wrong direction if (bearingDelta != null && (stepType.toLowerCase() == 'turn' || rawHasLeft || rawHasRight)) { if (resolvedType.isRight && bearingDelta < -35.0) { resolvedType = bearingDelta < -120.0 ? ManeuverType.turnSharpLeft : (bearingDelta < -45.0 ? ManeuverType.turnLeft : ManeuverType.turnSlightLeft); final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : ''; resolvedInstruction = 'Turn left$roadSuffix'; } else if (resolvedType.isLeft && bearingDelta > 35.0) { resolvedType = bearingDelta > 120.0 ? ManeuverType.turnSharpRight : (bearingDelta > 45.0 ? ManeuverType.turnRight : ManeuverType.turnSlightRight); final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : ''; resolvedInstruction = 'Turn right$roadSuffix'; } } // Re-verify that resolved instruction and resolved type agree on direction if (resolvedType.isLeft && resolvedInstruction.toLowerCase().contains('right')) { final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : ''; resolvedInstruction = 'Turn left$roadSuffix'; } else if (resolvedType.isRight && resolvedInstruction.toLowerCase().contains('left')) { final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : ''; resolvedInstruction = 'Turn right$roadSuffix'; } return (type: resolvedType, instruction: resolvedInstruction); } static String _buildManeuverInstruction({ required String stepType, required String? turn, required String? road, required String? rawInstruction, required ManeuverType maneuverType, }) { if (rawInstruction != null && rawInstruction.trim().isNotEmpty && rawInstruction.trim().toLowerCase() != 'continue') { return rawInstruction.trim(); } final roadSuffix = (road != null && road.trim().isNotEmpty) ? ' onto $road' : ''; switch (maneuverType) { case ManeuverType.depart: return roadSuffix.isNotEmpty ? 'Head out$roadSuffix' : 'Head towards destination'; case ManeuverType.arrive: return 'Arrive at destination'; case ManeuverType.turnRight: return 'Turn right$roadSuffix'; case ManeuverType.turnLeft: return 'Turn left$roadSuffix'; case ManeuverType.turnSharpRight: return 'Turn sharp right$roadSuffix'; case ManeuverType.turnSharpLeft: return 'Turn sharp left$roadSuffix'; case ManeuverType.turnSlightRight: return 'Turn slight right$roadSuffix'; case ManeuverType.turnSlightLeft: return 'Turn slight left$roadSuffix'; case ManeuverType.uTurn: return 'Make a U-turn$roadSuffix'; case ManeuverType.ramp: final side = (turn?.toLowerCase() == 'left') ? ' on the left' : ' on the right'; return 'Take the ramp$side$roadSuffix'; case ManeuverType.keepRight: return 'Keep right$roadSuffix'; case ManeuverType.keepLeft: return 'Keep left$roadSuffix'; case ManeuverType.enterRoundabout: return 'Enter roundabout$roadSuffix'; case ManeuverType.exitRoundabout: return 'Exit roundabout$roadSuffix'; case ManeuverType.continueStraight: default: return roadSuffix.isNotEmpty ? 'Continue$roadSuffix' : 'Continue straight'; } } 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 == 'keep') { if (turn.contains('left')) return ManeuverType.keepLeft; if (turn.contains('right')) return ManeuverType.keepRight; return ManeuverType.continueStraight; } if (t == 'exit') { if (turn.contains('left')) return ManeuverType.turnLeft; return ManeuverType.turnRight; } 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; } static double _distanceMeters(AppLatLng p1, AppLatLng p2) { const double r = 6371000.0; final lat1 = p1.latitude * (math.pi / 180.0); final lat2 = p2.latitude * (math.pi / 180.0); final dLat = (p2.latitude - p1.latitude) * (math.pi / 180.0); final dLon = (p2.longitude - p1.longitude) * (math.pi / 180.0); final a = math.sin(dLat / 2) * math.sin(dLat / 2) + math.cos(lat1) * math.cos(lat2) * math.sin(dLon / 2) * math.sin(dLon / 2); final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); return r * c; } } class _CandidateRoute { final double distanceMeters; final int durationSeconds; final List geometry; final double curvature; final List maneuvers; _CandidateRoute({ required this.distanceMeters, required this.durationSeconds, required this.geometry, required this.curvature, required this.maneuvers, }); }