Miler rider app: surface system, visible design language, backend lifecycle

Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 05:40:35 +05:30
parent c8350563d9
commit d7348e253f
387 changed files with 80693 additions and 12272 deletions

View File

@@ -0,0 +1,385 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show listEquals;
import 'package:flutter/material.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'map_config.dart';
import 'map_marker_icons.dart';
export 'package:maplibre_gl/maplibre_gl.dart'
show
LatLng,
LatLngBounds,
CameraPosition,
CameraUpdate,
MyLocationTrackingMode;
/// Where a marker bitmap sits relative to its coordinate.
enum MarkerAnchor { center, bottom }
@immutable
class DoormileMapMarker {
const DoormileMapMarker({
required this.id,
required this.position,
required this.icon,
this.anchor = MarkerAnchor.bottom,
this.size = 1.0,
this.onTap,
});
final String id;
final LatLng position;
final MarkerIcon icon;
final MarkerAnchor anchor;
final double size;
final VoidCallback? onTap;
/// Identity for diffing. Deliberately excludes [onTap] — a new closure on
/// every rebuild must not force the marker to be torn down and re-added.
@override
bool operator ==(Object other) =>
other is DoormileMapMarker &&
other.id == id &&
other.position.latitude == position.latitude &&
other.position.longitude == position.longitude &&
other.icon == icon &&
other.anchor == anchor &&
other.size == size;
@override
int get hashCode => Object.hash(
id,
position.latitude,
position.longitude,
icon,
anchor,
size,
);
}
@immutable
class DoormileMapPolyline {
const DoormileMapPolyline({
required this.id,
required this.points,
required this.color,
this.width = 5.0,
});
final String id;
final List<LatLng> points;
final Color color;
final double width;
@override
bool operator ==(Object other) =>
other is DoormileMapPolyline &&
other.id == id &&
other.color == color &&
other.width == width &&
other.points.length == points.length &&
(points.isEmpty ||
(other.points.first.latitude == points.first.latitude &&
other.points.first.longitude == points.first.longitude &&
other.points.last.latitude == points.last.latitude &&
other.points.last.longitude == points.last.longitude));
@override
int get hashCode => Object.hash(id, color, width, points.length);
}
/// Thin, crash-tolerant facade over [MapLibreMapController].
class DoormileMapController {
DoormileMapController._(this._raw, this._isAlive);
final MapLibreMapController _raw;
final bool Function() _isAlive;
/// Escape hatch for anything this facade does not wrap.
MapLibreMapController get raw => _raw;
/// Frames [bounds] with uniform [padding] in logical pixels.
///
/// Degenerate bounds (a single point, or a perfectly horizontal/vertical
/// pair) make the native fit routine fail, so they are nudged outward first.
Future<void> fitBounds(LatLngBounds bounds, {double padding = 80}) async {
if (!_isAlive()) return;
const double minSpan = 0.0015; // ~165 m
var south = bounds.southwest.latitude;
var north = bounds.northeast.latitude;
var west = bounds.southwest.longitude;
var east = bounds.northeast.longitude;
if (north - south < minSpan) {
final mid = (north + south) / 2;
south = mid - minSpan / 2;
north = mid + minSpan / 2;
}
if (east - west < minSpan) {
final mid = (east + west) / 2;
west = mid - minSpan / 2;
east = mid + minSpan / 2;
}
await _guard(
() => _raw.animateCamera(
CameraUpdate.newLatLngBounds(
LatLngBounds(
southwest: LatLng(south, west),
northeast: LatLng(north, east),
),
left: padding,
top: padding,
right: padding,
bottom: padding,
),
),
);
}
Future<void> animateCamera(CameraUpdate update) =>
_guard(() => _raw.animateCamera(update));
Future<void> moveCamera(CameraUpdate update) =>
_guard(() => _raw.moveCamera(update));
Future<void> _guard(Future<void> Function() action) async {
if (!_isAlive()) return;
try {
await action();
} catch (e) {
debugPrint('[DOORMILE_MAP] Camera command skipped: $e');
}
}
}
/// The app's single map surface, backed by MapLibre + OpenStreetMap vector
/// tiles. No API key is involved anywhere in this widget.
///
/// Markers and polylines are declarative: pass new lists and the widget diffs
/// them against what is on the map, updating only what actually changed. That
/// matters on the navigation screen, where the rider marker moves every few
/// metres and a clear-and-re-add would flicker.
class DoormileMap extends StatefulWidget {
const DoormileMap({
super.key,
required this.initialCameraPosition,
this.markers = const [],
this.polylines = const [],
this.myLocationEnabled = false,
this.trackingMode = MyLocationTrackingMode.none,
this.compassEnabled = false,
this.zoomGesturesEnabled = true,
this.scrollGesturesEnabled = true,
this.rotateGesturesEnabled = true,
this.tiltGesturesEnabled = true,
this.onMapReady,
});
final CameraPosition initialCameraPosition;
final List<DoormileMapMarker> markers;
final List<DoormileMapPolyline> polylines;
final bool myLocationEnabled;
final MyLocationTrackingMode trackingMode;
final bool compassEnabled;
final bool zoomGesturesEnabled;
final bool scrollGesturesEnabled;
final bool rotateGesturesEnabled;
final bool tiltGesturesEnabled;
final void Function(DoormileMapController controller)? onMapReady;
@override
State<DoormileMap> createState() => _DoormileMapState();
}
class _DoormileMapState extends State<DoormileMap> {
MapLibreMapController? _controller;
DoormileMapController? _facade;
bool _styleLoaded = false;
bool _disposed = false;
final Set<String> _registeredIcons = {};
final Map<String, Symbol> _symbols = {};
final Map<String, DoormileMapMarker> _markerById = {};
final Map<String, Line> _lines = {};
final Map<String, DoormileMapPolyline> _polylineById = {};
bool _syncing = false;
bool _syncQueued = false;
@override
void dispose() {
_disposed = true;
_controller?.onSymbolTapped.remove(_onSymbolTapped);
super.dispose();
}
bool _isAlive() => !_disposed && mounted && _styleLoaded;
Future<void> _onStyleLoaded() async {
final controller = _controller;
if (controller == null || _disposed) return;
_styleLoaded = true;
// Delivery pins overlap constantly in dense areas; without this MapLibre
// silently hides the collided ones.
try {
await controller.setSymbolIconAllowOverlap(true);
await controller.setSymbolIconIgnorePlacement(true);
} catch (e) {
debugPrint('[DOORMILE_MAP] Overlap config skipped: $e');
}
await _sync();
final facade = _facade;
if (facade != null && !_disposed) widget.onMapReady?.call(facade);
}
@override
void didUpdateWidget(covariant DoormileMap oldWidget) {
super.didUpdateWidget(oldWidget);
final markersChanged = !listEquals(oldWidget.markers, widget.markers);
final linesChanged = !listEquals(oldWidget.polylines, widget.polylines);
if (markersChanged || linesChanged) _sync();
}
/// Reconciles the widget's markers/polylines with what is on the map.
Future<void> _sync() async {
final controller = _controller;
if (controller == null || !_styleLoaded || _disposed) return;
// Native annotation calls are not re-entrant; coalesce overlapping syncs.
if (_syncing) {
_syncQueued = true;
return;
}
_syncing = true;
try {
await _syncPolylines(controller);
await _syncMarkers(controller);
} catch (e) {
debugPrint('[DOORMILE_MAP] Annotation sync failed: $e');
} finally {
_syncing = false;
if (_syncQueued && !_disposed) {
_syncQueued = false;
unawaited(_sync());
}
}
}
Future<void> _syncPolylines(MapLibreMapController controller) async {
final desired = {for (final p in widget.polylines) p.id: p};
for (final id in _lines.keys.toList()) {
if (desired.containsKey(id)) continue;
final line = _lines.remove(id);
_polylineById.remove(id);
if (line != null) await controller.removeLine(line);
}
for (final polyline in widget.polylines) {
if (polyline.points.length < 2) continue;
final existing = _lines[polyline.id];
final options = LineOptions(
geometry: polyline.points,
lineColor: _hex(polyline.color),
lineWidth: polyline.width,
lineOpacity: 1.0,
lineJoin: 'round',
);
if (existing == null) {
_lines[polyline.id] = await controller.addLine(options);
} else if (_polylineById[polyline.id] != polyline) {
await controller.updateLine(existing, options);
}
_polylineById[polyline.id] = polyline;
}
}
Future<void> _syncMarkers(MapLibreMapController controller) async {
for (final marker in widget.markers) {
if (_registeredIcons.add(marker.icon.key)) {
try {
await controller.addImage(marker.icon.key, marker.icon.bytes);
} catch (e) {
_registeredIcons.remove(marker.icon.key);
debugPrint('[DOORMILE_MAP] Icon registration failed: $e');
}
}
}
final desired = {for (final m in widget.markers) m.id: m};
for (final id in _symbols.keys.toList()) {
if (desired.containsKey(id)) continue;
final symbol = _symbols.remove(id);
_markerById.remove(id);
if (symbol != null) await controller.removeSymbol(symbol);
}
for (final marker in widget.markers) {
final options = SymbolOptions(
geometry: marker.position,
iconImage: marker.icon.key,
iconSize: marker.size,
iconAnchor: marker.anchor == MarkerAnchor.bottom ? 'bottom' : 'center',
);
final existing = _symbols[marker.id];
if (existing == null) {
_symbols[marker.id] = await controller.addSymbol(options, {
'doormileId': marker.id,
});
} else if (_markerById[marker.id] != marker) {
await controller.updateSymbol(existing, options);
}
_markerById[marker.id] = marker;
}
}
void _onSymbolTapped(Symbol symbol) {
final id = symbol.data?['doormileId'];
if (id is! String) return;
// Read the tap handler from the current widget, not the cached marker, so
// it always closes over fresh state.
for (final marker in widget.markers) {
if (marker.id == id) {
marker.onTap?.call();
return;
}
}
}
static String _hex(Color color) =>
'#${color.toARGB32().toRadixString(16).padLeft(8, '0').substring(2)}';
@override
Widget build(BuildContext context) {
return MapLibreMap(
styleString: MapConfig.styleUrl,
initialCameraPosition: widget.initialCameraPosition,
myLocationEnabled: widget.myLocationEnabled,
myLocationTrackingMode: widget.trackingMode,
compassEnabled: widget.compassEnabled,
zoomGesturesEnabled: widget.zoomGesturesEnabled,
scrollGesturesEnabled: widget.scrollGesturesEnabled,
rotateGesturesEnabled: widget.rotateGesturesEnabled,
tiltGesturesEnabled: widget.tiltGesturesEnabled,
trackCameraPosition: true,
onMapCreated: (controller) {
_controller = controller;
_facade = DoormileMapController._(controller, _isAlive);
controller.onSymbolTapped.add(_onSymbolTapped);
},
onStyleLoadedCallback: _onStyleLoaded,
);
}
}

