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 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 _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 getRoute({ required LatLng origin, required LatLng destination, List 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 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 _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": ""}. 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 = []; 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 = []; 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 decodePolyline(String encoded, {int precision = 5}) { final points = []; 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 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; }