Files
doormile_milderapp/lib/data/work_domain.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

285 lines
14 KiB
Dart

import 'package:miler/Models/stop_status.dart';
import 'package:miler/data/milk_run.dart';
import 'package:miler/data/next_leg.dart';
import 'package:miler/data/service_profile.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// WHICH SCREEN OWNS AN ORDER
///
/// One rule, in one place, for the question both work screens have to answer
/// about every row of the shared day: **is this Home's or is it Deliveries'?**
///
/// ── Why it is here and not on the screens ──
///
/// It used to be answered twice. Home decided what to draw from
/// [stopStateOf] plus its own local stores; the Deliveries tab decided what to
/// list from a `where` clause inside its fetch, with a second `where` for the
/// statuses that clause was supposed to re-admit. Two expressions of one fact,
/// edited on different days, and they disagreed exactly where it hurts most:
/// **an accepted booking could be claimed by both.** It stayed on Home to be
/// collected, and it appeared on Deliveries as live work with a strip offering
/// to continue it — which opened a map, an I'VE ARRIVED and a confirmation
/// sheet for a collection the rider had not made yet.
///
/// The fix is not a third filter. It is that the classification is one function
/// on the shared data layer, above both screens, so they cannot hold different
/// opinions about where an order belongs.
///
/// ── The boundary ──
///
/// PENDING ─ accept ─▶ ACCEPTED ─ navigate ─▶ ARRIVED ─ pick up ─▶ PICKED
/// └──────────────── HOME owns all of this ──────────────┘ │
/// ▼
/// DELIVERIES owns from here on
///
/// **Picked up is the boundary**, and only the backend can move it. Accepting
/// an assignment is a decision about work still to be done; it is not evidence
/// that anything has been collected. Nothing in this file infers a completed
/// pickup from an acceptance.
///
/// ── The one knob ──
///
/// Where the boundary sits is a property of the rider's line, declared once as
/// [ServiceProfile.handoffAt], and read here rather than re-derived:
///
/// • **A kitchen line** ([HandoffPoint.collected]) hands over at collection.
/// The rider makes one trip to one counter for a stack of bags, so every
/// rung up to picked-up is Home's, in bulk, and Deliveries holds only what
/// is in his hands.
/// • **Logistics** ([HandoffPoint.accepted]) hands over at acceptance. There is
/// no counter and nothing to batch: he drives to one customer, raises the
/// shipment at the door and the collection *is* the job — so an accepted
/// booking is already the work tab's, which is where that flow lives.
///
/// Same rule, one declared knob. Not a line-name check, and not a per-screen
/// conditional. See [ServiceProfile.handoffAt].
/// ─────────────────────────────────────────────────────────────────────────
enum WorkDomain {
/// Home's. Pending, accepted, on the way, arrived — everything before the
/// backend has confirmed the load is aboard.
pickup,
/// The Deliveries tab's. Past the hand-over point for this line.
delivery,
/// Neither work screen's. Finished or withdrawn — Activity's record.
closed,
}
/// The hand-over point between [WorkDomain.pickup] and [WorkDomain.delivery].
abstract final class WorkBoundary {
/// True once the **backend** says the collection is done.
///
/// Two sources, both authoritative, neither of them "the rider accepted it":
///
/// • The status on the row — `Picked_Up` / `Converted_To_Consignment`, or
/// any of the delivery rungs past them.
/// • The collected record, written by Home only after the pickup-complete
/// call has come back successful. It exists because the queue is up to one
/// poll behind: without it a stop the rider has just handed over jumps back
/// to Home for a few seconds, which reads as the confirm having failed.
///
/// It deliberately does **not** consult the accepted store. That set says a
/// decision was made, not that goods changed hands.
static bool pickupComplete(
Map<String, dynamic> stop, {
Set<String> collectedIds = const <String>{},
}) {
final status = stopStatusOf(stop);
if (status.isPicked || status.isDeliveryLeg) return true;
// Booking key as well as stop key: a multi-destination pickup is collected
// once, before it splits into one row per door. See [MilkRun.wasCollected].
return MilkRun.wasCollected(stop, collectedIds);
}
/// True when this order is finished or withdrawn, whatever screen it was on.
///
/// [StopStatus.picked] is terminal for some orders and the middle of the
/// morning for others, and **which one is a property of the order, not of the
/// line**. It is asked of [NextLegResolver], which reads the backend's own
/// routing answer. See the note on the picked branch below.
///
/// ── Skipped closes, and why it has to close *here* ──
///
/// A skip used to leave the order open, and the Deliveries tab kept it under
/// its own heading on the argument that the rider looks for it where he left
/// it. The cost of that was **dual ownership**: the same stop sat in the
/// active queue *and* in Activity, so the two screens disagreed about whether
/// the rider still owed it a visit. One order, one owner — a stop he has
/// written off is a record, and records live on Activity.
///
/// ── Why the status alone cannot answer it ──
///
/// On the delivery leg the backend has no failed-attempt outcome. A
/// successful skip leaves the consignment `Out_for_Delivery`, so
/// `/miler/bookings` keeps returning the row as live delivery work for the
/// rest of the day, and [stopStatusOf] reads exactly what the server sent.
/// Asking only the payload therefore re-admits the stop on every poll and on
/// every cold start.
///
/// [closedIds] is the answer to that, and it is **not** a display flag: it is
/// the persisted record of a mutation that already succeeded — day-stamped,
/// scoped to this rider, tenant and line (see [WorkScope]) — written by the
/// completed and skipped stores at the moment the rider's action came back
/// OK. It is the same shape of evidence [pickupComplete] already takes from
/// `collectedIds`, and it survives the process that wrote it, which is the
/// whole point.
///
/// Clearing it is what a resume does: [removeSkippedBookings] drops the id
/// and the stop is live work again on the very next read. There is no second
/// place holding the same opinion.
static bool isClosed(
Map<String, dynamic> stop, {
Set<String> closedIds = const <String>{},
/// `next_action` recorded at each order's pivot — see [NextLegResolver].
Map<String, String> pivotActions = const <String, String>{},
}) {
if (closedIds.isNotEmpty && closedIds.contains(MilkRun.idOf(stop))) {
return true;
}
final status = stopStatusOf(stop);
if (status == StopStatus.delivered || status.isCancelled) return true;
// Filed under its own word, never flattened into cancelled: a stop the
// rider walked away from and one the office called off are different
// records, and Activity slices them apart.
if (status.isSkipped) return true;
// ── After the pickup, ownership is a question about the ORDER ──
//
// This read `status.isPicked && ServiceProfile.active.endsAtHub`, and that
// is a line-level constant deciding a per-order fact. On logistics
// `endsAtHub` is always true, so **every collected parcel was closed at the
// pickup** — including the hyperlocal ones the pivot had just released
// `Out_for_Delivery` into this rider's own hands. He walked away from the
// sender's door carrying a parcel his app had already filed as finished.
//
// The backend decides where a parcel goes next and always has; the app now
// asks. [NextLeg.closed] is the only answer that retires the stop —
// `customer` and `hub` are both journeys the rider still owes, and
// `unknown` is a parcel in his bag whose destination has not been named,
// which must never be silently written off. See [NextLegResolver] for the
// precedence between the pivot, the row and the local record.
//
// [ServiceProfile.endsAtHub] survives as a line-level capability hint — it
// still tells the route card whether the day ends at a depot — but it no
// longer decides whether one order is finished.
if (!status.isPicked && !status.isDeliveryLeg) return false;
return NextLegResolver.resolve(
stop,
pivotAction: pivotActions[MilkRun.idOf(stop)] ?? '',
).isClosed;
}
/// Which screen owns this order.
///
/// [rejectedIds] and a server-side rejection do **not** close an order here:
/// a declined stop stays in the pickup domain because Home is where the rider
/// can change his mind about it. What it must never be is delivery work.
static WorkDomain domainOf(
Map<String, dynamic> stop, {
Set<String> collectedIds = const <String>{},
Set<String> acceptedIds = const <String>{},
/// Orders written off today — delivered, cancelled or skipped — read back
/// from the completed and skipped stores. See [isClosed].
Set<String> closedIds = const <String>{},
/// `next_action` recorded at each order's pivot — see [NextLegResolver].
Map<String, String> pivotActions = const <String, String>{},
}) {
if (isClosed(stop, closedIds: closedIds, pivotActions: pivotActions)) {
return WorkDomain.closed;
}
// ── A collected parcel is the work tab's, whatever the line says ──
//
// Below [isClosed] and above the handoff knob, deliberately. Once the
// backend confirms the collection the parcel is physically the rider's, and
// "in my hands, still owed a journey" is the definition of the delivery
// domain on every line. Reading it here rather than from the per-line
// handoff point is what lets a hyperlocal logistics parcel reach Deliveries
// at all — its own line hands off at *acceptance*, which says nothing about
// custody.
//
// ── Gated on the collection having happened ──
//
// [NextLegResolver] answers [NextLeg.unknown] for any row it cannot place,
// and an uncollected booking is one of those: a pending assignment carries
// no consignment state and no pivot record, exactly like a collected parcel
// the server has not routed yet. Asking without this gate handed every
// pending stop to Deliveries — the dual-ownership failure in reverse.
//
// "Is it in his hands?" is [pickupComplete]'s question and it has a real
// answer: the backend's own status, or the collected record written after
// the pivot came back OK. Only then is there a leg to resolve.
if (pickupComplete(stop, collectedIds: collectedIds) &&
NextLegResolver.resolve(
stop,
pivotAction: pivotActions[MilkRun.idOf(stop)] ?? '',
).isInCustody) {
return WorkDomain.delivery;
}
final handedOver = switch (ServiceProfile.active.handoffAt) {
HandoffPoint.collected => pickupComplete(
stop,
collectedIds: collectedIds,
),
// On logistics the work tab takes it at acceptance — from either source,
// because the local store leads the queue by a poll.
HandoffPoint.accepted =>
acceptedIds.contains(MilkRun.idOf(stop)) ||
stopStatusOf(stop) == StopStatus.accepted ||
stopStatusOf(stop).isActive ||
stopStatusOf(stop) == StopStatus.arrived,
};
return handedOver ? WorkDomain.delivery : WorkDomain.pickup;
}
/// Everything the Deliveries tab is allowed to hold, out of the shared day.
///
/// The tab's list, the count on Home's pill and anything else that asks "how
/// much is waiting on the other tab?" all read this, so the two screens
/// cannot report different numbers for the same day.
static List<Map<String, dynamic>> deliveryQueue(
Iterable<Map<String, dynamic>> day, {
Set<String> collectedIds = const <String>{},
Set<String> acceptedIds = const <String>{},
Set<String> closedIds = const <String>{},
Map<String, String> pivotActions = const <String, String>{},
}) => [
for (final stop in day)
if (domainOf(
stop,
collectedIds: collectedIds,
acceptedIds: acceptedIds,
closedIds: closedIds,
pivotActions: pivotActions,
) ==
WorkDomain.delivery)
stop,
];
/// Everything Home is still responsible for, out of the shared day.
static List<Map<String, dynamic>> pickupQueue(
Iterable<Map<String, dynamic>> day, {
Set<String> collectedIds = const <String>{},
Set<String> acceptedIds = const <String>{},
Set<String> closedIds = const <String>{},
Map<String, String> pivotActions = const <String, String>{},
}) => [
for (final stop in day)
if (domainOf(
stop,
collectedIds: collectedIds,
acceptedIds: acceptedIds,
closedIds: closedIds,
pivotActions: pivotActions,
) ==
WorkDomain.pickup)
stop,
];
}