View File

@@ -0,0 +1,44 @@
/// Map and routing endpoints.
///
/// Everything here is open source and keyless:
///
/// * **Tiles** — OpenFreeMap (<https://openfreemap.org>), OpenStreetMap data
/// built with Planetiler and served as MapLibre vector tiles. No API key, no
/// registration, no request limits, MIT licensed. MapLibre adds the required
/// attribution automatically.
/// * **Routing** — OSRM (<https://project-osrm.org>), the Open Source Routing
/// Machine. Also keyless.
///
/// Both are overridable at build time so production can point at self-hosted
/// instances without a code change:
///
/// flutter build apk \
/// --dart-define=OSRM_BASE_URL=https://osrm.internal.doormile.in \
/// --dart-define=MAP_STYLE_URL=https://tiles.internal.doormile.in/styles/liberty
class MapConfig {
const MapConfig._();
/// MapLibre style document. Passed straight to the map — no key to inject,
/// so unlike a commercial provider there is nothing to patch or proxy.
///
/// OpenFreeMap styles: `liberty` (detailed), `bright`, `positron` (muted).
static const String styleUrl = String.fromEnvironment(
'MAP_STYLE_URL',
defaultValue: 'https://tiles.openfreemap.org/styles/liberty',
);
/// Base URL of an OSRM instance, without a trailing slash.
///
/// The default is the FOSSGIS community server. It is free and needs no key,
/// but it is donation-funded infrastructure with a fair-use policy — fine for
/// development, NOT appropriate for a production delivery fleet. Point this
/// at your own OSRM before shipping; see `deploy/osrm/README.md`.
static const String osrmBaseUrl = String.fromEnvironment(
'OSRM_BASE_URL',
defaultValue: 'https://routing.openstreetmap.de/routed-car',
);
/// True when routing points somewhere other than the shared community server.
static bool get usingOwnRoutingServer =>
!osrmBaseUrl.contains('routing.openstreetmap.de');
}

