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

574 lines
19 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>[];
// 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,
}),
)
.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,
}),
)
.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',
);
}
}
@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 ??
(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 = <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,
'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<dynamic>?;
if (routes == null || routes.isEmpty) {
throw RouteProviderException(
'routeNotFound',
'No route returned by AWS.',
);
}
final route = routes[0];
final summary = route['Summary'] as Map<String, dynamic>?;
final distanceMeters = (summary?['Distance'] as num?)?.toDouble() ?? 0.0;
final durationSeconds = (summary?['Duration'] as num?)?.toInt() ?? 0;
final legs = route['Legs'] as List<dynamic>? ?? [];
final geometry = <AppLatLng>[];
for (final leg in legs) {
final geom = leg['Geometry'] as Map<String, dynamic>?;
final lineString = geom?['LineString'] as List<dynamic>?;
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<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 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<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;
}
}