MotorSignal-App/lib/services/routes/aws_location_route_provider...

976 lines
35 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
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';
static String? _cleanAddressValue(dynamic value) {
final candidates = value is Map ? [value['Name'], value['Code']] : [value];
for (final candidate in candidates) {
final cleaned = candidate?.toString().trim();
if (cleaned != null && cleaned.isNotEmpty) return cleaned;
}
return null;
}
@visibleForTesting
static String? buildCoarseLocationLabel(Map<String, dynamic> address) {
final locality = _cleanAddressValue(
address['Locality'] ?? address['Municipality'],
);
final region = _cleanAddressValue(address['Region']);
final subRegion = _cleanAddressValue(address['SubRegion']);
final country = _cleanAddressValue(address['Country']);
final adminArea = region ?? subRegion ?? country;
if (locality != null && adminArea != null) {
return '$locality, $adminArea';
}
return locality ?? adminArea;
}
@override
Future<List<PlaceSearchResult>> 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 = <String>{};
final combined = <PlaceSearchResult>[];
// 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<List<PlaceSearchResult>> _searchAutocomplete(
String clean,
List<double> 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<dynamic>? ?? [];
return items.whereType<Map<String, dynamic>>().map((item) {
final title = item['Title']?.toString() ?? '';
final address = item['Address'] as Map<String, dynamic>?;
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<List<PlaceSearchResult>> _searchText(
String clean,
List<double> 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<dynamic>? ?? [];
return items.whereType<Map<String, dynamic>>().map((item) {
final title = item['Title']?.toString() ?? '';
final address = item['Address'] as Map<String, dynamic>?;
final label = address?['Label']?.toString() ?? '';
final pos = item['Position'] as List<dynamic>?;
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<ResolvedPlace> 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<String, dynamic>?;
final label = address?['Label']?.toString() ?? title;
final pos = data['Position'] as List<dynamic>?;
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',
);
}
}
/// Privacy-safe coarse reverse geocode for a geographic point.
///
/// Returns a coarse region label (e.g. "Saratoga, CA") suitable for privacy-safe
/// starting location labels without exposing house numbers, street names, or exact GPS coordinates.
///
/// Returns null if unavailable or if reverse-geocoding fails.
Future<String?> reverseGeocodeCoarse(AppLatLng position) async {
try {
final uri = Uri.parse(
'https://places.geo.$region.amazonaws.com/v2/reverse-geocode?key=$apiKey',
);
final response = await _client
.post(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'QueryPosition': [position.longitude, position.latitude],
'Filter': {
'IncludeCountries': ['USA'],
},
}),
)
.timeout(const Duration(seconds: 6));
if (response.statusCode != 200) return null;
final data = jsonDecode(response.body);
final items = data['ResultItems'] as List<dynamic>? ?? [];
if (items.isEmpty) return null;
final first = items.first as Map<String, dynamic>;
final address = first['Address'] as Map<String, dynamic>?;
if (address == null) return null;
// Extract only coarse locality and administrative area.
return buildCoarseLocationLabel(address);
} catch (_) {
return null;
}
}
@override
Future<RouteCalculationResult> calculateRoute(
List<RouteStop> stops, {
bool avoidHighways = false,
bool avoidTolls = false,
bool avoidFerries = false,
RoutingOptions? options,
}) 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.',
);
}
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 = <String, dynamic>{
if (effectiveAvoidHighways) 'ControlledAccessHighways': true,
if (effectiveAvoidTolls) 'TollRoads': true,
if (effectiveAvoidFerries) 'Ferries': true,
};
final travelOptions = <String, dynamic>{
'Car': {
'Avoid': {
if (effectiveAvoidHighways) 'Highways': true,
if (effectiveAvoidTolls) 'Tolls': true,
if (effectiveAvoidFerries) 'Ferries': true,
},
},
};
final bodyMap = <String, dynamic>{
'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<dynamic>?;
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<String, dynamic>) continue;
final summary = r['Summary'] as Map<String, dynamic>?;
final distanceM = (summary?['Distance'] as num?)?.toDouble() ?? 0.0;
final durationS = (summary?['Duration'] as num?)?.toInt() ?? 0;
final legs = r['Legs'] as List<dynamic>? ?? [];
final geom = <AppLatLng>[];
for (final leg in legs) {
final gMap = leg['Geometry'] as Map<String, dynamic>?;
final lineString = gMap?['LineString'] as List<dynamic>?;
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<String, dynamic>?;
final rLineString = rGMap?['LineString'] as List<dynamic>?;
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<RouteManeuver> _extractManeuvers(
List<dynamic> legs,
List<AppLatLng> geometry,
List<RouteStop> orderedStops,
) {
final maneuvers = <RouteManeuver>[];
for (final leg in legs) {
if (leg is! Map<String, dynamic>) continue;
final legDetails = leg['VehicleLegDetails'] as Map<String, dynamic>?;
final travelSteps = (legDetails?['TravelSteps'] ?? leg['TravelSteps'])
as List<dynamic>? ?? [];
for (final rawStep in travelSteps) {
if (rawStep is! Map<String, dynamic>) 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<String, dynamic>?;
final rampStep = rawStep['RampStepDetails'] as Map<String, dynamic>?;
final keepStep = rawStep['KeepStepDetails'] as Map<String, dynamic>?;
final uTurnStep = rawStep['UTurnStepDetails'] as Map<String, dynamic>?;
final roundaboutStep = rawStep['RoundaboutStepDetails'] as Map<String, dynamic>?;
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<AppLatLng> 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<RouteManeuver> _synthesizeManeuvers(
List<AppLatLng> geometry,
List<RouteStop> orderedStops,
) {
final list = <RouteManeuver>[];
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<AppLatLng> 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<AppLatLng> geometry;
final double curvature;
final List<RouteManeuver> maneuvers;
_CandidateRoute({
required this.distanceMeters,
required this.durationSeconds,
required this.geometry,
required this.curvature,
required this.maneuvers,
});
}