345 lines
11 KiB
Dart
345 lines
11 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
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,
|
|
}) 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.',
|
|
);
|
|
}
|
|
|
|
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 bodyMap = <String, dynamic>{
|
|
'Origin': origin,
|
|
'Destination': destination,
|
|
'TravelMode': 'Car',
|
|
'LegGeometryFormat': 'Simple',
|
|
};
|
|
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 straightLine = MotorSignalRoute.calculateTotalDistanceMeters(ordered);
|
|
|
|
return RouteCalculationResult(
|
|
straightLineDistanceMeters: straightLine,
|
|
estimatedDistanceMeters:
|
|
distanceMeters > 0 ? distanceMeters : straightLine,
|
|
estimatedDurationSeconds: durationSeconds > 0 ? durationSeconds : null,
|
|
geometry: geometry.isNotEmpty
|
|
? geometry
|
|
: ordered.map((s) => s.latLng).toList(),
|
|
isStraightLine: geometry.isEmpty,
|
|
providerName: name,
|
|
);
|
|
} catch (e) {
|
|
if (e is RouteProviderException) rethrow;
|
|
throw RouteProviderException(
|
|
'routeCalculationFailed',
|
|
'AWS route calculation failed: $e',
|
|
);
|
|
}
|
|
}
|
|
}
|