Files
doormile_milderapp/lib/data/milk_run.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 05:40:35 +05:30

442 lines
20 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/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.
static String idOf(Map<String, dynamic> stop) =>
(stop['orderid'] ?? stop['orderId'] ?? '').toString();
/// 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.
static String sourceKeyOf(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 'none';
}
/// 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.
static String sourceNameOf(Map<String, dynamic> stop) =>
(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,
) {
final key = sourceKeyOf(stop);
return [
for (final s in stops)
if (sourceKeyOf(s) == 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 {},
}) {
if (!ServiceProfile.active.deliversToCustomer) return false;
if (collectedIds.contains(idOf(stop))) 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 kitchen = sourceNameOf(stop);
if (kitchen.isNotEmpty) return kitchen;
// ── 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. Its first non-numeric component is the neighbourhood — the
// same reading the timeline's area line uses — and "RS Puram" is
// something he can ride to in a way "pickup" is not. Only when the
// payload has no address either does the label fall back to a word, and
// then it is at least a capitalised noun.
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 'Pickup';
}
/// 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 {},
}) {
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;
}
if (reported == StopStatus.arrived) 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,
};
}