Files
doormile_milderapp/lib/data/milk_run.dart
Thiru-tenext d612916fe4 Session expiry, arrival geofence guard, multi-destination stops
Three fixes found by running the app on a real handset against production.

1. An expired token left the app looking signed in and unable to work.
   MilerApi.onUnauthorized was declared and called on every 401 but never
   assigned, so the token was dropped and nothing else happened: the profile
   stayed on disk, logged_out stayed false, and the rider saw his own name over
   a dashboard whose every call returned 401. He reads that as "no work today".
   The teardown now lives in endSession() and both ways out of a session — the
   Log out button and the 401 path — use it.

2. Arrived was written locally even when the rider was not there.
   updateArrivedStatus answers false for three different things and the caller
   treated all of them as "the write did not land", which is only true of one.
   A geofence refusal and a server refusal now stop the rung and hand back the
   reason; a dead network still advances, as it should.

3. A multi-destination customer pickup collapsed onto one stop.
   GET /miler/bookings returns a row per destination once collected, all with
   the same bookingid and reference. Every local store keys on that id, so the
   accepted store deduped two of three drops away and their consignment ids
   were unrecoverable. orderid is now the stop key; bookingreference stays the
   booking's name. Cards show "Stop 2 of 3" and the receiver's own name and
   number rather than the sender's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
2026-09-18 11:05:40 +05:30