View File

@@ -0,0 +1,241 @@
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
/// A marker bitmap plus a stable identity for it.
///
/// MapLibre registers images on the style by name, so every distinct bitmap
/// needs a key that is equal whenever the pixels are equal.
@immutable
class MarkerIcon {
const MarkerIcon(this.key, this.bytes);
final String key;
final Uint8List bytes;
@override
bool operator ==(Object other) => other is MarkerIcon && other.key == key;
@override
int get hashCode => key.hashCode;
}
/// Marker bitmaps drawn at runtime with a canvas.
///
/// MapLibre has no equivalent of Google's `BitmapDescriptor.defaultMarker*`,
/// so the pins the app used to get for free are drawn here instead. Everything
/// is generated in-process — no new image assets, no network fetch.
class MapMarkerIcons {
const MapMarkerIcons._();
static final Map<String, MarkerIcon> _cache = {};
/// Classic teardrop pin, anchored at its tip.
static Future<MarkerIcon> pin(Color color) =>
_memo('pin_${color.toARGB32()}', () => _drawPin(color));
/// Filled circle with a white ring — used for the rider's own position.
static Future<MarkerIcon> dot(Color color) =>
_memo('dot_${color.toARGB32()}', () => _drawDot(color));
/// Numbered step marker for the multi-stop route screen.
static Future<MarkerIcon> numberedCircle(
int number, {
required Color primaryColor,
bool isSkipped = false,
bool isNext = false,
}) => _memo(
'step_${number}_${primaryColor.toARGB32()}_${isSkipped}_$isNext',
() => _drawNumberedCircle(
number,
primaryColor: primaryColor,
isSkipped: isSkipped,
isNext: isNext,
),
);
static Future<MarkerIcon> _memo(
String key,
Future<Uint8List> Function() build,
) async {
final cached = _cache[key];
if (cached != null) return cached;
return _cache[key] = MarkerIcon(key, await build());
}
static Future<Uint8List> _toBytes(
ui.PictureRecorder recorder,
int width,
int height,
) async {
final image = await recorder.endRecording().toImage(width, height);
final data = await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
return data!.buffer.asUint8List();
}
static Future<Uint8List> _drawPin(Color color) async {
const double width = 72;
const double height = 96;
const double radius = 27;
const center = Offset(width / 2, radius + 4);
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final body = Paint()
..color = color
..isAntiAlias = true;
final outline = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 4
..isAntiAlias = true;
// Tail: a curve from the circle's lower flanks down to the anchor point.
final tail = Path()
..moveTo(center.dx - radius + 4, center.dy + radius - 9)
..quadraticBezierTo(center.dx - 6, height - 14, center.dx, height - 4)
..quadraticBezierTo(
center.dx + 6,
height - 14,
center.dx + radius - 4,
center.dy + radius - 9,
)
..close();
canvas.drawPath(tail, body);
canvas.drawCircle(center, radius, body);
canvas.drawCircle(center, radius, outline);
canvas.drawCircle(center, 9, Paint()..color = Colors.white);
return _toBytes(recorder, width.toInt(), height.toInt());
}
static Future<Uint8List> _drawDot(Color color) async {
const double size = 48;
const center = Offset(size / 2, size / 2);
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.drawCircle(
center,
20,
Paint()
..color = color.withValues(alpha: 0.25)
..isAntiAlias = true,
);
canvas.drawCircle(
center,
12,
Paint()
..color = Colors.white
..isAntiAlias = true,
);
canvas.drawCircle(
center,
9,
Paint()
..color = color
..isAntiAlias = true,
);
return _toBytes(recorder, size.toInt(), size.toInt());
}
/// Preserves the exact look of the previous Google Maps step marker.
static Future<Uint8List> _drawNumberedCircle(
int number, {
required Color primaryColor,
required bool isSkipped,
required bool isNext,
}) async {
const double size = 70;
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
const center = Offset(size / 2, size / 2);
final paint = Paint()..color = isSkipped ? Colors.orange : primaryColor;
canvas.drawCircle(center, 15, paint);
final border = Paint()
..color = isSkipped ? Colors.orange.shade900 : Colors.white
..style = PaintingStyle.stroke
..strokeWidth = isSkipped ? 4 : 3;
canvas.drawCircle(center, 15, border);
final textPainter = TextPainter(
text: TextSpan(
text: number.toString(),
style: const TextStyle(
fontSize: 19,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(
center.dx - textPainter.width / 2,
center.dy - textPainter.height / 2,
),
);
if (isNext) {
final signPaint = Paint()
..color = Colors.green
..style = PaintingStyle.fill;
final signRect = RRect.fromRectAndRadius(
Rect.fromCenter(
center: const Offset(size / 2, 8),
width: 45,
height: 30,
),
const Radius.circular(4),
);
canvas.drawRRect(signRect, signPaint);
final signBorder = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
canvas.drawRRect(signRect, signBorder);
final nextTextPainter = TextPainter(
text: const TextSpan(
text: 'NEXT',
style: TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
nextTextPainter.layout();
nextTextPainter.paint(
canvas,
Offset(size / 2 - nextTextPainter.width / 2, 4),
);
final arrowPath = Path()
..moveTo(size / 2, 18)
..lineTo(size / 2 - 4, 24)
..lineTo(size / 2 + 4, 24)
..close();
canvas.drawPath(
arrowPath,
Paint()
..color = Colors.white
..style = PaintingStyle.fill,
);
}
return _toBytes(recorder, size.toInt(), size.toInt());
}
}

View File

@@ -0,0 +1,333 @@
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;
}