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>
212 lines
8.6 KiB
Dart
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;
|
|
}
|