Files
doormile_milderapp/lib/views/Dashboard/pickups/route_metrics.dart
2026-08-11 13:16:33 +05:30

190 lines
7.5 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,
}) {
final stopLat = _toDouble(stop['pickuplat'] ?? stop['PickupLat']);
final stopLng = _toDouble(stop['pickuplon'] ?? stop['PickupLon']);
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;
}