684 lines
32 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:miler/Models/stop_status.dart';
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
import 'package:miler/data/pickup_locations.dart';
import 'package:miler/data/next_leg.dart';
import 'package:miler/data/service_profile.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE MILK RUN — accept per order, collect per kitchen, deliver per order.
///
/// ```
/// HOME DELIVERIES ACTIVITY
/// ──── ────────── ────────
/// 6 orders ─ accept ─┬─ Vidhya ×3 ─ arrived ─ picked ─▶ deliver ─▶ done
/// ├─ Priyanka×2 ─ arrived ─ picked ─▶ deliver ─▶ done
/// └─ ABC ×1 ─ arrived ─ picked ─▶ deliver ─▶ done
/// ```
///
/// ── The three units, and why they differ ──
///
/// **Acceptance is per order.** The rider takes on the morning's work in one
/// press, whichever counters it comes from.
///
/// **Pickup is per kitchen.** Three orders from Vidhya Kitchen are one ride,
/// one counter and one handover. He arrives once and is then given bags one at
/// a time, so arrival is grouped and collection is per bag — and a selection
/// must never span two kitchens, or he posts "arrived" for a shop he is
/// nowhere near. See [sameSourceAs].
///
/// **Delivery is per order.** Each bag goes to its own door.
///
/// Accepted orders therefore stay individually visible on Home; the kitchen is
/// what bounds a pickup operation, not what replaces the cards.
///
/// ── Collected work goes out immediately ──
///
/// There is no "start delivery" gate. A kitchen's orders become live
/// deliveries the moment they are in the rider's hands, so he can drop the
/// first three while a second kitchen is still cooking. Holding them until
/// every counter was done made the first customers wait for food already on
/// the bike.
///
/// ── How client stages map to the real backend ──
///
/// The UI has more stages than the API has statuses, which is fine as long as
/// every one either writes something real or is honestly local. None invents a
/// server state:
///
/// | Rider does | UI stage | Server call |
/// |-------------------|--------------------|--------------------------------------|
/// | Accepts work | `accepted` | `POST /assignments/:aid/accept` |
/// | Reaches a kitchen | `arrived` | `POST /bookings/:id/reached` |
/// | Takes a bag | `picked` | `POST /bookings/:id/pickup-complete` |
/// | (rides on) | `outForDelivery` | `POST /deliveries/start` ¹ |
/// | Reaches a door | `deliveryArrived` | *(local — no route exists)* ² |
/// | Hands it over | `delivered` | `POST /consignments/:cid/deliver` |
///
/// ¹ **not deployed.** Fired best-effort as part of pickup; until it ships the
/// consignments stay `Inwarded_at_Hub` and `deliver` refuses them.
/// ² **no endpoint exists.** See [deliveryArrivalIsLocalOnly].
///
/// Everything here is a pure function over the stop maps the rest of the app
/// already passes around, so it can be tested without a widget tree, a GetX
/// container or a network. Same reasoning as `AssignmentLookup.buildIndex`.
/// ─────────────────────────────────────────────────────────────────────────
class MilkRun {
MilkRun._();
/// **BACKEND DEPENDENCY.** There is no route that records a rider arriving at
/// a *delivery* address — `/bookings/:id/reached` keys on a booking in its
/// pickup phase, and the consignment routes have no arrival event. So the
/// `deliveryArrived` rung is held on the device and the hub sees the round
/// jump from out-for-delivery straight to delivered.
///
/// Nothing is faked to cover this: no call is made and no success is
/// invented. The rung exists because the *rider* needs the distinction — it
/// is what turns his Deliver button on at the right door — and the flag is
/// here so the gap is greppable rather than folklore.
static const bool deliveryArrivalIsLocalOnly = true;
/// Order id of a stop, however the payload spells it.
///
/// On a customer-app pickup with several destinations this is the **stop**
/// key, not the booking's — `DM-482913#1` — because everything the device
/// remembers is filed under it and three doors sharing one key means three
/// doors sharing one memory. The suffix is added by `ApiConfig` where the row
/// is adapted; see the note there. [bookingKeyOf] is the plain booking key,
/// and `bookingreference` on the row is what the rider is shown.
static String idOf(Map<String, dynamic> stop) =>
(stop['orderid'] ?? stop['orderId'] ?? '').toString();
/// The booking this stop belongs to, with any destination suffix removed.
///
/// Used where the fact being recorded is about the **visit** rather than the
/// drop. Collection is the case that matters: the rider collects a whole
/// pickup once, so the collected set is written with the booking key while
/// the delivery stops that come out of it each carry their own. See
/// [wasCollected].
static String bookingKeyOf(Map<String, dynamic> stop) {
final raw = idOf(stop);
final cut = raw.indexOf('#');
if (cut > 0) return raw.substring(0, cut);
final ref = (stop['bookingreference'] ?? '').toString().trim();
return ref.isNotEmpty ? ref : raw;
}
/// How many doors this one pickup visit is for. 1 for everything but a
/// multi-destination customer-app booking.
static int destinationCountOf(Map<String, dynamic> stop) =>
int.tryParse((stop['destinationcount'] ?? '').toString()) ?? 1;
/// Which door of the visit this stop is, counting from 0.
static int destinationSeqOf(Map<String, dynamic> stop) =>
int.tryParse((stop['destinationseq'] ?? '').toString()) ?? 0;
/// `Stop 2 of 3`, or `''` when the pickup has only one door.
///
/// Empty rather than `Stop 1 of 1` deliberately: a label that appears on
/// every card in the app stops being read, and the fact it carries — this
/// bag is one of several from the same counter — is only ever news when
/// there are several.
static String stopLabel(Map<String, dynamic> stop) {
final count = destinationCountOf(stop);
if (count <= 1) return '';
return 'Stop ${destinationSeqOf(stop) + 1} of $count';
}
/// True when the visit this stop came from has been collected.
///
/// Asks for the stop's own key first and the booking's second, and the second
/// question is the one that matters. A multi-destination pickup is collected
/// **once**, under the booking key, while it is still a single pre-pickup
/// row; the poll after `pickup-complete` then replaces that row with one per
/// door, each carrying a key the collected set has never seen. Asking only
/// the stop key would drop every one of those bags back to "not collected"
/// at the moment the rider actually had them in his hands.
static bool wasCollected(
Map<String, dynamic> stop,
Set<String> collectedIds,
) =>
collectedIds.contains(idOf(stop)) ||
collectedIds.contains(bookingKeyOf(stop));
/// The consignment this stop became once it was collected, or '' before that.
///
/// The delivery route keys on this and not on the booking id — they come from
/// different sequences, the same trap [AssignmentLookup] exists for on the
/// accept side. A stop without one cannot be delivered yet.
static String consignmentIdOf(Map<String, dynamic> stop) =>
(stop['consignmentid'] ?? stop['consignmentId'] ?? '').toString().trim();
// ── Which counter a stop is collected from ──
//
// Pickup is grouped by kitchen even though acceptance and delivery are per
// order. Three orders from Vidhya Kitchen are one ride, one counter and one
// handover; the rider works them together and must not be able to sweep an
// order from a different kitchen into that operation.
//
// The key is the **stable id** where the payload carries one, because two
// kitchens can share a display name across areas and the id is what the hub
// controls. A name-only payload still groups rather than collapsing into one
// nameless pile, and a stop with neither gets its own bucket rather than a
// fabricated kitchen.
/// The stable key a stop groups under for pickup.
///
/// ── The address is the last witness, and it was not being asked ──
///
/// Id, then name, then `'none'` — and `'none'` is its own bucket per the note
/// above, on the sound principle that the app must not fabricate a kitchen.
///
/// What that missed is that a stop with no source id and no source name is
/// not a stop with no *place*. It still says where it is collected from: the
/// pickup address, which is on every row, and which two orders off the same
/// counter share exactly because it is the same counter.
///
/// The cost of not asking was visible on a real morning. Eight orders came
/// from one kitchen, seven carrying its id and one not, and the eighth could
/// not be grouped with its siblings — so it fell out of the dropdown into a
/// flat header of its own, titled with the customer's name, reading as a
/// second place the rider had to ride to. Seven bags in a list and one
/// stranded above it, off the same shelf.
///
/// Grouping by address is not a fabricated kitchen: it is the weakest true
/// statement available — *these are collected from the same address* — and it
/// is only reached when the two stronger ones are absent. A row with no
/// address at all still gets `'none'`, and still stands alone.
/// [within] is the rest of the day, when the caller has it. A stop that
/// names no counter **adopts the one its neighbours name at the same
/// address** — which is the half of this that the reported case actually
/// needed: seven of the eight carried the kitchen's id and the eighth carried
/// nothing, so keying the odd one out by its address alone still left it in a
/// bucket of its own, correctly grouped with nobody.
///
/// Only ever *adopts*, never overrides: a stop with its own identity is
/// keyed by it and does not consult the day at all. So two counters that
/// share a street cannot merge, and a row cannot be pulled away from the id
/// the hub gave it.
static String sourceKeyOf(
Map<String, dynamic> stop, {
List<Map<String, dynamic>> within = const [],
}) {
final own = _identityKeyOf(stop);
if (own.isNotEmpty) return own;
final place = _addressKey(stop);
if (place.isEmpty) return 'none';
for (final other in within) {
if (_addressKey(other) != place) continue;
final identified = _identityKeyOf(other);
if (identified.isNotEmpty) return identified;
}
return 'at:$place';
}
/// The counter this stop names for itself — `id:…`, `name:…`, or `''` when
/// the payload names none.
static String _identityKeyOf(Map<String, dynamic> stop) {
final id =
(stop['sourceid'] ??
stop['kitchenid'] ??
stop['pickuplocationid'] ??
'')
.toString()
.trim();
if (id.isNotEmpty && id != '0') return 'id:$id';
final name = sourceNameOf(stop).toLowerCase();
if (name.isNotEmpty) return 'name:$name';
return '';
}
/// A pickup address reduced to something two rows can be compared on.
///
/// Case, spacing and punctuation all vary between rows the same hub wrote —
/// `12, SNS Colony` and `12 SNS COLONY ` are one place — so everything but
/// the letters and digits is dropped before comparing. Deliberately not
/// `compactAddress`: that is written to be *read*, and a key should not
/// change because a display rule was tuned.
static String _addressKey(Map<String, dynamic> stop) {
final raw =
(stop['pickupaddress'] ??
stop['PickupAddress'] ??
stop['pickup_address'] ??
'')
.toString()
.toLowerCase();
return raw.replaceAll(RegExp(r'[^a-z0-9]'), '');
}
/// The kitchen's name as the rider reads it, or '' when the payload has none.
/// **The** reader for a place's name. `stopSourceName` delegates here.
///
/// ── Two readers, one question, different answers ──
///
/// This read `sourcename` / `kitchenname`. `stopSourceName` read those *and*
/// the CamelCase `KitchenName` / `SourceName` the payload sometimes carries.
/// A booking with only the capitalised key therefore had a source according
/// to one function and none according to the other, and the route card used
/// both in the same expression:
///
/// • `stopSourceName` said "there is a counter", so the group was **not**
/// flat — the header became a foldable place with its orders under it.
/// • `sourceNameOf` said "there is no counter", so `navigationLabel` fell
/// through to its leg description and the header was titled **"pickup"**.
///
/// A group headed by the word *pickup* that folds open onto one order named
/// after the same stop — which is the shape the `flat` flag exists to
/// prevent, produced by the two halves of the decision disagreeing.
///
/// One key list, one answer.
///
/// ── The tenant's own location list wins ──
///
/// The booking row's `sourcename` is filled by the backend from
/// `providercompany` / `providerlocation`, and what lands there is often
/// whoever is on the account rather than the counter — the route card's
/// heading, the biggest type on Home, read `Sudharsan`.
///
/// The booking does carry the location's **id**, and the tenant's locations
/// are a list with proper names on them, so the name is joined rather than
/// read off the row. See [PickupLocations]: when the table is empty — not
/// loaded yet, or the rider's token is not admitted to the admin route —
/// this falls through to exactly what it returned before.
static String sourceNameOf(Map<String, dynamic> stop) {
final resolved = PickupLocations.nameFor(
stop['sourceid'] ?? stop['kitchenid'] ?? stop['pickuplocationid'],
);
if (resolved.isNotEmpty) return resolved;
// ── The counter's own name, ahead of the account's ──
//
// `pickup_source_name` ships with request 28 and is what the hub holds for
// the place. `sourcename` is filled from `providercompany` /
// `providerlocation`, and what lands there is whoever is on the account
// rather than the counter — which is how the biggest type on Home once read
// `Sudharsan`, a person, above a distance and an ETA to a place that name
// does not identify.
//
// Below the locations table, which is still the canonical name for a base,
// and above every legacy spelling, which are guesses by comparison.
final named = (stop['pickup_source_name'] ?? '').toString().trim();
if (named.isNotEmpty) return named;
return (stop['sourcename'] ??
stop['SourceName'] ??
stop['kitchenname'] ??
stop['KitchenName'] ??
'')
.toString()
.trim();
}
/// True when two stops are collected from the same counter.
static bool sameSource(Map<String, dynamic> a, Map<String, dynamic> b) =>
sourceKeyOf(a) == sourceKeyOf(b);
/// Every stop collected from the same counter as [stop], out of [stops].
///
/// What the rider ticks at a kitchen, and the bound on what one pickup
/// operation may touch.
static List<Map<String, dynamic>> sameSourceAs(
Map<String, dynamic> stop,
List<Map<String, dynamic>> stops,
) {
// The day is passed on both sides so a row that names no counter is
// gathered with the ones that do — see [sourceKeyOf]. Without it the bulk
// collect would leave behind exactly the order the route card had just
// learned to group in, which is the two-readers failure in a new place.
final key = sourceKeyOf(stop, within: stops);
return [
for (final s in stops)
if (sourceKeyOf(s, within: stops) == key) s,
];
}
// ══════════════════════════════════════════════════════════════════════
// WHERE "NAVIGATE" GOES
//
// The same button on the same order means two different places depending on
// where the order is in its day, and getting it wrong is expensive in both
// directions: sending a rider to a customer for an order still sitting in a
// kitchen wastes the trip *and* the customer's slot, and sending him back to
// a kitchen for a bag already in his box is a ride to collect nothing.
//
// So the destination follows the stop's stage, in one place, rather than
// being decided by whichever screen happens to own the button.
// ══════════════════════════════════════════════════════════════════════
/// Whether this stop's next journey is to the customer rather than the
/// kitchen.
///
/// True once it is collected. On a logistics booking this is always false:
/// that line's collected parcel goes to a hub and is delivered by somebody
/// else, so the rider never drives to its customer.
static bool navigatesToCustomer(
Map<String, dynamic> stop, {
Set<String> collectedIds = const {},
/// `next_action` per order, from the pivot. See [NextLegResolver].
Map<String, String> pivotActions = const {},
}) {
// ── The order's own routing outranks the line's shape ──
//
// The two lines below are the whole logistics fix, and they are checked
// first because a per-order fact must not be overruled by a per-line one.
//
// • **hub** is the safety case. A Gandhipuram → Chennai parcel is in this
// rider's hands and its receiver is 500km away; pointing a Navigate
// button at that address sends a Coimbatore rider down a national
// highway. He is going to a hub, and the customer is context, not a
// destination.
// • **customer** is the case the line-level test could never reach.
// `deliversToCustomer` is false on logistics, so a hyperlocal parcel the
// pivot had already released into this rider's own hands could not get a
// customer destination out of this function at all.
final leg = NextLegResolver.resolve(
stop,
pivotAction: pivotActions[idOf(stop)] ?? '',
);
if (leg.isHub) return false;
if (leg.isCustomer) return true;
// No per-order answer — [NextLeg.unknown], or a stop that has not been
// collected yet. Fall back to the line's own shape, which is exactly what
// this function did before and is still right for a milk run, whose rows
// often carry no consignment state at all.
if (!ServiceProfile.active.deliversToCustomer) return false;
if (wasCollected(stop, collectedIds)) return true;
final status = stopStatusOf(stop);
return status.isPicked || status.isDeliveryLeg;
}
/// The kind of work this stop is **for the leg the rider is on**.
///
/// ── Why [stopKindOf] is not enough on its own ──
///
/// `stopKindOf` answers "what kind of stop is this?" from the payload, and the
/// payload is wrong about it on the one line where it matters most: the
/// booking adapter stamps `type: pickup` on every row it builds, because a v1
/// booking *is* a first-mile pickup and the backend has no per-stop type to
/// send. See `ApiConfig.pickupFromBooking`.
///
/// So a milk-run order the rider has already collected — a bag in his box, on
/// its way to a subscriber's door — still described itself as a pickup. The
/// card offered **Start Pickup**, the confirmation sheet asked him to
/// **Confirm pickup**, and the write path behind that button took the pickup
/// branch. He was being asked to collect something he was carrying.
///
/// The leg is the honest question, and this app already knows how to answer
/// it: [navigatesToCustomer] is true exactly when the load is in his hands.
/// Everything the rider reads and everything the button posts should follow
/// that, not the stamped type.
///
/// Returns [stopKindOf]'s answer unchanged on every other line and every
/// other stage, so a parcel booking is untouched.
static StopKind workingKind(
Map<String, dynamic> stop, {
Set<String> collectedIds = const {},
}) => navigatesToCustomer(stop, collectedIds: collectedIds)
? StopKind.delivery
: stopKindOf(stop);
/// Whether this stop's next rung is worked on **its own screen** — the map,
/// then I'VE ARRIVED, then the confirmation sheet — or in bulk on Home.
///
/// ── The rule ──
///
/// On **logistics**, every stop is worked on its own screen. The rider drives
/// to one customer, weighs one parcel, raises one shipment and takes one
/// payment; there is nothing to batch.
///
/// On a **kitchen line** the pickup half is not a per-stop journey at all. He
/// makes one trip to one counter and is handed a stack of bags, so the rungs
/// up to *picked up* are a bulk gesture on Home — select the orders, slide
/// once — and the single-stop map/arrive/confirm screen is only ever the
/// **delivery** leg, one subscriber's door at a time.
///
/// ── What it prevents ──
///
/// A stop still to be collected could be opened on that screen from the
/// Deliveries tab's live strip. It took the rider through a map, an I'VE
/// ARRIVED and a confirmation sheet for a collection he was supposed to make
/// at the kitchen with everything else — a second, contradictory way to work
/// the same rung, on the tab that is supposed to be his load. The screens ask
/// this before they offer that route in.
static bool worksOnOwnScreen(
Map<String, dynamic> stop, {
Set<String> collectedIds = const {},
}) =>
!ServiceProfile.active.handsOffAtCollection ||
navigatesToCustomer(stop, collectedIds: collectedIds);
/// The coordinates Navigate should open, or null when the stop carries none
/// for the leg it is on.
///
/// Returns null rather than falling back to the other end: a Navigate button
/// that quietly opens the wrong destination is worse than one that is
/// disabled, because the rider only finds out when he arrives.
static ({double lat, double lng})? navigationTarget(
Map<String, dynamic> stop, {
Set<String> collectedIds = const {},
}) {
double? read(List<String> keys) {
for (final k in keys) {
final v = double.tryParse('${stop[k] ?? ''}');
if (v != null && v != 0) return v;
}
return null;
}
if (navigatesToCustomer(stop, collectedIds: collectedIds)) {
final lat = read(['droplat', 'DropLat', 'deliverylatitude']);
final lng = read(['droplon', 'DropLon', 'deliverylongitude']);
if (lat == null || lng == null) return null;
return (lat: lat, lng: lng);
}
final lat = read(['pickuplat', 'PickupLat', 'pickuplatitude']);
final lng = read([
'pickuplon',
'pickuplong',
'PickupLon',
'pickuplongitude',
]);
if (lat == null || lng == null) return null;
return (lat: lat, lng: lng);
}
/// What that destination is called, for the button and the sheet.
static String navigationLabel(
Map<String, dynamic> stop, {
Set<String> collectedIds = const {},
}) {
if (navigatesToCustomer(stop, collectedIds: collectedIds)) {
final name = (stop['pickupcustomer'] ?? stop['PickupCustomer'] ?? '')
.toString()
.trim();
return name.isEmpty ? 'customer' : name;
}
final place = pickupPlaceOf(stop);
if (place.isNotEmpty) return place;
// ── The word "pickup" is never a place ──
//
// This returned the literal `'pickup'`, and on a payload with no source
// name — which is what the live backend sends today — that word became
// the 24sp headline of the rider's first group. The project's own rule
// ("never display 'pickup' when a real name exists") was being met to the
// letter and lost in spirit: no name existed, so a leg description was
// promoted to a title.
//
// A rider thinks in PLACES, and the payload still knows one: the pickup
// address. Only when it has no address either does the label fall back to
// a word, and then it is at least a capitalised noun. See [pickupPlaceOf].
return 'Pickup';
}
/// **Where the rider is collecting from**, or `''` when the payload says.
///
/// ── Why this is not the customer's name ──
///
/// The route card's heading is the biggest type on Home, and on a group of
/// one order it was the *customer* — so a rider planning his next collection
/// read `Sudharsan`, which is a person, not somewhere he can ride to. The
/// place was on the card the whole time, one line down, in grey.
///
/// The order of preference is the order of usefulness at a kerb:
///
/// 1. the counter's own name — what is written on the sign
/// 2. the neighbourhood off the pickup address — what he steers by
///
/// Split out of [navigationLabel] so a caller can tell "no place in this
/// payload" from the word *Pickup*, which reads as a name and is not one.
static String pickupPlaceOf(Map<String, dynamic> stop) {
final kitchen = sourceNameOf(stop);
if (kitchen.isNotEmpty) return kitchen;
for (final key in const ['pickupaddress', 'PickupAddress']) {
final raw = (stop[key] ?? '').toString().trim();
if (raw.isEmpty) continue;
for (final part in raw.split(',')) {
final p = part.trim();
if (p.isEmpty) continue;
// Skip a leading door/plot number — the rider navigates by the
// neighbourhood, not by "12/4".
if (RegExp(r'^[0-9/\-]+$').hasMatch(p)) continue;
// And when the component carries its own house number — "124
// Gandhipuram Main Road" — the number is still not the place. Strip
// the leading digit token and title the street.
return p.replaceFirst(RegExp(r'^[0-9][0-9/\-]*\s+'), '');
}
}
return '';
}
/// Stops still owing the rider a collection at a given counter.
///
/// A stop counts as outstanding when it has been accepted and is not yet in
/// his hands. Anything he has been told he will not get — not loaded by the
/// source, cancelled, rejected — is not outstanding: it is settled, and
/// holding the round open for it would strand him at a counter waiting for a
/// bag that is not coming.
static List<Map<String, dynamic>> outstandingPickups(
List<Map<String, dynamic>> stops, {
required Set<String> acceptedIds,
required Set<String> collectedIds,
Set<String> notLoadedIds = const {},
Set<String> rejectedIds = const {},
}) {
final out = <Map<String, dynamic>>[];
for (final stop in stops) {
final id = idOf(stop);
if (id.isEmpty) continue;
if (collectedIds.contains(id)) continue;
if (notLoadedIds.contains(id)) continue;
if (rejectedIds.contains(id)) continue;
final status = stopStatusOf(stop);
if (status.isCancelled || status.isRejected) continue;
// Already down the delivery leg — collected on a previous session whose
// local set was cleared. Not outstanding.
if (status.isDeliveryLeg || status.isPicked) continue;
// Only work he has taken on holds the round open. An un-accepted stop is
// an offer, and a rider must not be blocked from starting his round by
// work he never agreed to.
if (!acceptedIds.contains(id) && !status.isFinishedPickup) {
if (status.isPending) continue;
}
out.add(stop);
}
return out;
}
/// Where a stop is on the milk-run ladder, from the app's own records.
///
/// The local sets lead the server here, deliberately: the queue endpoints are
/// a poll or more behind the rider and a card that ignores what he just did
/// reads as a button that did nothing. The backend status is the tie-breaker
/// underneath, not the first word.
static StopStatus stageOf(
Map<String, dynamic> stop, {
required Set<String> acceptedIds,
required Set<String> collectedIds,
Set<String> outForDeliveryIds = const {},
Set<String> deliveredIds = const {},
/// Stops the rider has reported arriving at.
///
/// ── Why arrival is a set and not a status ──
///
/// The backend team confirmed there is no Arrived rung in the booking
/// lifecycle: `/reached` records the arrival as a **timestamp**
/// (`reachedat`) beside a status that stays `pickup_scheduled`. And
/// `GET /miler/bookings` does not return that stamp — see
/// `getArrivedOrderIds` — so there is no server field to reconstruct
/// arrival from after a refresh.
///
/// It therefore arrives the same way the other two rider-owned facts do,
/// as a set of ids, and it is subject to the same precedence: it speaks
/// only where nothing further along has happened. Delete this parameter,
/// not the rung, on the day the stamp appears on a booking row.
Set<String> arrivedIds = const {},
}) {
final id = idOf(stop);
final reported = stopStatusOf(stop);
if (deliveredIds.contains(id) || reported.isDelivered) {
return StopStatus.delivered;
}
if (reported == StopStatus.deliveryArrived) {
return StopStatus.deliveryArrived;
}
// ── `Out_for_Delivery` is the consignment's state, not the rider's ──
//
// `pickup-complete` releases hyperlocal work itself: pickup and delivery
// pincodes sharing a 3-digit prefix means the parcel never sees a hub, so
// the consignment is stamped `Out_for_Delivery` in the same call that
// records the collection. Every DailyGrubs order is hyperlocal, so that is
// *always* what the next poll reports after a successful **Picked**.
//
// Reading it as this rung skipped `picked` entirely: the rider slid to
// confirm a hand-over at the counter and the ladder jumped straight to the
// delivery-active rung — before he had left the kitchen, let alone started
// the round. He never saw the state he had just created.
//
// So the released consignment resolves to [StopStatus.picked], and the
// delivery-active rung is the rider's own: [outForDeliveryIds] is written
// when he opens the stop and sets off. Nothing is faked and no status is
// invented — the two facts were simply being read as one. `deliveryArrived`
// and `delivered` are checked above and still win, so a stop the backend
// genuinely reports further along is never dragged back to picked.
if (outForDeliveryIds.contains(id)) return StopStatus.outForDelivery;
if (collectedIds.contains(id) ||
reported.isPicked ||
reported == StopStatus.outForDelivery) {
return StopStatus.picked;
}
// ── Arrived, from either source ──
//
// Below everything above it, deliberately: the pickup milestone outranks
// the arrival, and the delivery rungs outrank both. A rider's own arrival
// record can never walk a stop the hub has moved on backwards — the same
// rule the local store obeys everywhere else in the app.
if (reported == StopStatus.arrived || arrivedIds.contains(id)) {
return StopStatus.arrived;
}
if (acceptedIds.contains(id) || reported == StopStatus.accepted) {
return StopStatus.accepted;
}
return reported;
}
/// The button a stop's current stage offers, in the rider's words, or null
/// when the stop is waiting on something other than him.
static String? nextActionLabel(StopStatus stage) => switch (stage) {
StopStatus.accepted => 'Arrived',
StopStatus.arrived => 'Picked up',
StopStatus.outForDelivery => 'Arrived',
StopStatus.deliveryArrived => 'Delivered',
_ => null,
};
}