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>
This commit is contained in:
2026-08-22 05:40:35 +05:30
parent c8350563d9
commit d7348e253f
387 changed files with 80693 additions and 12272 deletions

441
lib/data/milk_run.dart Normal file
View File

@@ -0,0 +1,441 @@
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,
};
}