Files
doormile_milderapp/lib/views/Dashboard/pickups/route_metrics.dart
Thiru-tenext d7348e253f 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>
2026-08-22 05:40:35 +05:30

212 lines
8.6 KiB
Dart

import 'dart:math' as math;
/// Live per-stop route metrics — how far away the next stop is, and when the
/// rider will get there.
///
/// Why this is its own file: distance was previously formatted in three
/// different places with three different rules, and always in kilometres. A
/// stop 80 metres away rendered as `0.1 km`, which is useless when you are
/// looking for a gate. Riders think in **metres up close and kilometres far
/// away**, so the unit has to switch at the threshold they actually use.
///
/// Everything here is pure — no Flutter, no I/O — so it is unit-testable and
/// safe to call from `build`.
class RouteMetricsHelper {
RouteMetricsHelper._();
/// Below this many metres, show metres. At or above it, show kilometres.
static const double kMetreThreshold = 1000;
/// Fallback speed when the rider's live speed is unknown or unusable:
/// 20 km/h, a realistic urban two-wheeler average once lights, turns and
/// traffic are counted. Riding speed alone would promise ETAs he can't hit.
static const double kDefaultSpeedMps = 5.55; // 20 km/h
/// Speeds below this are noise (stopped at a light, GPS jitter) and must not
/// drive an ETA — at 0.5 m/s a 2 km leg would read as over an hour.
static const double kMinUsableSpeedMps = 1.5; // ~5.4 km/h
/// Clamp on live speed. A GPS spike of 200 km/h on a bike is a bad fix, not
/// a fast rider, and would promise an ETA that has already passed.
static const double kMaxUsableSpeedMps = 22.0; // ~79 km/h
// ── Distance ───────────────────────────────────────────────────────────
/// Great-circle distance between two coordinates, in metres.
static double distanceMeters(
double fromLat,
double fromLng,
double toLat,
double toLng,
) {
const earthRadiusM = 6371000.0;
final dLat = _rad(toLat - fromLat);
final dLng = _rad(toLng - fromLng);
final h =
math.sin(dLat / 2) * math.sin(dLat / 2) +
math.cos(_rad(fromLat)) *
math.cos(_rad(toLat)) *
math.sin(dLng / 2) *
math.sin(dLng / 2);
return earthRadiusM * 2 * math.atan2(math.sqrt(h), math.sqrt(1 - h));
}
/// Formats a distance the way a rider reads it.
///
/// • `< 1000 m` → whole metres, rounded to 10 (`450 m`, `80 m`)
/// • `>= 1000 m` → kilometres with one decimal (`3.2 km`, `12.0 km`)
/// • `>= 100 km` → whole kilometres (`104 km`) — the decimal is noise
///
/// Returns `'—'` for unknown/zero, never a misleading `0 m`.
static String formatDistance(double? meters) {
if (meters == null || meters.isNaN || meters <= 0) return '—';
if (meters < kMetreThreshold) {
// Round to the nearest 10 m — GPS is not accurate to the metre, and
// "450 m" is easier to hold in your head than "447 m".
final rounded = (meters / 10).round() * 10;
return '${rounded < 10 ? 10 : rounded} m';
}
final km = meters / 1000;
if (km >= 100) return '${km.round()} km';
return '${km.toStringAsFixed(1)} km';
}
/// Distance with the trailing word riders expect on a card: `450 m away`.
static String formatDistanceAway(double? meters) {
final d = formatDistance(meters);
return d == '—' ? 'Distance unknown' : '$d away';
}
// ── Time ───────────────────────────────────────────────────────────────
/// Usable travel speed in m/s, given the rider's live speed.
///
/// Falls back to [kDefaultSpeedMps] whenever the live reading is missing,
/// too slow to be movement, or too fast to be real.
static double usableSpeedMps(double? liveSpeedMps) {
if (liveSpeedMps == null || liveSpeedMps.isNaN) return kDefaultSpeedMps;
if (liveSpeedMps < kMinUsableSpeedMps) return kDefaultSpeedMps;
if (liveSpeedMps > kMaxUsableSpeedMps) return kDefaultSpeedMps;
return liveSpeedMps;
}
/// Travel time for [meters] at the rider's usable speed.
static Duration travelTime(double? meters, {double? liveSpeedMps}) {
if (meters == null || meters.isNaN || meters <= 0) return Duration.zero;
final seconds = meters / usableSpeedMps(liveSpeedMps);
return Duration(seconds: seconds.round());
}
/// Arrival clock time for [meters] away, from [now] (defaults to real now).
static DateTime? eta(double? meters, {double? liveSpeedMps, DateTime? now}) {
if (meters == null || meters.isNaN || meters <= 0) return null;
return (now ?? DateTime.now()).add(
travelTime(meters, liveSpeedMps: liveSpeedMps),
);
}
/// ETA as a clock time — `4:35 PM`. Returns `'—'` when it can't be computed.
static String formatEta(
double? meters, {
double? liveSpeedMps,
DateTime? now,
}) {
final arrival = eta(meters, liveSpeedMps: liveSpeedMps, now: now);
if (arrival == null) return '—';
return formatClock(arrival);
}
/// `14:05` → `2:05 PM`.
static String formatClock(DateTime t) {
final hour12 = t.hour % 12 == 0 ? 12 : t.hour % 12;
final minute = t.minute.toString().padLeft(2, '0');
return '$hour12:$minute ${t.hour < 12 ? 'AM' : 'PM'}';
}
/// Duration as a rider reads a clock: `4 min`, `1h 05m`.
static String formatDuration(Duration d) {
if (d.inSeconds <= 0) return '—';
if (d.inMinutes < 1) return '1 min';
if (d.inMinutes < 60) return '${d.inMinutes} min';
final minutes = d.inMinutes.remainder(60).toString().padLeft(2, '0');
return '${d.inHours}h ${minutes}m';
}
/// The one-line strip shown on every stop card:
/// `450 m away · ETA 4:35 PM`. Degrades to whichever half is known.
static String formatStrip(
double? meters, {
double? liveSpeedMps,
DateTime? now,
}) {
final distance = formatDistanceAway(meters);
final arrival = formatEta(meters, liveSpeedMps: liveSpeedMps, now: now);
if (arrival == '—') return distance;
return '$distance · ETA $arrival';
}
// ── Stop-map convenience ───────────────────────────────────────────────
/// Resolves the distance from the rider to a stop, in metres.
///
/// Prefers a live rider fix; falls back to the coordinates the backend
/// stamped on the booking, then to its precomputed `kms` field. Returns null
/// when nothing usable exists — callers render `—` rather than a fake zero.
static double? metersToStop(
Map<String, dynamic> stop, {
double? riderLat,
double? riderLng,
// ── Which end of the journey is "the stop"? ──
//
// This always read the pickup pair, which on the delivery leg measures
// the ride BACK to the kitchen the rider just left — the number grew as
// he approached the customer's door. The caller knows which leg it is
// rendering (the queue asks `MilkRun.workingKind`); this function cannot,
// so the leg arrives as a flag. Default false keeps every pickup-leg
// caller — Home's cards, the group nodes — exactly as it was.
//
// A delivery row whose payload carries no drop pair still falls back to
// the pickup pair: on a hyperlocal round the stop IS the customer's
// address in many payloads, and a wrong-ish number beats a dash the
// rider cannot plan with.
bool toDrop = false,
}) {
double stopLat = _toDouble(stop['pickuplat'] ?? stop['PickupLat']);
double stopLng = _toDouble(stop['pickuplon'] ?? stop['PickupLon']);
if (toDrop) {
final dLat = _toDouble(stop['droplat'] ?? stop['DropLat']);
final dLng = _toDouble(stop['droplon'] ?? stop['DropLon']);
if (dLat != 0 && dLng != 0) {
stopLat = dLat;
stopLng = dLng;
}
}
final fromLat = (riderLat != null && riderLat != 0)
? riderLat
: _toDouble(stop['riderslat']);
final fromLng = (riderLng != null && riderLng != 0)
? riderLng
: _toDouble(stop['riderslon']);
if (stopLat != 0 && stopLng != 0 && fromLat != 0 && fromLng != 0) {
return distanceMeters(fromLat, fromLng, stopLat, stopLng);
}
// Backend-precomputed kilometres, if it sent any.
final km = _toDouble(stop['kms'] ?? stop['km']);
return km > 0 ? km * 1000 : null;
}
static double _toDouble(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();
final cleaned = v.toString().replaceAll(RegExp(r'[^0-9.\-]'), '');
return double.tryParse(cleaned) ?? 0;
}
static double _rad(double deg) => deg * math.pi / 180.0;
}