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
447 lines
20 KiB
Dart
447 lines
20 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
|
|
import 'package:miler/data/next_leg.dart';
|
|
import 'package:miler/data/service_profile.dart';
|
|
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// WHAT **THIS ONE JOB** REQUIRES — asked of the job, never of the rider
|
|
///
|
|
/// There is one kind of Miler. The same rider can be carrying ten meal parcels
|
|
/// out of a kitchen, five customer pickups booked in the Doormile app, and a
|
|
/// hub handover, all inside one shift. So nothing about a rider can decide how
|
|
/// a stop behaves: a rider is not "a milk man" or "a logistics rider", he is a
|
|
/// person with a queue, and every item in that queue brings its own workflow.
|
|
///
|
|
/// This is the replacement for reading [ServiceProfile.active]. That static
|
|
/// answers "who is this rider", which is the wrong question and gives the same
|
|
/// answer for every row on the screen — so a customer pickup sitting next to a
|
|
/// kitchen collection got the kitchen's rules, or vanished.
|
|
///
|
|
/// ── Read, never inferred ──
|
|
///
|
|
/// Every field below is derived from what the **server said about this row**:
|
|
/// `pickup_source_type` and `next_action`, both already on `GET /miler/bookings`
|
|
/// and both already parsed ([PickupSource], [NextLeg]). Nothing here looks at a
|
|
/// tenant, a business name, an address, a pincode or a missing id. Where the
|
|
/// server has not said, the answer is [WorkKind.unknown] and the screens draw
|
|
/// the plain, safe shape — the same doctrine [PickupSource] already states:
|
|
/// the server's word, or an honest "not known", never a guess.
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
enum WorkKind {
|
|
/// Collect in bulk from a place of business — a kitchen, a base, a shop.
|
|
/// The meal round's morning load and an ordinary hub collection are the same
|
|
/// shape of work: one address, a manifest, many bags.
|
|
sourcePickup,
|
|
|
|
/// Collect from a person's front door, against a booking that customer made
|
|
/// in the Doormile app. Needs the verify-at-the-door flow: count what is
|
|
/// actually there, weigh it, capture the destination(s), take payment if the
|
|
/// booking asks for it.
|
|
customerPickup,
|
|
|
|
/// Already in the rider's hands, going to a receiver.
|
|
customerDelivery,
|
|
|
|
/// Already in the rider's hands, going to a base. The network carries it from
|
|
/// there — this rider does not ride it to another city.
|
|
hubHandover,
|
|
|
|
/// Nothing left for this rider on this item.
|
|
done,
|
|
|
|
/// In custody, and the server has not said where it goes. Deliberately not
|
|
/// folded into [customerDelivery] or [done]: see [NextLeg.unknown], which
|
|
/// makes the same call for the same reason.
|
|
unknown,
|
|
}
|
|
|
|
/// The capabilities one work item needs, resolved from that item alone.
|
|
@immutable
|
|
class TaskProfile {
|
|
const TaskProfile._({
|
|
required this.kind,
|
|
required this.groupsBySource,
|
|
required this.leavesHomeOnAccept,
|
|
required this.deliversToCustomer,
|
|
required this.endsAtHub,
|
|
required this.capturesShipmentDetails,
|
|
required this.mayCollectCash,
|
|
});
|
|
|
|
/// What kind of work this row is.
|
|
final WorkKind kind;
|
|
|
|
/// This stop is collected in bulk from a named place of business, so Home can
|
|
/// head it with that place — a kitchen load is one visit, not fourteen rows.
|
|
///
|
|
/// ── NOT a drop-in for `ServiceProfile.active.sourceIsKitchen` ──
|
|
///
|
|
/// Read this before migrating a grouping call site. `sourceIsKitchen` looks
|
|
/// like "does this group?" and is not: it **selects which key to group by**.
|
|
///
|
|
/// • On the milk round, stops group under the **source they were collected
|
|
/// from** — the kitchen.
|
|
/// • On logistics, stops group under the **base that assigned the work**,
|
|
/// drops and collections alike, *including a collection made at a
|
|
/// customer's own door*. `hub_pickup_group_test.dart` states the rule:
|
|
/// "one building assigned them, so one node holds them".
|
|
///
|
|
/// Substituting this flag for that one splits a single hub run into two
|
|
/// groups — a customer collection stops keying to the assigning base and
|
|
/// wanders off under its own name. That was tried, and those tests caught it.
|
|
///
|
|
/// ── And DO NOT migrate a grouping site to this flag ──
|
|
///
|
|
/// This was attempted twice and reverted twice. The second attempt is the
|
|
/// instructive one: with the safe default below, `hub_pickup_group_test.dart`
|
|
/// went green and the **milk round lost every kitchen heading** —
|
|
/// `service_flow_test.dart` reports `Found 0 widgets with text "Kitchen 2"`.
|
|
///
|
|
/// The reason is the thing to understand before trying again: **the rows do
|
|
/// not carry the discriminator**. Neither the meal fixtures nor the logistics
|
|
/// fixtures set `pickup_source_type`, so per-stop classification cannot tell
|
|
/// a kitchen load from a customer door — both land on [WorkKind.unknown].
|
|
/// `ServiceProfile.active.sourceIsKitchen` was not merely *deciding* the
|
|
/// grouping; it was *supplying information the payload lacks*. Removing it
|
|
/// removes the fact, and no default can be right for both lines at once:
|
|
/// grouping by default breaks logistics, not grouping breaks the milk round.
|
|
///
|
|
/// What a grouping site actually needs is the stop's own **source identity**,
|
|
/// which the rows DO carry — `sourcename`, `pickuplocationid`, `sourceid`.
|
|
/// `MilkRun.sourceKeyOf` already reads exactly those and returns `id:…` or
|
|
/// `name:…` for a stop with a real named counter, falling back to `at:…` or
|
|
/// `none` for one without. That distinction is the grouping key, and it works
|
|
/// on today's payloads. Build on it, not on this flag.
|
|
///
|
|
/// This flag stays honest about its own narrow claim: "the server said this
|
|
/// is collected from a business". It is correct for rows that carry
|
|
/// `pickup_source_type`, and it is silent — not wrong — for rows that do not.
|
|
final bool groupsBySource;
|
|
|
|
/// Whether accepting moves it off Home immediately.
|
|
///
|
|
/// A customer pickup does: it becomes an appointment the rider owes someone,
|
|
/// and it belongs on Bookings until he has collected it. A source pickup does
|
|
/// not: accepting a kitchen load does not put anything in his hands, so it
|
|
/// stays on Home until he is actually carrying it.
|
|
///
|
|
/// Replaces `ServiceProfile.active.handoffAt`.
|
|
final bool leavesHomeOnAccept;
|
|
|
|
/// Finishes at a receiver's door.
|
|
final bool deliversToCustomer;
|
|
|
|
/// Finishes by handing over at a base.
|
|
final bool endsAtHub;
|
|
|
|
/// The door flow has to capture what is being shipped and where it is going —
|
|
/// package count, weights, destinations, recipients. Only a customer pickup
|
|
/// does this; a kitchen manifest is already known before the rider arrives.
|
|
final bool capturesShipmentDetails;
|
|
|
|
/// Money may change hands on this item. Whether it actually does is the
|
|
/// row's own COD figure — this only says the flow must be able to.
|
|
final bool mayCollectCash;
|
|
|
|
/// The safe shape: a plain stop, no grouping, no door capture, no promises
|
|
/// about where it ends.
|
|
static const TaskProfile unknownTask = TaskProfile._(
|
|
kind: WorkKind.unknown,
|
|
groupsBySource: false,
|
|
leavesHomeOnAccept: false,
|
|
deliversToCustomer: false,
|
|
endsAtHub: false,
|
|
capturesShipmentDetails: false,
|
|
mayCollectCash: false,
|
|
);
|
|
|
|
static const TaskProfile sourcePickup = TaskProfile._(
|
|
kind: WorkKind.sourcePickup,
|
|
groupsBySource: true,
|
|
leavesHomeOnAccept: false,
|
|
deliversToCustomer: false,
|
|
endsAtHub: false,
|
|
capturesShipmentDetails: false,
|
|
mayCollectCash: false,
|
|
);
|
|
|
|
static const TaskProfile customerPickup = TaskProfile._(
|
|
kind: WorkKind.customerPickup,
|
|
groupsBySource: false,
|
|
leavesHomeOnAccept: true,
|
|
deliversToCustomer: false,
|
|
endsAtHub: false,
|
|
capturesShipmentDetails: true,
|
|
mayCollectCash: true,
|
|
);
|
|
|
|
static const TaskProfile customerDelivery = TaskProfile._(
|
|
kind: WorkKind.customerDelivery,
|
|
groupsBySource: false,
|
|
leavesHomeOnAccept: true,
|
|
deliversToCustomer: true,
|
|
endsAtHub: false,
|
|
capturesShipmentDetails: false,
|
|
mayCollectCash: true,
|
|
);
|
|
|
|
static const TaskProfile hubHandover = TaskProfile._(
|
|
kind: WorkKind.hubHandover,
|
|
groupsBySource: false,
|
|
leavesHomeOnAccept: true,
|
|
deliversToCustomer: false,
|
|
endsAtHub: true,
|
|
capturesShipmentDetails: false,
|
|
mayCollectCash: false,
|
|
);
|
|
|
|
static const TaskProfile done = TaskProfile._(
|
|
kind: WorkKind.done,
|
|
groupsBySource: false,
|
|
leavesHomeOnAccept: true,
|
|
deliversToCustomer: false,
|
|
endsAtHub: false,
|
|
capturesShipmentDetails: false,
|
|
mayCollectCash: false,
|
|
);
|
|
|
|
/// Which surface this item belongs on. The whole point of the type.
|
|
///
|
|
/// • **Home** — assigned, not yet in the rider's hands, and not yet an
|
|
/// appointment he has taken on.
|
|
/// • **Bookings** — a customer visit he has accepted and still owes.
|
|
/// • **Deliveries** — anything already in his custody.
|
|
/// • **Activity** — finished.
|
|
WorkSurface surfaceWhen({required bool accepted}) => switch (kind) {
|
|
WorkKind.done => WorkSurface.activity,
|
|
WorkKind.customerPickup =>
|
|
accepted ? WorkSurface.bookings : WorkSurface.home,
|
|
WorkKind.sourcePickup => WorkSurface.home,
|
|
WorkKind.customerDelivery ||
|
|
WorkKind.hubHandover ||
|
|
WorkKind.unknown => WorkSurface.deliveries,
|
|
};
|
|
|
|
/// Resolve a queue row.
|
|
///
|
|
/// Order matters: `next_action` is asked first because it is the server's
|
|
/// statement about **where this parcel is in its life**, and that outranks
|
|
/// where it was collected from. A parcel picked up at a customer's door is a
|
|
/// delivery once it is in the bag; it stops being a pickup the moment it is
|
|
/// collected, and only the leg knows that.
|
|
static TaskProfile of(Map<String, dynamic> row) {
|
|
final leg = NextLegResolver.rowNextAction(row);
|
|
|
|
switch (leg) {
|
|
case NextActionWord.deliver:
|
|
case NextActionWord.startDelivery:
|
|
return customerDelivery;
|
|
case NextActionWord.inwardAtHub:
|
|
return hubHandover;
|
|
case NextActionWord.handedToHub:
|
|
case NextActionWord.none:
|
|
return done;
|
|
}
|
|
|
|
// Still to be collected — `next_action: pickup`, or a row too old to carry
|
|
// one. `stoptype` is the second witness: the server derives it from the
|
|
// booking status, so it answers even when next_action is absent.
|
|
final stopType = (row['stoptype'] ?? '').toString().trim().toLowerCase();
|
|
if (leg.isEmpty && stopType == 'delivery') {
|
|
// Collected, and nothing said where it goes. Custody without a
|
|
// destination — NextLeg.unknown's case exactly.
|
|
return unknownTask;
|
|
}
|
|
|
|
return switch (PickupSource.of(row)) {
|
|
PickupSource.customer => customerPickup,
|
|
PickupSource.hub ||
|
|
PickupSource.merchant ||
|
|
PickupSource.store => sourcePickup,
|
|
// ── No word from the server: take the safe shape, not a kind ──
|
|
//
|
|
// This said [sourcePickup] once, and that was the bug. `sourcePickup`
|
|
// carries `groupsBySource: true`, so every row too old to carry a
|
|
// `pickup_source_type` — which is every fixture in the existing suite,
|
|
// and every booking written before these fields shipped — started
|
|
// grouping under whatever name it could scrape together. A logistics day
|
|
// that is meant to bucket under one assigning base split into a group per
|
|
// customer. `hub_pickup_group_test.dart` caught it.
|
|
//
|
|
// An absent field is not evidence of a kitchen. It is evidence of
|
|
// nothing, and [unknownTask] is what nothing looks like: no grouping, no
|
|
// door capture, no promise about where it ends. Same doctrine
|
|
// [PickupSource] already states — the server's word, or an honest "not
|
|
// known", never a guess.
|
|
PickupSource.unknown => unknownTask,
|
|
};
|
|
}
|
|
}
|
|
|
|
/// The four places work can live in the rider app.
|
|
enum WorkSurface { home, bookings, deliveries, activity }
|
|
|
|
/// What a whole trip looks like, derived from the stops actually in it.
|
|
///
|
|
/// ── Why a trip needs its own answer ──
|
|
///
|
|
/// A few things on Home describe the **day**, not one stop: whether it starts
|
|
/// at a kitchen, whether it ends by returning to a base. Those used to read the
|
|
/// rider's line, which made them constant for the whole app — true enough when
|
|
/// a rider only ever did one kind of work.
|
|
///
|
|
/// Under mixed work they are no longer constant, and they are no longer even
|
|
/// exclusive: one trip can hold a kitchen load *and* five customer pickups, and
|
|
/// can finish by handing some parcels to a base while the last meal goes to
|
|
/// somebody's door. So the question changes from "which line is this rider on"
|
|
/// to "what is actually in this trip", and the honest answer is a fold over the
|
|
/// stops.
|
|
///
|
|
/// [endsAtHub] is `any`, not `every`, deliberately: if even one parcel has to
|
|
/// be handed in at a base, the rider's day ends at that base. Telling him
|
|
/// "END · HOME" while he is still carrying something the network needs is the
|
|
/// failure worth avoiding.
|
|
abstract final class TripShape {
|
|
/// The trip collects in bulk somewhere — so Home can head that group with the
|
|
/// place it came from.
|
|
static bool groupsBySource(Iterable<Map<String, dynamic>> stops) =>
|
|
stops.any((s) => TaskProfile.of(s).groupsBySource);
|
|
|
|
/// Something in the trip has to be handed over at a base before the rider is
|
|
/// finished.
|
|
static bool endsAtHub(Iterable<Map<String, dynamic>> stops) =>
|
|
stops.any((s) => TaskProfile.of(s).endsAtHub);
|
|
|
|
/// The trip contains at least one customer visit the rider owes — the work
|
|
/// that belongs on Bookings rather than Home once accepted.
|
|
static bool hasCustomerPickup(Iterable<Map<String, dynamic>> stops) =>
|
|
stops.any((s) => TaskProfile.of(s).kind == WorkKind.customerPickup);
|
|
}
|
|
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// THE BRIDGE — per item where the row knows, per line where it does not
|
|
///
|
|
/// [TaskProfile] is the right answer and it could not simply replace
|
|
/// [ServiceProfile.active] in one pass, for a reason worth stating plainly
|
|
/// rather than discovering twice: **most rows do not carry the discriminator
|
|
/// yet.** `pickup_source_type` and `next_action` shipped recently; a booking
|
|
/// written before them, and every fixture in the existing suite, carries
|
|
/// neither. [TaskProfile.of] correctly answers [WorkKind.unknown] for those,
|
|
/// and a screen that took `unknown` at face value would lose the milk round its
|
|
/// kitchen headings and the logistics day its door capture — which is what
|
|
/// happened both times a grouping site was migrated wholesale.
|
|
///
|
|
/// So this is the migration path, not a second source of truth:
|
|
///
|
|
/// the row said something → the row decides, per item
|
|
/// the row said nothing → the rider's line decides, exactly as before
|
|
///
|
|
/// Every method below is that shape. The consequence is the one that matters
|
|
/// for mixed work: a CX customer pickup sitting next to a kitchen collection
|
|
/// now gets **its own** rules on the strength of its own `pickup_source_type`,
|
|
/// instead of inheriting whatever the rider's tenant happens to say. The
|
|
/// fallback keeps every existing row behaving precisely as it does today, which
|
|
/// is what makes this safe to ship in the same release as the fence.
|
|
///
|
|
/// As the backend fills these fields in on every row (see handoff BE-6), the
|
|
/// fallbacks stop being reachable and can be deleted a call site at a time.
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
abstract final class WorkPolicy {
|
|
/// Does the rider have to establish what is being shipped at this stop —
|
|
/// count the parcels, weigh them, capture destination and recipient, take
|
|
/// payment?
|
|
///
|
|
/// This is the question that most needed taking off the rider. It had exactly
|
|
/// one call site, `ServiceProfile.active.needsVerification`, and that static
|
|
/// gives the *same answer for every row on the screen*: on a meal tenant a CX
|
|
/// customer pickup was walked straight past the door flow — no parcel count,
|
|
/// no destination, no weight — and the booking pivoted on whatever the
|
|
/// customer had typed into the app days earlier. On a parcel tenant the
|
|
/// reverse: a kitchen collection of fourteen known bags asked the rider to
|
|
/// itemise a manifest that was already on his screen.
|
|
///
|
|
/// A customer pickup needs it. A collection from a counter does not — the
|
|
/// manifest is known before he arrives.
|
|
static bool capturesShipmentDetails(Map<String, dynamic> row) {
|
|
final profile = TaskProfile.of(row);
|
|
if (profile.kind == WorkKind.unknown) {
|
|
return ServiceProfile.active.needsVerification;
|
|
}
|
|
return profile.capturesShipmentDetails;
|
|
}
|
|
|
|
/// Does this stop establish the shipment's addresses and price — the
|
|
/// logistics desk, as against a proof-of-collection photo?
|
|
///
|
|
/// Same shape and the same reason. Falls back to
|
|
/// `ServiceProfile.active.capturesShipmentAddresses`.
|
|
static bool capturesShipmentAddresses(Map<String, dynamic> row) {
|
|
final profile = TaskProfile.of(row);
|
|
if (profile.kind == WorkKind.unknown) {
|
|
return ServiceProfile.active.capturesShipmentAddresses;
|
|
}
|
|
return profile.capturesShipmentDetails;
|
|
}
|
|
|
|
/// Does collecting here bring a shipment into existence?
|
|
static bool initiatesShipment(Map<String, dynamic> row) {
|
|
final profile = TaskProfile.of(row);
|
|
if (profile.kind == WorkKind.unknown) {
|
|
return ServiceProfile.active.initiatesShipment;
|
|
}
|
|
return profile.kind == WorkKind.customerPickup;
|
|
}
|
|
|
|
/// Does an accepted stop stay on Home until the rider is actually carrying
|
|
/// it, rather than moving to Bookings the moment he accepts?
|
|
///
|
|
/// The per-item replacement for `ServiceProfile.active.handsOffAtCollection`
|
|
/// (`handoffAt == HandoffPoint.collected`). A **source pickup** stays: taking
|
|
/// on a kitchen load puts nothing in his hands and he still has to go and
|
|
/// collect it. A **customer pickup** does not: accepting it is an appointment
|
|
/// he now owes somebody, and it belongs on Bookings until he has been.
|
|
///
|
|
/// Asking the rider's line for this gave one answer for every row on the
|
|
/// screen — so on a meal tenant an accepted CX pickup sat on Home beside ten
|
|
/// kitchen bags with nothing to distinguish it, and on a parcel tenant a
|
|
/// kitchen load vanished off Home the instant it was accepted.
|
|
static bool staysOnHomeUntilCollected(Map<String, dynamic> row) {
|
|
final profile = TaskProfile.of(row);
|
|
if (profile.kind == WorkKind.unknown) {
|
|
return ServiceProfile.active.handsOffAtCollection;
|
|
}
|
|
return !profile.leavesHomeOnAccept;
|
|
}
|
|
|
|
/// Which surface this item belongs on.
|
|
///
|
|
/// The whole point of [TaskProfile], and the answer the four-tab shell cannot
|
|
/// yet act on in full — see the release report. Correct per item today, so
|
|
/// the queues can be split without re-deriving the rule.
|
|
static WorkSurface surfaceOf(
|
|
Map<String, dynamic> row, {
|
|
required bool accepted,
|
|
}) => TaskProfile.of(row).surfaceWhen(accepted: accepted);
|
|
|
|
/// True when this one item ends by handing over at a base.
|
|
///
|
|
/// Read from the row's `next_action`. Unlike the flags above there is no
|
|
/// fallback and none is wanted: [TaskProfile.hubHandover] is reached only
|
|
/// when the server has said `inward_at_hub`, and guessing a base leg from the
|
|
/// rider's line is how a hyperlocal parcel gets routed to a counter.
|
|
static bool endsAtHub(Map<String, dynamic> row) =>
|
|
TaskProfile.of(row).kind == WorkKind.hubHandover;
|
|
|
|
/// True when this one item ends at a receiver's door.
|
|
///
|
|
/// Falls back to the line, because a row with no `next_action` genuinely does
|
|
/// not say — and on a meal run every stop does end at a door.
|
|
static bool deliversToCustomer(Map<String, dynamic> row) {
|
|
final profile = TaskProfile.of(row);
|
|
if (profile.kind == WorkKind.unknown) {
|
|
return ServiceProfile.active.deliversToCustomer;
|
|
}
|
|
return profile.deliversToCustomer;
|
|
}
|
|
}
|