1233 lines
49 KiB
Dart
1233 lines
49 KiB
Dart
import 'dart:math' as math;
|
||
|
||
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
|
||
import 'package:miler/data/route_order.dart';
|
||
import 'package:miler/data/service_profile.dart';
|
||
import 'package:miler/Models/stop_status.dart';
|
||
import 'package:miler/data/work_domain.dart';
|
||
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
/// A TRIP — one slot's worth of work, as a single unit.
|
||
///
|
||
/// The hub manager does not assign bookings one at a time. He takes every
|
||
/// customer booking that falls inside a slot (say 10:00–13:00), orders them
|
||
/// into a route, and hands the whole thing to one miler. So the rider's
|
||
/// decision is never "do I want this one parcel?" — it is "do I take this
|
||
/// trip?". Home used to show loose bookings, which framed the job as a
|
||
/// marketplace it isn't, and hid the two facts that actually matter: how long
|
||
/// the whole run takes, and where it starts and ends.
|
||
///
|
||
/// A trip is therefore always shaped:
|
||
///
|
||
/// HUB → Stop 1 → Stop 2 → … → Stop N → HUB
|
||
///
|
||
/// with the same hub at both ends. Every duration and distance below includes
|
||
/// the return leg, because the rider is not finished until he is back.
|
||
///
|
||
/// This class is pure — no Flutter, no I/O — so the arithmetic is unit-tested
|
||
/// and safe to call from `build`.
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
/// WHICH TRIP A STOP IS ON
|
||
///
|
||
/// A rider's day is three trips, and which one an order lands on is decided by
|
||
/// **when the work is due**, not by how many buckets happen to have filled up:
|
||
///
|
||
/// morning → Trip 1
|
||
/// afternoon → Trip 2
|
||
/// evening → Trip 3
|
||
///
|
||
/// ── What this replaces ──
|
||
///
|
||
/// Stops were bucketed into rolling three-hour windows aligned to the clock —
|
||
/// 09:00–12:00, 12:00–15:00, and so on — which produces up to eight buckets a
|
||
/// day, folded down to three at the end. Two consequences, both wrong on a
|
||
/// real morning: an 08:30 stop and an 11:00 stop are both *the morning run*
|
||
/// and landed on different trips, and which trip a stop appeared on depended
|
||
/// on what else was in the day. A rider with only afternoon work saw it as
|
||
/// "Trip 1", so his Trip 2 and the hub's Trip 2 were different things.
|
||
///
|
||
/// The boundaries are fixed here rather than derived, because they are a
|
||
/// business fact about the shift and not something to infer from the data.
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
enum DayPart {
|
||
morning('Morning'),
|
||
afternoon('Afternoon'),
|
||
evening('Evening');
|
||
|
||
const DayPart(this.label);
|
||
|
||
/// What this part of the day is called, on a tab and in a sentence.
|
||
final String label;
|
||
|
||
/// Afternoon begins at noon.
|
||
static const int afternoonFromHour = 12;
|
||
|
||
/// Evening begins at 5pm.
|
||
static const int eveningFromHour = 17;
|
||
|
||
/// The day part [at] falls in.
|
||
static DayPart of(DateTime at) {
|
||
if (at.hour < afternoonFromHour) return DayPart.morning;
|
||
if (at.hour < eveningFromHour) return DayPart.afternoon;
|
||
return DayPart.evening;
|
||
}
|
||
|
||
/// The window this part covers on [day].
|
||
static (DateTime, DateTime) windowOn(DateTime day, DayPart part) {
|
||
final midnight = DateTime(day.year, day.month, day.day);
|
||
return switch (part) {
|
||
DayPart.morning => (
|
||
midnight,
|
||
midnight.add(const Duration(hours: afternoonFromHour)),
|
||
),
|
||
DayPart.afternoon => (
|
||
midnight.add(const Duration(hours: afternoonFromHour)),
|
||
midnight.add(const Duration(hours: eveningFromHour)),
|
||
),
|
||
DayPart.evening => (
|
||
midnight.add(const Duration(hours: eveningFromHour)),
|
||
midnight.add(const Duration(days: 1)),
|
||
),
|
||
};
|
||
}
|
||
|
||
/// True when [from]–[to] is exactly one day part's window, which is how
|
||
/// [Trip.slotLabel] knows to say "Morning" instead of a clock range.
|
||
static bool spans(DateTime from, DateTime to) {
|
||
final (start, end) = windowOn(from, of(from));
|
||
return from.isAtSameMomentAs(start) && to.isAtSameMomentAs(end);
|
||
}
|
||
}
|
||
|
||
/// The three slots of a day, whether or not the rider has work in each.
|
||
///
|
||
/// Trip 2 is the afternoon even on a day with no morning work — the number is
|
||
/// the day part, so the rider's "Trip 2" and the hub's are the same run.
|
||
///
|
||
/// ── This split is the client's, and the backend has confirmed it ──
|
||
///
|
||
/// Asked directly (24 Aug 2026): *does the console own Trip 1/2/3?* Answer:
|
||
/// **no — there is no `tripid` or `slotid` backend-side.** The day-part
|
||
/// grouping is therefore documented client behaviour rather than a guess
|
||
/// standing in for a field that exists, and the app is not waiting on one.
|
||
///
|
||
/// If that ever changes, this is the class to delete: the ids would arrive on
|
||
/// the booking row and the split would stop being derived at all.
|
||
extension TripSlots on List<Trip> {
|
||
/// The day laid out as slots: index 0 is the morning, 1 the afternoon, 2 the
|
||
/// evening, and an empty slot is a part of the day with no work in it.
|
||
///
|
||
/// Dated trips claim their own part. Anything undated — a tenant that sends
|
||
/// no times, demo data, a trip whose stops carry only addresses — falls back
|
||
/// to the first free slot, in order, because a trip that cannot be placed
|
||
/// still has to be reachable. Guessing a part from the wall clock instead
|
||
/// would quietly move such a trip from Trip 1 to Trip 2 at noon.
|
||
List<Trip?> get slots {
|
||
final out = List<Trip?>.filled(DayPart.values.length, null, growable: true);
|
||
|
||
// ── Day parts place the trips only when they can place all of them ──
|
||
//
|
||
// A trip claims the slot its part names — that is what makes an
|
||
// afternoon-only day read as Trip 2 rather than Trip 1. It works because
|
||
// the common day is one run per part.
|
||
//
|
||
// When two trips want the *same* part it stops working, and the first
|
||
// attempt at patching it made things worse: the second trip took the first
|
||
// free slot anywhere, so on an evening with two evening runs the tabs came
|
||
// out `[2, 0]` — trip one under Trip 3, trip two under Trip 1, inverted,
|
||
// and only between 5pm and midnight. A layout that depends on the hour the
|
||
// rider opened the screen is worse than one that ignores day parts.
|
||
//
|
||
// So it is all or nothing. Every trip has its own part, or the run is laid
|
||
// out in the order it arrived — which is already sorted, and which is the
|
||
// honest answer when the data does not fit the model.
|
||
final parts = <int>{};
|
||
var distinct = true;
|
||
for (final t in this) {
|
||
final p = t.dayPart?.index;
|
||
if (p == null || !parts.add(p)) {
|
||
distinct = false;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (distinct) {
|
||
for (final t in this) {
|
||
out[t.dayPart!.index] = t;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
for (var i = 0; i < length; i++) {
|
||
if (i < out.length) {
|
||
out[i] = this[i];
|
||
} else {
|
||
out.add(this[i]);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/// The trip in slot [index] (0 = morning), or null when nothing is assigned
|
||
/// to that part of the day.
|
||
Trip? tripAt(int index) {
|
||
final s = slots;
|
||
return index >= 0 && index < s.length ? s[index] : null;
|
||
}
|
||
}
|
||
|
||
class Trip {
|
||
/// Stable identity for this trip (backend slot/trip id, or a derived key).
|
||
final String id;
|
||
|
||
/// The customer-facing slot window this trip serves. Null when the backend
|
||
/// sends no usable times — the trip is then simply "today's route".
|
||
final DateTime? slotStart;
|
||
final DateTime? slotEnd;
|
||
|
||
/// Stops in the admin's fixed order. Never re-sorted by the app.
|
||
final List<Map<String, dynamic>> stops;
|
||
|
||
/// Straight-line round-trip length in metres, hub → stops → hub.
|
||
final double routeMeters;
|
||
|
||
/// Sum of the per-stop service estimates (time off the bike).
|
||
final Duration serviceDuration;
|
||
|
||
/// Time on the bike, at [RouteMetricsHelper.kDefaultSpeedMps].
|
||
final Duration travelDuration;
|
||
|
||
final int totalParcels;
|
||
final int deliverParcels;
|
||
final int collectParcels;
|
||
final double totalWeightKg;
|
||
final double cashToCollect;
|
||
|
||
const Trip({
|
||
required this.id,
|
||
required this.slotStart,
|
||
required this.slotEnd,
|
||
required this.stops,
|
||
required this.routeMeters,
|
||
required this.serviceDuration,
|
||
required this.travelDuration,
|
||
required this.totalParcels,
|
||
required this.deliverParcels,
|
||
required this.collectParcels,
|
||
required this.totalWeightKg,
|
||
required this.cashToCollect,
|
||
});
|
||
|
||
int get stopCount => stops.length;
|
||
|
||
/// The same trip, recounted over **only the work still owed on Home**.
|
||
///
|
||
/// ── Why the brief was reading the wrong number ──
|
||
///
|
||
/// The four figures in [TripBriefStrip] — duration, distance, parcels,
|
||
/// payment — were computed once, over every stop the slot ever held, and
|
||
/// never moved again. The headline above them says `20 stops left` and
|
||
/// counts down as the rider works; the figures under it kept describing the
|
||
/// morning he started with.
|
||
///
|
||
/// So a rider with five collections to go read `≈4h 15m · 29.0 km · 25
|
||
/// parcels` — the whole day, including the twenty stops already in his box
|
||
/// and gone to Deliveries. The one question the card exists to answer, *what
|
||
/// is left in front of me*, was the one thing on it that was stale.
|
||
///
|
||
/// ── Where the line is ──
|
||
///
|
||
/// [WorkBoundary.pickupComplete] — the same boundary Home and Deliveries
|
||
/// already split on, so a stop cannot be counted here and owned there. Once
|
||
/// the collection is done the stop belongs to the delivery leg and is not
|
||
/// this card's business. Skipped and cancelled work is dropped for the same
|
||
/// reason: it is a record now, not a stop to plan around.
|
||
///
|
||
/// [origin] is the point the round trip is measured from — the rider's own
|
||
/// position, which is what the page passes to [Trip.fromStops] as the hub.
|
||
/// Passing it keeps the recount on the same footing as the original
|
||
/// measurement; omitting it would silently fall back to per-stop kilometres
|
||
/// and read as a distance that changed for no reason.
|
||
///
|
||
/// Returns `this` unchanged when nothing has been collected yet, so the
|
||
/// common case allocates nothing.
|
||
/// ── Declined work is not outstanding work ──
|
||
///
|
||
/// [rejectedIds] is the set the rider's own decision writes, and it has to be
|
||
/// passed in because a decline is **local first**: the stop stays in the
|
||
/// payload reading `assigned` until the backend catches up, so
|
||
/// `stopStatusOf` cannot see it and the figures counted a stop the rider had
|
||
/// refused. He declined four of eight and the card still measured the
|
||
/// kilometres to all eight.
|
||
Trip outstanding({
|
||
Set<String> collectedIds = const <String>{},
|
||
Set<String> rejectedIds = const <String>{},
|
||
double? originLat,
|
||
double? originLng,
|
||
}) {
|
||
final left = <Map<String, dynamic>>[
|
||
for (final s in stops)
|
||
if (!WorkBoundary.pickupComplete(s, collectedIds: collectedIds) &&
|
||
!stopStatusOf(s).isCancelled &&
|
||
!stopStatusOf(s).isSkipped &&
|
||
!stopStatusOf(s).isRejected &&
|
||
!rejectedIds.contains((s['orderid'] ?? '').toString()))
|
||
s,
|
||
];
|
||
if (left.length == stops.length) return this;
|
||
|
||
return Trip.fromStops(
|
||
id: id,
|
||
stops: left,
|
||
slotStart: slotStart,
|
||
slotEnd: slotEnd,
|
||
hubLat: originLat,
|
||
hubLng: originLng,
|
||
);
|
||
}
|
||
|
||
/// The number the rider is actually planning around: ride time plus every
|
||
/// minute spent standing at a door.
|
||
Duration get totalDuration => travelDuration + serviceDuration;
|
||
|
||
/// Average time per stop across the whole trip, service only.
|
||
Duration get averageStopDuration => stopCount == 0
|
||
? Duration.zero
|
||
: Duration(seconds: serviceDuration.inSeconds ~/ stopCount);
|
||
|
||
/// `10:00 AM – 1:00 PM`, or a plain label when the slot is unknown.
|
||
String get slotLabel {
|
||
final s = slotStart;
|
||
final e = slotEnd;
|
||
if (s == null || e == null) return "Today's route";
|
||
// A day part is named, not spelled out as a clock range: "Morning" is what
|
||
// the rider and the hub both call it, and "5:00 AM – 12:00 PM" says the
|
||
// same thing in seven characters more and one reading step.
|
||
if (DayPart.spans(s, e)) return DayPart.of(s).label;
|
||
return '${RouteMetricsHelper.formatClock(s)} – '
|
||
'${RouteMetricsHelper.formatClock(e)}';
|
||
}
|
||
|
||
/// The slot as a **clock range**, or `''` when it has none.
|
||
///
|
||
/// ── Why a day part is not printed at clock position ──
|
||
///
|
||
/// [slotLabel] answers "what is this trip called" and names a whole day part
|
||
/// as *Morning* / *Afternoon* / *Evening*, which is right for a heading.
|
||
/// It is wrong wherever the app is printing a live figure.
|
||
///
|
||
/// Two things happen when it lands there. The word is a **label pretending to
|
||
/// be data**: it sits where "3h 10m left" sits, in the corner a rider checks
|
||
/// for how much time he has, and answers with a noun he cannot act on. And it
|
||
/// is redundant twice over — the trip is already numbered `Trip 1`, and the
|
||
/// number *is* the day part, so `Trip 1 · Morning` says one thing twice.
|
||
///
|
||
/// This became visible when trips started being placed by day part at all:
|
||
/// before that they carried no window, [slotLabel] fell through to "Today's
|
||
/// route", and the same call sites printed nothing. So the fix is not to undo
|
||
/// the placement — it is to stop reading a heading as a measurement.
|
||
///
|
||
/// Returns a range only when the hub sent a real one: `10:00 AM – 1:00 PM`.
|
||
String get slotClockLabel {
|
||
final s = slotStart;
|
||
final e = slotEnd;
|
||
if (s == null || e == null) return '';
|
||
if (DayPart.spans(s, e)) return '';
|
||
return '${RouteMetricsHelper.formatClock(s)} – '
|
||
'${RouteMetricsHelper.formatClock(e)}';
|
||
}
|
||
|
||
/// True when the slot window has already closed.
|
||
bool isExpired([DateTime? now]) {
|
||
final e = slotEnd;
|
||
if (e == null) return false;
|
||
return (now ?? DateTime.now()).isAfter(e);
|
||
}
|
||
|
||
/// Order ids in this trip.
|
||
List<String> get orderIds => [
|
||
for (final s in stops)
|
||
if ((s['orderid'] ?? '').toString().isNotEmpty) (s['orderid']).toString(),
|
||
];
|
||
|
||
// ── Per-stop state & completion ────────────────────────────────────────
|
||
|
||
/// State of every stop, in route order.
|
||
List<StopState> states({
|
||
required Set<String> acceptedIds,
|
||
required Set<String> rejectedIds,
|
||
}) => [
|
||
for (final s in stops)
|
||
stopStateOf(s, acceptedIds: acceptedIds, rejectedIds: rejectedIds),
|
||
];
|
||
|
||
/// Ids an "accept whole trip" action should operate on — only the stops
|
||
/// still awaiting a decision. Re-accepting one already accepted would be a
|
||
/// wasted call, and re-accepting a rejected one would undo the rider.
|
||
List<String> pendingOrderIds({
|
||
required Set<String> acceptedIds,
|
||
required Set<String> rejectedIds,
|
||
}) => [
|
||
for (final s in stops)
|
||
if (stopStateOf(s, acceptedIds: acceptedIds, rejectedIds: rejectedIds) ==
|
||
StopState.pending)
|
||
(s['orderid'] ?? '').toString(),
|
||
].where((id) => id.isNotEmpty).toList();
|
||
|
||
/// Fraction of the trip that is finished, 0.0 → 1.0.
|
||
///
|
||
/// Counts **resolved** stops — done or rejected — not just completed ones.
|
||
/// A rejected stop is off the rider's plate; leaving it out of the numerator
|
||
/// would strand the trip below 100% with nothing he could do about it.
|
||
double completion({
|
||
required Set<String> acceptedIds,
|
||
required Set<String> rejectedIds,
|
||
}) {
|
||
if (stops.isEmpty) return 0;
|
||
final resolved = states(
|
||
acceptedIds: acceptedIds,
|
||
rejectedIds: rejectedIds,
|
||
).where((s) => s.isResolved).length;
|
||
return resolved / stops.length;
|
||
}
|
||
|
||
/// Completion as a whole percent, for display.
|
||
int completionPercent({
|
||
required Set<String> acceptedIds,
|
||
required Set<String> rejectedIds,
|
||
}) => (completion(acceptedIds: acceptedIds, rejectedIds: rejectedIds) * 100)
|
||
.round();
|
||
|
||
/// True once every stop has been resolved — the rider can head back.
|
||
bool isFinished({
|
||
required Set<String> acceptedIds,
|
||
required Set<String> rejectedIds,
|
||
}) =>
|
||
stops.isNotEmpty &&
|
||
states(
|
||
acceptedIds: acceptedIds,
|
||
rejectedIds: rejectedIds,
|
||
).every((s) => s.isResolved);
|
||
|
||
/// True while any stop still needs an accept/reject decision.
|
||
bool hasPendingStops({
|
||
required Set<String> acceptedIds,
|
||
required Set<String> rejectedIds,
|
||
}) => states(
|
||
acceptedIds: acceptedIds,
|
||
rejectedIds: rejectedIds,
|
||
).any((s) => s.needsDecision);
|
||
|
||
/// Short tab label for a **slot** — `Trip 1`.
|
||
///
|
||
/// The slot is the day part, not a running count: slot 0 is the morning
|
||
/// whether or not the rider has morning work. See [DayPart].
|
||
static String tabLabel(int index) =>
|
||
'Trip ${index + 1}'; // slot index is the day part's index
|
||
|
||
/// Which part of the day this trip belongs to — **null when nothing on it is
|
||
/// dated**.
|
||
///
|
||
/// Taken from the slot the grouping put it in. A trip the backend named with
|
||
/// its own `tripid` still gets one, from whatever window its stops fall in.
|
||
///
|
||
/// Null matters: a trip with no times at all cannot be placed by day part,
|
||
/// and guessing one from the wall clock would move it between tabs as the
|
||
/// afternoon wore on. Those keep their position instead — see
|
||
/// [TripSlots.slots].
|
||
DayPart? get dayPart {
|
||
final at = slotStart ?? _earliestDue;
|
||
return at == null ? null : DayPart.of(at);
|
||
}
|
||
|
||
/// `1`, `2` or `3` — the number the rider and the hub both use. Zero when
|
||
/// this trip has no time to place it by.
|
||
int get tripNumber => (dayPart?.index ?? -1) + 1;
|
||
|
||
DateTime? get _earliestDue {
|
||
DateTime? best;
|
||
for (final s in stops) {
|
||
final due = dayPartTimeOf(s);
|
||
if (due == null) continue;
|
||
if (best == null || due.isBefore(best)) best = due;
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/// The clock that decides which part of the day a stop belongs to.
|
||
///
|
||
/// ── Due time first, assignment time second ──
|
||
///
|
||
/// The right answer is when the work is *due*: an order to be collected at
|
||
/// 10am is morning work whenever the hub happened to raise it. That is
|
||
/// `expected_pickup_time`, and where the backend sends it nothing here
|
||
/// changes.
|
||
///
|
||
/// Where it does not — which is the live Doormile contract today — the day
|
||
/// part had nothing to read at all, and every stop fell into the undated
|
||
/// `day:` bucket. One trip, no day part, and [TripSlots.slots] put it in the
|
||
/// first free slot: **everything was Trip 1, all day**. A rider given an
|
||
/// evening run opened Home to find it filed under the morning.
|
||
///
|
||
/// So the fallback is when the work reached him — `assignedat`, merged onto
|
||
/// the row by [WorkRepository] from the assignment ledger. It is the hub's
|
||
/// own act as the phone observed it, and it is what dispatch means on the
|
||
/// telephone: work handed over in the morning is the morning run. Approximate
|
||
/// where a due time would be exact, and exactly right for the common case,
|
||
/// which is a slot assigned at the top of the slot it is for.
|
||
///
|
||
/// `eta` is read last and usually answers nothing — on the v1 adapter it is
|
||
/// a duration, not a clock, and [_parseTimestamp] rejects it rather than
|
||
/// inventing a 15th of the month out of "15 min".
|
||
static DateTime? dayPartTimeOf(Map<String, dynamic> stop) =>
|
||
_parseTimestamp(
|
||
stop['expected_pickup_time'] ??
|
||
stop['expectedpickuptime'] ??
|
||
stop['assignedat'] ??
|
||
stop['assignedon'] ??
|
||
stop['eta'],
|
||
);
|
||
|
||
/// Address components every stop on this trip shares — city, state,
|
||
/// pincode and so on. Cached per build by the caller.
|
||
List<String> get sharedAddressTail => commonAddressTail([
|
||
for (final s in stops) (s['pickupaddress'] ?? '').toString(),
|
||
]);
|
||
|
||
/// The distinguishing part of a stop's address, with the shared tail removed.
|
||
String shortAddress(Map<String, dynamic> stop, List<String> tail) =>
|
||
stripAddressTail((stop['pickupaddress'] ?? '').toString(), tail);
|
||
|
||
// ── Per-stop estimates ─────────────────────────────────────────────────
|
||
|
||
/// Estimated time OFF the bike at [index].
|
||
///
|
||
/// Service time is not a constant. Handing a parcel over and reading back an
|
||
/// OTP is quick; weighing and photographing a collection is slower; doing
|
||
/// both at one door is slower still; and counting cash adds real minutes.
|
||
/// These are the numbers the pace warning on Home is measured against, so
|
||
/// they are deliberately not optimistic.
|
||
static Duration serviceTimeFor(Map<String, dynamic> stop) {
|
||
final kind = stopKindOf(stop);
|
||
|
||
var seconds = switch (kind) {
|
||
StopKind.delivery => 150, // 2m30 — hand over, OTP, photo
|
||
StopKind.pickup => 240, // 4m00 — collect, weigh, photo
|
||
StopKind.combined => 330, // 5m30 — both, but one walk-up
|
||
};
|
||
|
||
// Cash handling is the single biggest variable at a door.
|
||
if (stopCollectionAmount(stop) > 0) seconds += 90;
|
||
|
||
// Multi-parcel stops take longer to count and load. First parcel is in
|
||
// the base estimate; each extra adds handling time.
|
||
final parcels = deliveryParcelCount(stop) + pickupParcelCount(stop);
|
||
if (parcels > 1) seconds += (parcels - 1) * 25;
|
||
|
||
return Duration(seconds: seconds);
|
||
}
|
||
|
||
/// Estimated ride time from the previous point to stop [index]. For index 0
|
||
/// that is the hub → stop 1 leg.
|
||
Duration travelTimeToStop(
|
||
int index, {
|
||
double? hubLat,
|
||
double? hubLng,
|
||
// ── The rail's legs run door to door, not counter to counter ──
|
||
//
|
||
// On the Deliveries rail every stop is the delivery leg, and this walked
|
||
// the route over the PICKUP pairs — so the remaining-time estimate was a
|
||
// tour of the kitchens the rider had already left. The flag mirrors
|
||
// `RouteMetricsHelper.metersToStop(toDrop:)`: the previous stop's exit
|
||
// point and this stop's target are both the drop when the leg is a
|
||
// delivery, with the pickup pair as the payload fallback. Default false —
|
||
// Home's pickup-leg arithmetic is untouched.
|
||
bool deliveryLeg = false,
|
||
}) {
|
||
if (index < 0 || index >= stops.length) return Duration.zero;
|
||
|
||
final ({double lat, double lng})? from = index == 0
|
||
? _origin(hubLat, hubLng)
|
||
: _coordsOf(stops[index - 1], toDrop: deliveryLeg);
|
||
final to = _coordsOf(stops[index], toDrop: deliveryLeg);
|
||
if (from == null || to == null) return Duration.zero;
|
||
|
||
final meters = RouteMetricsHelper.distanceMeters(
|
||
from.lat,
|
||
from.lng,
|
||
to.lat,
|
||
to.lng,
|
||
);
|
||
return RouteMetricsHelper.travelTime(meters);
|
||
}
|
||
|
||
/// Running clock estimate of when the rider reaches stop [index], assuming
|
||
/// he leaves the hub at [departure].
|
||
DateTime etaForStop(
|
||
int index, {
|
||
required DateTime departure,
|
||
double? hubLat,
|
||
double? hubLng,
|
||
}) {
|
||
var clock = departure;
|
||
for (var i = 0; i <= index && i < stops.length; i++) {
|
||
clock = clock.add(travelTimeToStop(i, hubLat: hubLat, hubLng: hubLng));
|
||
if (i < index) clock = clock.add(serviceTimeFor(stops[i]));
|
||
}
|
||
return clock;
|
||
}
|
||
|
||
({double lat, double lng})? _origin(double? lat, double? lng) =>
|
||
(lat != null && lng != null && lat != 0 && lng != 0)
|
||
? (lat: lat, lng: lng)
|
||
: null;
|
||
|
||
// ── Construction ───────────────────────────────────────────────────────
|
||
|
||
/// Builds one trip from an ordered list of stops.
|
||
///
|
||
/// [hubLat]/[hubLng] anchor both ends of the round trip. The backend does
|
||
/// not yet send hub coordinates, so callers pass the rider's current
|
||
/// position — he is at (or near) the hub when he picks up a trip, which
|
||
/// makes it a workable stand-in. When it is missing, the route length is the
|
||
/// stop-to-stop chain only and the return leg is simply absent rather than
|
||
/// guessed.
|
||
factory Trip.fromStops({
|
||
required String id,
|
||
required List<Map<String, dynamic>> stops,
|
||
DateTime? slotStart,
|
||
DateTime? slotEnd,
|
||
double? hubLat,
|
||
double? hubLng,
|
||
}) {
|
||
final ordered = sortStops(stops);
|
||
|
||
int parcels = 0;
|
||
int deliver = 0;
|
||
int collect = 0;
|
||
double weight = 0;
|
||
double cash = 0;
|
||
var service = Duration.zero;
|
||
|
||
for (final s in ordered) {
|
||
final d = deliveryParcelCount(s);
|
||
final c = pickupParcelCount(s);
|
||
deliver += d;
|
||
collect += c;
|
||
parcels += (d + c) > 0 ? (d + c) : 1;
|
||
weight += _toDouble(s['weight'] ?? s['Weight']);
|
||
cash += stopCollectionAmount(s);
|
||
service += serviceTimeFor(s);
|
||
}
|
||
|
||
// Round trip: hub → every stop in order → back to the hub.
|
||
final chain = <({double lat, double lng})>[];
|
||
final origin =
|
||
(hubLat != null && hubLng != null && hubLat != 0 && hubLng != 0)
|
||
? (lat: hubLat, lng: hubLng)
|
||
: null;
|
||
if (origin != null) chain.add(origin);
|
||
for (final s in ordered) {
|
||
final c = _coordsOf(s);
|
||
if (c != null) chain.add(c);
|
||
}
|
||
if (origin != null && chain.length > 1) chain.add(origin);
|
||
|
||
double meters = 0;
|
||
for (var i = 0; i < chain.length - 1; i++) {
|
||
meters += RouteMetricsHelper.distanceMeters(
|
||
chain[i].lat,
|
||
chain[i].lng,
|
||
chain[i + 1].lat,
|
||
chain[i + 1].lng,
|
||
);
|
||
}
|
||
if (meters == 0) {
|
||
// Fall back to whatever per-stop kilometres the backend sent.
|
||
for (final s in ordered) {
|
||
meters += _toDouble(s['kms'] ?? s['km']) * 1000;
|
||
}
|
||
}
|
||
|
||
return Trip(
|
||
id: id,
|
||
slotStart: slotStart,
|
||
slotEnd: slotEnd,
|
||
stops: ordered,
|
||
routeMeters: meters,
|
||
serviceDuration: service,
|
||
travelDuration: RouteMetricsHelper.travelTime(meters),
|
||
totalParcels: parcels,
|
||
deliverParcels: deliver,
|
||
collectParcels: collect,
|
||
totalWeightKg: weight,
|
||
cashToCollect: cash,
|
||
);
|
||
}
|
||
|
||
/// Groups loose stops into trips.
|
||
///
|
||
/// Grouping key, in order of trust:
|
||
/// 1. An explicit backend `tripid` / `slotid` / `routeid`.
|
||
/// 2. An explicit slot window (`slotstarttime` + `slotendtime`).
|
||
/// 3. The `expected_pickup_time` bucketed into a [windowHours] block
|
||
/// aligned to the clock — 10:00–13:00, 13:00–16:00, and so on. This is
|
||
/// how the hub builds slots, so bucketing reproduces them closely enough
|
||
/// to be useful while the backend has no trip field.
|
||
/// 4. Everything else lands in one "today's route" trip rather than being
|
||
/// dropped.
|
||
///
|
||
/// Returned trips are ordered by slot start, earliest first.
|
||
/// The most trips a rider's day can contain.
|
||
///
|
||
/// Three is the operational fact, not a display limit: the hub assigns morning,
|
||
/// afternoon and evening slots. [TripTabs] renders three slots for the same
|
||
/// reason, and [_capToMaxTrips] folds any extra buckets into the last so the
|
||
/// two never disagree.
|
||
static const int maxTripsPerDay = 3;
|
||
|
||
static List<Trip> groupIntoTrips(
|
||
List<Map<String, dynamic>> stops, {
|
||
|
||
/// No longer used for bucketing — stops are grouped by [DayPart]. Kept so
|
||
/// existing call sites compile; passing it changes nothing.
|
||
@Deprecated('Trips are bucketed by DayPart') int windowHours = 3,
|
||
double? hubLat,
|
||
double? hubLng,
|
||
DateTime? now,
|
||
}) {
|
||
if (stops.isEmpty) return const [];
|
||
|
||
final clock = now ?? DateTime.now();
|
||
final buckets = <String, List<Map<String, dynamic>>>{};
|
||
final windows = <String, (DateTime?, DateTime?)>{};
|
||
|
||
for (final stop in stops) {
|
||
final explicitId = _firstNonEmpty(stop, [
|
||
'tripid',
|
||
'tripId',
|
||
'slotid',
|
||
'slotId',
|
||
'routeid',
|
||
'routeId',
|
||
]);
|
||
|
||
final slotFrom = _parseTimestamp(
|
||
stop['slotstarttime'] ?? stop['slotStartTime'] ?? stop['slotfrom'],
|
||
);
|
||
final slotTo = _parseTimestamp(
|
||
stop['slotendtime'] ?? stop['slotEndTime'] ?? stop['slotto'],
|
||
);
|
||
|
||
String key;
|
||
DateTime? from;
|
||
DateTime? to;
|
||
|
||
if (explicitId != null) {
|
||
key = 'id:$explicitId';
|
||
from = slotFrom;
|
||
to = slotTo;
|
||
} else if (slotFrom != null && slotTo != null) {
|
||
key = 'slot:${slotFrom.toIso8601String()}';
|
||
from = slotFrom;
|
||
to = slotTo;
|
||
} else {
|
||
// Due time where the hub sends one, and the moment the work reached
|
||
// the rider where it does not — see [Trip.dayPartTimeOf]. Without the
|
||
// second half every stop landed in the undated bucket below and the
|
||
// whole day rendered as Trip 1.
|
||
final due = dayPartTimeOf(stop);
|
||
if (due != null) {
|
||
// Morning, afternoon or evening — see [DayPart]. Not a rolling
|
||
// window: two stops due an hour apart are on the same run, and the
|
||
// trip a stop belongs to must not depend on what else is in the day.
|
||
final part = DayPart.of(due);
|
||
final (partFrom, partTo) = DayPart.windowOn(due, part);
|
||
from = partFrom;
|
||
to = partTo;
|
||
key = 'part:${due.year}-${due.month}-${due.day}:${part.name}';
|
||
} else {
|
||
from = null;
|
||
to = null;
|
||
key = 'day:${clock.year}-${clock.month}-${clock.day}';
|
||
}
|
||
}
|
||
|
||
buckets.putIfAbsent(key, () => []).add(stop);
|
||
windows.putIfAbsent(key, () => (from, to));
|
||
}
|
||
|
||
final trips = <Trip>[
|
||
for (final entry in buckets.entries)
|
||
Trip.fromStops(
|
||
id: entry.key,
|
||
stops: entry.value,
|
||
slotStart: windows[entry.key]?.$1,
|
||
slotEnd: windows[entry.key]?.$2,
|
||
hubLat: hubLat,
|
||
hubLng: hubLng,
|
||
),
|
||
];
|
||
|
||
trips.sort((a, b) {
|
||
final as = a.slotStart;
|
||
final bs = b.slotStart;
|
||
if (as == null && bs == null) return 0;
|
||
if (as == null) return 1; // undated trips last
|
||
if (bs == null) return -1;
|
||
// Day part first, so Trip 1 is always the morning even on a day that
|
||
// starts at two in the afternoon. Within a part, earliest first.
|
||
final pa = a.dayPart?.index ?? DayPart.values.length;
|
||
final pb = b.dayPart?.index ?? DayPart.values.length;
|
||
if (pa != pb) return pa.compareTo(pb);
|
||
return as.compareTo(bs);
|
||
});
|
||
|
||
return _capToMaxTrips(trips, hubLat: hubLat, hubLng: hubLng);
|
||
}
|
||
|
||
/// A rider's day is three trips. Anything past that is folded into the last.
|
||
///
|
||
/// The bucketing above is time-based — a stop with no trip id lands in a
|
||
/// three-hour window — so a long day, or one stop booked well outside the
|
||
/// others, can produce a fourth or fifth bucket. That surfaced as a "Trip 4"
|
||
/// tab, which is not a thing the hub assigns.
|
||
///
|
||
/// **Merged, never dropped.** Truncating the list would be the one-line fix
|
||
/// and it would silently hide real work: those stops are accepted bookings the
|
||
/// rider is expected to complete, and a stop he cannot see is a stop he cannot
|
||
/// deliver. The tail is therefore folded into the third trip, whose slot then
|
||
/// spans from its own start to the last stop's end, so the label stays honest
|
||
/// about what it now contains.
|
||
static List<Trip> _capToMaxTrips(
|
||
List<Trip> trips, {
|
||
double? hubLat,
|
||
double? hubLng,
|
||
}) {
|
||
if (trips.length <= maxTripsPerDay) return trips;
|
||
|
||
final kept = trips.take(maxTripsPerDay - 1).toList();
|
||
final tail = trips.skip(maxTripsPerDay - 1).toList();
|
||
|
||
final mergedStops = [for (final t in tail) ...t.stops];
|
||
final starts = tail.map((t) => t.slotStart).whereType<DateTime>();
|
||
final ends = tail.map((t) => t.slotEnd).whereType<DateTime>();
|
||
|
||
kept.add(
|
||
Trip.fromStops(
|
||
id: tail.first.id,
|
||
stops: mergedStops,
|
||
slotStart: starts.isEmpty
|
||
? null
|
||
: starts.reduce((a, b) => a.isBefore(b) ? a : b),
|
||
slotEnd: ends.isEmpty
|
||
? null
|
||
: ends.reduce((a, b) => a.isAfter(b) ? a : b),
|
||
hubLat: hubLat,
|
||
hubLng: hubLng,
|
||
),
|
||
);
|
||
return kept;
|
||
}
|
||
|
||
/// Puts stops in the admin's intended order.
|
||
///
|
||
/// `step` is authoritative — it IS the admin's solved sequence, and the app
|
||
/// must never second-guess it. Only when no stop carries one do we fall back
|
||
/// to the booked time, then to the order the backend sent. **Distance is not
|
||
/// one of the options here**: sorting by it would be re-optimising a route
|
||
/// the hub has planned, which the rider is not allowed to do.
|
||
///
|
||
/// The rule itself lives in [RouteOrder] — one implementation, shared with
|
||
/// the Deliveries tab, which used to keep its own and disagreed. This is the
|
||
/// pickup leg's door onto it.
|
||
static List<Map<String, dynamic>> sortStops(
|
||
List<Map<String, dynamic>> stops,
|
||
) => orderStops(stops).$1;
|
||
|
||
/// [sortStops], plus which rule produced the order.
|
||
static (List<Map<String, dynamic>>, RouteOrderSource) orderStops(
|
||
List<Map<String, dynamic>> stops,
|
||
) => RouteOrder.sort(
|
||
stops,
|
||
bookedTimeOf: (s) => _parseTimestamp(
|
||
s['expected_pickup_time'] ?? s['expectedpickuptime'] ?? s['eta'],
|
||
),
|
||
);
|
||
|
||
// ── helpers ────────────────────────────────────────────────────────────
|
||
|
||
static ({double lat, double lng})? _coordsOf(
|
||
Map<String, dynamic> stop, {
|
||
bool toDrop = false,
|
||
}) {
|
||
if (toDrop) {
|
||
final dLat = _toDouble(stop['droplat'] ?? stop['DropLat']);
|
||
final dLng = _toDouble(stop['droplon'] ?? stop['DropLon']);
|
||
if (dLat != 0 && dLng != 0) return (lat: dLat, lng: dLng);
|
||
}
|
||
final lat = _toDouble(stop['pickuplat'] ?? stop['PickupLat']);
|
||
final lng = _toDouble(stop['pickuplon'] ?? stop['PickupLon']);
|
||
if (lat == 0 || lng == 0) return null;
|
||
return (lat: lat, lng: lng);
|
||
}
|
||
|
||
static String? _firstNonEmpty(Map<String, dynamic> m, List<String> keys) {
|
||
for (final k in keys) {
|
||
final v = m[k];
|
||
final s = v?.toString().trim() ?? '';
|
||
if (s.isNotEmpty && s != '0' && s != 'null') return s;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
static DateTime? _parseTimestamp(dynamic v) {
|
||
if (v == null) return null;
|
||
return DateTime.tryParse(v.toString().trim());
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
/// ADDRESS COMPRESSION
|
||
///
|
||
/// A raw stop address is written for a postal system, not a rider:
|
||
///
|
||
/// "5/2, North Avenue, Vadavalli, Coimbatore, Tamil Nadu, 641041"
|
||
///
|
||
/// On a six-stop trip, "Coimbatore, Tamil Nadu, 641041" is repeated six times
|
||
/// and distinguishes nothing — every stop is in the same city, or the hub
|
||
/// would not have put them on one route. It is pure noise, and it is the noise
|
||
/// that pushes the useful part ("5/2, North Avenue") onto a second line and
|
||
/// then off the end of an ellipsis.
|
||
///
|
||
/// So: find the trailing components EVERY stop on the trip shares, and drop
|
||
/// them. What survives is exactly the part that tells one stop from another.
|
||
/// The full address is never lost — it goes to the detail sheet and to the
|
||
/// navigation hand-off untouched.
|
||
///
|
||
/// This is data-driven rather than a hardcoded list of cities and states: it
|
||
/// works for any city, any country, and degrades to a no-op on a one-stop trip
|
||
/// (where nothing is shared, so nothing is dropped).
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
|
||
/// Splits an address into trimmed, non-empty components.
|
||
List<String> addressParts(String address) =>
|
||
address.split(',').map((p) => p.trim()).where((p) => p.isNotEmpty).toList();
|
||
|
||
/// The trailing components shared by every address in [addresses].
|
||
///
|
||
/// Returns an empty list when fewer than two addresses are given, or when the
|
||
/// tails differ — there is then nothing redundant to remove.
|
||
List<String> commonAddressTail(List<String> addresses) {
|
||
final parts = [
|
||
for (final a in addresses)
|
||
if (addressParts(a).isNotEmpty) addressParts(a),
|
||
];
|
||
if (parts.length < 2) return const [];
|
||
|
||
final shortest = parts.map((p) => p.length).reduce((a, b) => a < b ? a : b);
|
||
final tail = <String>[];
|
||
|
||
for (var back = 1; back < shortest; back++) {
|
||
final candidate = parts.first[parts.first.length - back].toLowerCase();
|
||
final shared = parts.every(
|
||
(p) => p[p.length - back].toLowerCase() == candidate,
|
||
);
|
||
if (!shared) break;
|
||
tail.insert(0, parts.first[parts.first.length - back]);
|
||
}
|
||
return tail;
|
||
}
|
||
|
||
/// [address] with [tail] removed. Always leaves at least one component, so a
|
||
/// stop can never render a blank address.
|
||
String stripAddressTail(String address, List<String> tail) {
|
||
if (tail.isEmpty) return address.trim();
|
||
final parts = addressParts(address);
|
||
var end = parts.length;
|
||
for (var i = tail.length - 1; i >= 0; i--) {
|
||
if (end <= 1) break;
|
||
if (parts[end - 1].toLowerCase() != tail[i].toLowerCase()) break;
|
||
end--;
|
||
}
|
||
return parts.sublist(0, end).join(', ');
|
||
}
|
||
|
||
/// Where a single stop sits in the accept → work → done lifecycle.
|
||
///
|
||
/// This is what lets one trip card serve both Home and Bookings. A trip does
|
||
/// not leave Home the moment it is accepted — the rider still wants to watch
|
||
/// it fill up — so every stop carries its own state and the card renders the
|
||
/// right control for it: Accept/Reject while pending, a progress tick once
|
||
/// it's moving, nothing at all once it's done.
|
||
enum StopState {
|
||
/// Awaiting the rider's decision. Shows Accept + Reject.
|
||
pending,
|
||
|
||
/// Accepted, not yet started. Lives on the Bookings tab.
|
||
accepted,
|
||
|
||
/// Loaded from its source and physically in the rider's hands.
|
||
///
|
||
/// Service routes only. The parcel ladder has no equivalent — for a parcel,
|
||
/// collecting it from the customer *is* the job — so this state exists only
|
||
/// where accepting and carrying are two different days' work apart. See
|
||
/// [ServiceProfile.handoffAt] and the collected store.
|
||
collected,
|
||
|
||
/// He is **at** the stop and has said so — the arrival is recorded, the
|
||
/// pickup is not yet made.
|
||
///
|
||
/// ── Why this is not [active] ──
|
||
///
|
||
/// It was. `stopStateOf` folded `arrived` and `active` into one value, so the
|
||
/// Home row had no arrived to render and the live chip printed the literal
|
||
/// word **Active** the moment a rider slid *confirm arrival*. He was told his
|
||
/// arrival had not registered, when in fact it had: the rung moved, the API
|
||
/// fired, the store was written, and the one word on screen said something
|
||
/// else.
|
||
///
|
||
/// They are different facts. Arrived is a rung on the pickup ladder that the
|
||
/// rider puts the stop on. Active is an operational statement about which
|
||
/// stop is being worked — see `_activePickupOrderId` — and it is the word the
|
||
/// backend reserves for a delivery under way.
|
||
arrived,
|
||
|
||
/// The rider is physically on this stop right now.
|
||
active,
|
||
|
||
/// Picked up / delivered.
|
||
done,
|
||
|
||
/// Skipped, awaiting a return visit. Not a failure.
|
||
skipped,
|
||
|
||
/// The rider declined this stop. It stays visible, struck through, so he
|
||
/// can see he handled it rather than wondering where it went.
|
||
rejected,
|
||
}
|
||
|
||
extension StopStateX on StopState {
|
||
/// Counts toward "trip complete". A rejected stop is resolved — the rider
|
||
/// has nothing left to do with it — so it must not hold the trip at 90%
|
||
/// forever.
|
||
bool get isResolved => this == StopState.done || this == StopState.rejected;
|
||
|
||
/// Still needs a decision from the rider.
|
||
bool get needsDecision => this == StopState.pending;
|
||
|
||
/// Committed to: he has taken it and owes the customer a visit.
|
||
bool get isCommitted =>
|
||
this == StopState.accepted ||
|
||
this == StopState.collected ||
|
||
this.isAtSource ||
|
||
this == StopState.skipped;
|
||
|
||
/// In the rider's hands right now.
|
||
bool get isCollected => this == StopState.collected;
|
||
|
||
/// He is **at** the source and the pickup is not yet made.
|
||
///
|
||
/// ── Why this is a predicate and not a comparison ──
|
||
///
|
||
/// [StopState.arrived] and [StopState.active] were one value. Splitting them
|
||
/// apart is what finally let the row say *Arrived* instead of *Active* — and
|
||
/// it silently changed the answer at every site that had written
|
||
/// `== StopState.active` to mean **is he standing at the counter**. There
|
||
/// were a dozen, and each of them began answering `false` for the one rung
|
||
/// whose entire point is that he is standing there:
|
||
///
|
||
/// • Home's visibility filter — so a stop *left the screen* the moment he
|
||
/// marked it arrived, and Deliveries would not take it either because
|
||
/// arrival is pre-pickup. The stop was in neither place.
|
||
/// • the selection bar's action, which offered *Accept* for a stop he was
|
||
/// already at
|
||
/// • the card's own **Mark as picked** button, which stopped being built
|
||
/// • which group opens by default, and which one counts as live
|
||
///
|
||
/// One question asked in one place, so the next rung added to the ladder
|
||
/// cannot quietly repeat it.
|
||
bool get isAtSource => this == StopState.arrived || this == StopState.active;
|
||
|
||
/// What this state is called on a card.
|
||
///
|
||
/// ── Two vocabularies, one state machine ──
|
||
///
|
||
/// The Doormile wording is written for a rider who is being *asked* something:
|
||
/// "Awaiting your decision" is true because the card under it carries Accept
|
||
/// and Reject. A service rider is asked nothing — the whole assignment came as
|
||
/// one commitment — so the same state means "the hub has given you this, it is
|
||
/// waiting for you to go and collect it", and calling that a decision invites
|
||
/// him to look for a button that is not there.
|
||
///
|
||
/// `Delivered` rather than `Completed` for the same reason: on a meal run the
|
||
/// last thing that happens at a door is a hand-over, and naming it is worth
|
||
/// more than a word that covers every stop type in the app.
|
||
String get label => ServiceProfile.active.acceptsPerStop
|
||
? switch (this) {
|
||
StopState.pending => 'Awaiting your decision',
|
||
StopState.accepted => 'Accepted',
|
||
StopState.collected => 'Collected',
|
||
StopState.arrived => 'Arrived',
|
||
StopState.active => 'In progress',
|
||
StopState.done => 'Completed',
|
||
StopState.skipped => 'Skipped',
|
||
StopState.rejected => 'Rejected',
|
||
}
|
||
: switch (this) {
|
||
StopState.pending => 'Pending',
|
||
StopState.accepted => 'Accepted',
|
||
StopState.collected => 'Collected',
|
||
StopState.arrived => 'Arrived',
|
||
StopState.active => 'Out for delivery',
|
||
StopState.done => 'Delivered',
|
||
StopState.skipped => 'Skipped',
|
||
StopState.rejected => 'Cancelled',
|
||
};
|
||
}
|
||
|
||
/// Derives a stop's state from its backend status plus the local
|
||
/// accepted/rejected stores.
|
||
///
|
||
/// Local stores win for accept/reject because those writes are optimistic —
|
||
/// the rider's tap is recorded instantly and the network catches up. If the
|
||
/// server view won, a slow response would bounce a stop back to "pending"
|
||
/// under his thumb.
|
||
StopState stopStateOf(
|
||
Map<String, dynamic> stop, {
|
||
required Set<String> acceptedIds,
|
||
required Set<String> rejectedIds,
|
||
|
||
/// Orders the rider is carrying. Optional so every existing parcel call site
|
||
/// is unchanged — a Doormile route never populates it.
|
||
Set<String> collectedIds = const <String>{},
|
||
}) {
|
||
final id = (stop['orderid'] ?? '').toString();
|
||
final raw = (stop['orderstatus'] ?? '').toString().trim().toLowerCase();
|
||
|
||
// 1. Irreversible server states win over everything. A parcel that has been
|
||
// collected or handed over cannot be un-decided by a local tap.
|
||
if (raw == 'picked' ||
|
||
raw == 'picked up' ||
|
||
raw == 'pickedup' ||
|
||
raw == 'pickuped' ||
|
||
raw == 'delivered' ||
|
||
raw == 'cancelled' ||
|
||
raw == 'canceled') {
|
||
return StopState.done;
|
||
}
|
||
|
||
// 2. He is at the stop — also not a local decision.
|
||
//
|
||
// Two values, not one. Folding them lost the arrival: see [StopState.arrived].
|
||
if (raw == 'arrived') return StopState.arrived;
|
||
if (raw == 'active') return StopState.active;
|
||
if (raw == 'skipped') return StopState.skipped;
|
||
|
||
// 2b. Released for delivery — which `pickup-complete` does by itself on
|
||
// hyperlocal work, in the same call that records the collection. It says
|
||
// the consignment may be delivered, not that the rider has set off, so it
|
||
// resolves to *carrying* and never to [StopState.active]. Asked ahead of
|
||
// the local sets because it must also hold after a reinstall, when the
|
||
// collected set is empty and this row would otherwise fall through to
|
||
// `pending` — putting a bag already in his box back on Home as work to
|
||
// accept. See `MilkRun.stageOf`.
|
||
if (raw == 'outfordelivery') return StopState.collected;
|
||
|
||
// 3. Carrying it beats every remaining local view. He has the food; no
|
||
// later tap on this screen can make that untrue, and a refetch that still
|
||
// reports `accepted` must not put the card back on Home.
|
||
if (collectedIds.contains(id)) return StopState.collected;
|
||
|
||
// 4. Local decisions next, and they beat the server's accept/reject.
|
||
// They are strictly newer: the rider just tapped, and the server view is
|
||
// at best one poll behind. Without this, un-rejecting a stop would be
|
||
// undone by the next refetch still reporting `rejected`.
|
||
if (rejectedIds.contains(id)) return StopState.rejected;
|
||
if (acceptedIds.contains(id)) return StopState.accepted;
|
||
|
||
// 5. Finally the server's own view.
|
||
if (raw == 'rejected') return StopState.rejected;
|
||
if (raw == 'accepted') return StopState.accepted;
|
||
return StopState.pending;
|
||
}
|
||
|
||
/// How a stop is progressing through the trip, for the progress rail.
|
||
enum StopProgress {
|
||
/// Finished — picked up / delivered. Rail turns green here.
|
||
done,
|
||
|
||
/// The stop the rider is on right now.
|
||
current,
|
||
|
||
/// Not started.
|
||
pending,
|
||
|
||
/// Skipped and awaiting a return visit.
|
||
skipped,
|
||
}
|
||
|
||
/// How one stop's lifecycle rung reads on the route rail.
|
||
///
|
||
/// ── "Physically on" is a question about BOTH legs ──
|
||
///
|
||
/// This lived inline in `_MyPickupsState._tripProgress` and tested
|
||
/// `isActive || arrived` — and both of those are **pickup-leg** rungs. It is
|
||
/// read on the Deliveries tab, where every stop is past `pickup-complete` by
|
||
/// definition, so on a milk run the rung the rider is actually standing on is
|
||
/// [StopStatus.deliveryArrived], which matched neither. The customer's door he
|
||
/// was at *that moment* drew grey — indistinguishable from the eleven he had
|
||
/// not ridden to yet — and the scooter fell through to the caller's "promote
|
||
/// the first pending one" fallback, parking it somewhere up the route behind
|
||
/// him.
|
||
///
|
||
/// The rail's whole job is *where am I*. Answering it from one leg's
|
||
/// vocabulary, on the tab that only ever shows the other leg, is why the answer
|
||
/// was wrong all round.
|
||
///
|
||
/// [StopStatusX.isWorkComplete] decides `done`, so the answer is line-aware:
|
||
/// `picked` ends a logistics stop and is the middle of the morning on a round.
|
||
///
|
||
/// Pure, and out here rather than on the State, because `_MyPickupsState`
|
||
/// cannot be pumped — Get, Geolocator and a 3s poll — so a mapping left inside
|
||
/// it can only ever be verified by reading it. See `trip_progress_test.dart`.
|
||
StopProgress stopProgressFor(StopStatus status) {
|
||
if (status.isWorkComplete) return StopProgress.done;
|
||
if (status.isSkipped) return StopProgress.skipped;
|
||
if (status.isActive ||
|
||
status == StopStatus.arrived ||
|
||
status == StopStatus.deliveryArrived) {
|
||
return StopProgress.current;
|
||
}
|
||
return StopProgress.pending;
|
||
}
|
||
|
||
/// Formats a duration the way a rider plans: `2h 15m`, `45 min`.
|
||
String formatTripDuration(Duration d) {
|
||
if (d.inMinutes < 1) return '—';
|
||
if (d.inMinutes < 60) return '${d.inMinutes} min';
|
||
final minutes = d.inMinutes.remainder(60);
|
||
return minutes == 0 ? '${d.inHours}h' : '${d.inHours}h ${minutes}m';
|
||
}
|
||
|
||
/// Rounds a duration up to the nearest 5 minutes.
|
||
///
|
||
/// An estimate printed as "37 min" claims a precision the arithmetic does not
|
||
/// have and invites the rider to treat it as a promise. "40 min" reads as
|
||
/// what it is.
|
||
Duration roundTripDuration(Duration d) {
|
||
if (d.inSeconds <= 0) return Duration.zero;
|
||
final minutes = (d.inMinutes / 5).ceil() * 5;
|
||
return Duration(minutes: math.max(5, minutes));
|
||
}
|