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 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; }