969 lines
34 KiB
Dart
969 lines
34 KiB
Dart
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<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 coarse locality and region/state — strictly avoid street addresses or exact house numbers
|
|
final locality = address['Locality']?.toString().trim();
|
|
final regionStr = address['Region']?.toString().trim();
|
|
final subRegion = address['SubRegion']?.toString().trim();
|
|
|
|
if (locality != null &&
|
|
locality.isNotEmpty &&
|
|
regionStr != null &&
|
|
regionStr.isNotEmpty) {
|
|
return '$locality, $regionStr';
|
|
} else if (locality != null && locality.isNotEmpty) {
|
|
return locality;
|
|
} else if (subRegion != null &&
|
|
subRegion.isNotEmpty &&
|
|
regionStr != null &&
|
|
regionStr.isNotEmpty) {
|
|
return '$subRegion, $regionStr';
|
|
} else if (regionStr != null && regionStr.isNotEmpty) {
|
|
return regionStr;
|
|
}
|
|
return null;
|
|
} 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,
|
|
});
|
|
}
|