Files
Xpress-rider/lib/services/maps/routing_service.dart
2026-08-12 10:50:45 +05:30

333 lines
10 KiB
Dart

import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:maplibre_gl/maplibre_gl.dart';
import 'map_config.dart';
/// A road route returned by the routing backend.
class RouteResult {
const RouteResult({
required this.points,
required this.distanceMeters,
required this.durationSeconds,
});
/// Decoded geometry of the route, ready to draw as a polyline.
final List<LatLng> points;
/// Total road distance in metres.
final double distanceMeters;
/// Total travel time in seconds.
final double durationSeconds;
double get distanceKm => distanceMeters / 1000.0;
bool get isEmpty => points.isEmpty && distanceMeters == 0;
}
/// Road routing via OSRM — the Open Source Routing Machine.
///
/// Replaces the Google Directions API (and `flutter_polyline_points`). OSRM is
/// keyless and self-hostable, so there is no credential to ship and no
/// per-request billing.
///
/// Request shape:
/// GET {base}/route/v1/driving/{lon},{lat};{lon},{lat}?overview=full&geometries=polyline
///
/// Note OSRM takes coordinates as **lon,lat** — the reverse of most APIs.
class RoutingService {
const RoutingService._();
static const Duration _timeout = Duration(seconds: 10);
static const Duration _cacheTtl = Duration(minutes: 30);
static const int _maxCacheEntries = 120;
static final Map<String, _CacheEntry> _cache = {};
/// Computes a driving route. [waypoints] are visited in order between
/// [origin] and [destination] — OSRM routes through all of them in a single
/// request, so a multi-stop route costs one call, not one per leg.
///
/// Returns null when the service is unreachable or finds no route, so callers
/// can fall back to a straight line.
static Future<RouteResult?> getRoute({
required LatLng origin,
required LatLng destination,
List<LatLng> waypoints = const [],
String profile = 'driving',
}) async {
if (!_isValid(origin) || !_isValid(destination)) {
debugPrint('[ROUTING] Invalid coordinates, skipping request.');
return null;
}
final cacheKey = _cacheKey(origin, destination, waypoints, profile);
final cached = _cache[cacheKey];
if (cached != null && !cached.isExpired) return cached.result;
// OSRM wants lon,lat pairs joined by ';' in the path itself.
final coordinates = [
origin,
...waypoints,
destination,
].map((p) => '${p.longitude},${p.latitude}').join(';');
final uri = Uri.parse(
'${MapConfig.osrmBaseUrl}/route/v1/$profile/$coordinates',
).replace(
queryParameters: const {
'overview': 'full',
'geometries': 'polyline',
'alternatives': 'false',
'steps': 'false',
},
);
try {
final response = await http.get(uri).timeout(_timeout);
if (response.statusCode != 200) {
debugPrint('[ROUTING] HTTP ${response.statusCode}');
return null;
}
final result = _parse(json.decode(response.body));
if (result == null || result.isEmpty) {
debugPrint('[ROUTING] No route in response.');
return null;
}
_store(cacheKey, result);
return result;
} catch (e) {
debugPrint('[ROUTING] Request failed: $e');
return null;
}
}
/// Convenience wrapper for callers that only need road distance.
static Future<double?> getRouteDistanceKm({
required LatLng origin,
required LatLng destination,
}) async {
final route = await getRoute(origin: origin, destination: destination);
return route?.distanceKm;
}
// ---------------------------------------------------------------------
// Response parsing
// ---------------------------------------------------------------------
/// Parses an OSRM `route` response. Written tolerantly so a self-hosted
/// build, a proxy, or a drop-in alternative (Valhalla's OSRM-compatible
/// endpoint, GraphHopper's) does not require code changes: numeric fields are
/// accepted as plain numbers or as `{"value": n}` objects, and the geometry
/// is looked for under several key names.
static RouteResult? _parse(dynamic body) {
if (body is! Map) return null;
final code = body['code'];
if (code is String && code != 'Ok') {
debugPrint('[ROUTING] Service returned code: $code');
return null;
}
final routes = body['routes'];
if (routes is! List || routes.isEmpty) return null;
final route = routes.first;
if (route is! Map) return null;
var distance = _num(route['distance']);
var duration = _num(route['duration']);
// Fall back to summing legs when totals are absent.
if (distance == 0 || duration == 0) {
final legs = route['legs'];
if (legs is List) {
var legDistance = 0.0;
var legDuration = 0.0;
for (final leg in legs) {
if (leg is! Map) continue;
legDistance += _num(leg['distance']);
legDuration += _num(leg['duration']);
}
if (distance == 0) distance = legDistance;
if (duration == 0) duration = legDuration;
}
}
final points = _extractGeometry(route);
if (points.isEmpty && distance == 0) return null;
return RouteResult(
points: points,
distanceMeters: distance,
durationSeconds: duration,
);
}
static List<LatLng> _extractGeometry(Map route) {
for (final key in const ['geometry', 'overview_polyline', 'polyline']) {
final value = route[key];
if (value is String && value.isNotEmpty) {
final decoded = decodePolyline(value);
if (decoded.isNotEmpty) return decoded;
}
// Encoded polyline nested as {"points": "<encoded>"}.
if (value is Map && value['points'] is String) {
final decoded = decodePolyline(value['points'] as String);
if (decoded.isNotEmpty) return decoded;
}
// GeoJSON LineString, if the caller ever requests geometries=geojson.
if (value is Map && value['coordinates'] is List) {
final coords = value['coordinates'] as List;
final points = <LatLng>[];
for (final c in coords) {
if (c is List && c.length >= 2) {
points.add(LatLng(_num(c[1]), _num(c[0])));
}
}
if (points.isNotEmpty) return points;
}
}
// Last resort: stitch the per-step geometries together.
final points = <LatLng>[];
final legs = route['legs'];
if (legs is List) {
for (final leg in legs) {
if (leg is! Map) continue;
final steps = leg['steps'];
if (steps is! List) continue;
for (final step in steps) {
if (step is! Map) continue;
final geometry = step['geometry'] ?? step['polyline'];
final encoded = geometry is Map ? geometry['points'] : geometry;
if (encoded is String && encoded.isNotEmpty) {
points.addAll(decodePolyline(encoded));
}
}
}
}
return points;
}
static double _num(dynamic value) {
if (value == null) return 0.0;
if (value is num) return value.toDouble();
if (value is Map) return _num(value['value']);
return double.tryParse(value.toString()) ?? 0.0;
}
// ---------------------------------------------------------------------
// Polyline codec (Google's Encoded Polyline Algorithm)
//
// OSRM's default `geometries=polyline` uses precision 5. Its `polyline6`
// option uses precision 6 — pass `precision: 6` if you switch to it.
// ---------------------------------------------------------------------
static List<LatLng> decodePolyline(String encoded, {int precision = 5}) {
final points = <LatLng>[];
final factor = _pow10(precision);
var index = 0;
var lat = 0;
var lng = 0;
while (index < encoded.length) {
final dLat = _decodeValue(encoded, index);
if (dLat == null) break;
index = dLat.nextIndex;
lat += dLat.value;
final dLng = _decodeValue(encoded, index);
if (dLng == null) break;
index = dLng.nextIndex;
lng += dLng.value;
points.add(LatLng(lat / factor, lng / factor));
}
return points;
}
static _Delta? _decodeValue(String encoded, int index) {
var shift = 0;
var result = 0;
int byte;
do {
if (index >= encoded.length) return null;
byte = encoded.codeUnitAt(index++) - 63;
result |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
final value = (result & 1) != 0 ? ~(result >> 1) : (result >> 1);
return _Delta(value, index);
}
static double _pow10(int exponent) {
var value = 1.0;
for (var i = 0; i < exponent; i++) {
value *= 10;
}
return value;
}
// ---------------------------------------------------------------------
// Cache
// ---------------------------------------------------------------------
static bool _isValid(LatLng p) =>
p.latitude != 0 &&
p.longitude != 0 &&
p.latitude.abs() <= 90 &&
p.longitude.abs() <= 180;
static String _cacheKey(
LatLng origin,
LatLng destination,
List<LatLng> waypoints,
String profile,
) {
// ~11 m resolution: identical pickup/drop pairs reuse one request.
String round(LatLng p) =>
'${p.latitude.toStringAsFixed(4)},${p.longitude.toStringAsFixed(4)}';
return [
profile,
round(origin),
round(destination),
...waypoints.map(round),
].join('>');
}
static void _store(String key, RouteResult result) {
if (_cache.length >= _maxCacheEntries) {
final oldest = _cache.entries.reduce(
(a, b) => a.value.storedAt.isBefore(b.value.storedAt) ? a : b,
);
_cache.remove(oldest.key);
}
_cache[key] = _CacheEntry(result, DateTime.now());
}
@visibleForTesting
static void clearCache() => _cache.clear();
}
class _Delta {
const _Delta(this.value, this.nextIndex);
final int value;
final int nextIndex;
}
class _CacheEntry {
const _CacheEntry(this.result, this.storedAt);
final RouteResult result;
final DateTime storedAt;
bool get isExpired =>
DateTime.now().difference(storedAt) > RoutingService._cacheTtl;
}