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:
385
lib/xpress/services/maps/doormile_map.dart
Normal file
385
lib/xpress/services/maps/doormile_map.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
44
lib/xpress/services/maps/map_config.dart
Normal file
44
lib/xpress/services/maps/map_config.dart
Normal 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');
|
||||
}
|
||||
241
lib/xpress/services/maps/map_marker_icons.dart
Normal file
241
lib/xpress/services/maps/map_marker_icons.dart
Normal 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());
|
||||
}
|
||||
}
|
||||
333
lib/xpress/services/maps/routing_service.dart
Normal file
333
lib/xpress/services/maps/routing_service.dart
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user