Files
doormile_milderapp/ABOUT_MILER.md
2026-08-28 18:16:28 +05:30

1161 lines
54 KiB
Markdown
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.
# Miler
The Doormile Miler app. One Android/iOS build that a rider signs into, gets his
work for the day, and reports it back from the doorstep.
`com.doormile.partner` · Flutter · GetX for the controllers that need it ·
one backend at `https://api.doormile.com/api/v1`
This is the only document in the repository. It covers what the app is, how the
work actually flows, the design system, every screen, and what is still wrong.
---
# Contents
1. [What it is](#1-what-it-is)
2. [One app, two lines of work](#2-one-app-two-lines-of-work)
3. [The lifecycle](#3-the-lifecycle)
4. [The API](#4-the-api)
5. [The data layer](#5-the-data-layer)
6. [The design system](#6-the-design-system)
7. [The bars](#7-the-bars)
8. [Home](#8-home)
9. [Deliveries](#9-deliveries)
10. [Activity](#10-activity)
11. [Delivery details](#11-delivery-details)
12. [Account](#12-account)
13. [Bottom sheets](#13-bottom-sheets)
14. [States every screen must survive](#14-states-every-screen-must-survive)
15. [What holds it](#15-what-holds-it)
16. [Known gaps](#16-known-gaps)
---
# 1. What it is
Miler is the field half of Doormile. The hub console assigns work; this is what
the rider on the bike actually holds. It covers a whole shift:
- **Sign in** — phone number and a 4-digit MPIN, verified server-side.
- **Go on duty** — a switch in the Home app bar, not a gate at launch.
- **Take work** — accept or decline the stops the hub assigns.
- **Get there** — a map with live routing, and one-tap navigation handoff.
- **Work the door** — arrive, confirm what happened, collect cash, take proof.
- **Report** — every transition posts as it happens, with GPS breadcrumbs.
- **Get paid** — earnings, activity history, rewards.
Plus what a shift needs around the edges: duty and break logging, background
location, push notifications, offline tolerance, support tickets, a profile.
### The one rule everything hangs off
> **One order = one bag.**
Five pickup orders from a kitchen means five bags, and then five deliveries.
There is no second quantity anywhere in the app that can disagree with the order
count — no crate figure, no aggregate bag total, no backend `Quantity` doing
double duty.
`lib/data/bag_manifest.dart` is the only place that answers "how many bags?" and
"which bag is this?":
- **The count** is `orders.length`. Derived, never stored, so it cannot drift.
- **The identity** prefers the label the kitchen printed on the physical bag and
falls back to the order's position *in its own pickup group* — `Bag 1`…`Bag 5`.
The third order from the second kitchen is that kitchen's Bag 3, not the
route's eighth, because a rider counts one shelf at a time.
Every surface states it the same way — `5 orders · 5 bags` — because those two
numbers being equal is something the rider can *check against a shelf*.
`test/one_bag_per_order_test.dart` fails if any screen starts counting something
else.
---
# 2. One app, two lines of work
Miler serves two operations off one login. Which one a rider gets is decided by
his **tenant**, resolved once from the login response.
| | **Milk Man** `ServiceProfile.milkMan` | **Logistics** `ServiceProfile.parcel` |
|---|---|---|
| The job | collect at kitchens, then a round of prepaid drops | first-mile: collect from a customer, take it to the hub |
| Home node | one per kitchen, orders beneath | one per stop, `flat: true` |
| Load line | `5 orders · 5 bags` | `Stop 3` |
| Hand-over point | **pickup-complete** | **acceptance** |
| Ladder | accept → arrived → picked → deliver | accept → work it on the work tab |
| Work tab is called | Deliveries | Bookings |
| Cash at the door | never | when the booking carries an amount |
| Verification form | none | full shipment desk |
| Closing leg | none — he finishes at the last door | `RETURN · HUB` |
### How the line is chosen
`lib/data/service_profile.dart` resolves it: stored tenant name → stored tenant
id → the **JWT `tenantid` claim** → build flag → logistics.
The claim read (`ApiConfig.tenantIdFromToken`) is the load-bearing one in
production: the deployed backend does not return `tenantid` in the verify-pin
body, only in the signed token. That is not a security decision and must not
become one — the signature is unverified client-side and every request is still
authorised server-side.
**It always falls back to logistics.** Showing a delivery rider the wrong
dashboard is recoverable; dropping a live parcel rider into an app with no
pickup flow is not.
### Capabilities, never line names
Everything else in the app reads a **capability**, never a line:
```dart
if (profile.collectsCash) // not: if (line == milkMan)
if (profile.needsProofPhoto)
if (profile.handsOffAtCollection)
```
That is why a new kind of client is a new `ServiceProfile` rather than a third
UI. `test/line_capability_test.dart` and `test/service_vocabulary_test.dart`
hold it.
> **`lib/xpress` is reference only.** A verbatim port of the old Xpress-rider
> app, reachable only via `--dart-define=TENANT=delivery`. It renders its own
> screens and does not go through anything in this document.
---
# 3. The lifecycle
This is the part to read before touching any delivery UI.
```
ASSIGNED ─accept─▶ ACCEPTED ─go─▶ ARRIVED ─pick up─▶ PICKED
└──────────────── HOME owns all of this ───────────┘ │
pickup-complete │
▼
consignment · Out_for_Delivery
│
DELIVERIES owns from here ▼
navigate → deliver → DELIVERED
```
### The boundary
`lib/data/work_domain.dart` · `WorkBoundary`
**One function decides which screen works an order**, so the two screens cannot
hold different opinions about the same row. It used to be answered twice — Home
from its local stores, Deliveries from a `where` clause inside its fetch — and
they disagreed exactly where it hurts: an accepted booking was claimed by both.
It stayed on Home to be collected *and* appeared on Deliveries as live work,
offering a map, an I'VE ARRIVED and a confirmation sheet for a collection the
rider had not made.
```dart
WorkDomain domainOf(stop, {collectedIds, acceptedIds})
→ pickup | delivery | closed // exactly one, always
```
**Picked up is the boundary, and only the backend can move it.** Two sources,
both authoritative, neither of them "the rider accepted it":
- the status on the row — `Picked_Up` / `Converted_To_Consignment` or beyond;
- the collected record, written by Home **only after `pickup-complete` returned
successful**. It exists because the queue is a poll behind: without it a stop
the rider 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.
Where the boundary sits is a property of the line, declared once as
`ServiceProfile.handoffAt` and read here rather than re-derived.
### Why Home's list is *not* filtered through it
`WorkBoundary` answers *"which screen **works** this order?"*. Home answers a
different question: *"what is my day?"*. Those are not the same list, and
filtering Home through the boundary broke two things at once:
- `pickupQueue` drops `WorkDomain.closed`, and Home's list is documented to keep
finished stops — the trip's progress ring needs them as its denominator.
Filtering them turned "3 of 8 done" into "3 of 3 done".
- On logistics the boundary is *acceptance*, so every accepted booking is
`WorkDomain.delivery`. Home still has to show them: they are the rider's run,
and the kitchen group expands to list exactly those orders. The filter emptied
the group and the dropdown stopped opening.
The guarantee is about **actions**, not display. Deliveries lists only what
`deliveryQueue` admits, and Home's own rungs are gated by
`MilkRun.worksOnOwnScreen`. An order can be *visible* on Home as part of the day
while being *worked* on Deliveries — what it can never be is actionable in two
places.
### The consignment id
`deliver` and `skip` key on the **consignment**, which exists only after
`pickup-complete` converts the booking. They are different sequences: passing a
booking id gets a 404 while the rider is shown success.
`pickup-complete` returns the new id and `rememberConsignmentId` files it
against the order. This was broken for a while: `updatePickedStatus` sent no
`orderid`, so the mapper fell back to the *booking* id while `closeDelivery`
read the map by **order** id. Every collected stop was filed under a key nothing
would ever ask for, and pressing **Delivered** answered *"this order was never
picked up on the system"* for a bag the rider was holding.
The door now resolves it from four places in order of freshness: the row, the
local record (by order id, then booking id, for stops filed the old way), and
finally `GET /miler/assignments`. Four empties is a real state and gets a real
message — never a fabricated id.
---
# 4. The API
The rider surface only: `/miler/*`, 38 routes, base
`https://api.doormile.com/api/v1`. **The API reference document is the contract
authority** — the local `doormile_backend` checkout is behind production and
contradicts it on at least five points.
### Auth
Two-step: phone → PIN. `configid` is **1001**, the partition riders live in.
```
POST /miler/login { phone, configid }
POST /miler/verify-pin { phone, pin, configid, device_token }
→ { success, token, user: { …, profile } }
```
No `data` key on verify-pin — the profile fields live under `user.profile`.
Bearer token on everything else; role 5 enforced.
`POST /miler/reset-pin` needs an **admin** token and is deliberately absent from
`MilerApi`. It was once open, and reset-pin → verify-pin took over any rider
account given only a phone number.
### The pickup flow
| Step | Call |
|---|---|
| 1 | `POST /miler/bookings/:id/reached` |
| 2 | `POST /miler/bookings/:id/parcel` |
| 3 | `POST /miler/bookings/:id/payment` |
| 4 | `POST /miler/bookings/:id/pickup-complete` |
**`pickup-complete` is the pivot.** It converts the booking into a consignment,
recomputes chargeable weight from the dimensions the rider measured, and decides
routing: matching 3-digit pickup/delivery pincode prefixes are hyperlocal and
the consignment goes **straight to `Out_for_Delivery`** in the rider's hands.
Everything else routes via a hub.
### Delivery
```
POST /miler/consignments/:id/deliver { deliveredtoname, otp?, photourl, lat, lon }
POST /miler/consignments/:id/skip { reason, lat, lon }
```
The consignment must be `Out_for_Delivery` or both are a 400. `otp` is required
**only** when the tenant has `requiredeliveryotp` on — off by default, and off
for DailyGrubs. Do not send a placeholder.
### Traps, all of them load-bearing
- **`accept`/`reject` key on `bookingassignmentid`**, not the booking id.
- **`deliver`/`skip` key on the consignment id.**
- **`vehicle-required` and `reject` read query strings**, not a body.
- **Telemetry scalars are strings.** `latitude`, `speed`, `heading`, `battery`
on `/logs` and `/consignments/logs` fail to parse as JSON numbers.
- **Never send `userid` in a telemetry body.** It once let one rider write
another's GPS into the dispatch index.
- **The break status is `Break`**, not `On_Break`.
- **`POST /miler/deliveries/start` does not exist.** It was declared and called
after every pickup, and 404'd every time. There is no rider-facing release
step to make — `pickup-complete` already does it. Removed; do not add it back
without a contract change.
- **`PATCH /bookings/:id/addresses`** is also off-contract and still referenced.
### Timestamps
IST wall-clock in `timestamp without time zone` columns. Some responses come
back with a trailing `Z` anyway — a Go + pgx footgun where a naive timestamp
loads under the UTC location and marshals with a marker it never had.
`DateTime.tryParse` believes the `Z`. That broke the Activity tracking rail in
the one way it cannot survive: an event stamped `11:43Z` sorted *below* two
11:57 events, because as an absolute instant it is 17:13 IST — while its own
rung still printed `11:43`, so nothing on screen explained the order.
**Everything goes through `parseStamp`** (`activity_format.dart`), which strips
any trailing zone marker first.
---
# 5. The data layer
```
GET /miler/bookings
│
WorkRepository one request however many screens ask; one copy;
│ out-of-order responses dropped
ApiConfig.pickupFromBooking new booking → legacy stop map
│
┌────┴────┐
Home Deliveries both classify through WorkBoundary
```
### Local stores — `lib/data/accepted_store.dart`
| Store | Means | Written |
|---|---|---|
| `accepted` | he took the work on | on accept |
| `collected` | it is in his hands | **after** `pickup-complete` succeeds |
| `outForDelivery` | he has set off | on the round-start press / opening a stop |
| `completed` | finished, with its clocks | at completion |
| `skipped` | put down, with the reason | on skip |
| `consignmentIds` | order id → consignment | at the pivot |
Every one is a **mirror of a fact the app already established**, existing because
the queue endpoints are a poll behind the rider. None of them invents a server
state. The stores are merged *last* when Activity loads, and they win.
### The event ledger — `lib/data/order_events.dart`
`GET /miler/bookings` returns a booking's *current* status and no event feed, so
four of the six moments a rider's day is made of left no trace: he accepted at
9:10, reached the kitchen at 9:28, took the bag at 9:35, set off at 10:15, and
by the time he opened his own history all four were gone.
`{orderid: {event: iso}}` in one prefs key, stamped **at the moment each is
true** by the screen that already knows:
| Event | Stamped by |
|---|---|
| `assigned` | `WorkRepository`, first fetch carrying the booking — and **only for work not already past his hands**, or opening the app in the afternoon retro-labels the morning |
| `accepted` | Home, all three accept paths |
| `arrived_at_pickup` | Home's `_advanceStop('ARRIVED')` |
| `picked_up` | Home's `_advanceStop('PICKED')` |
| `out_for_delivery` | `MyPickups.startRound` |
**Written once** (re-accepting does not move the clock), **cleared on resume** (a
resumed skip is a new attempt), **pruned at two days** so a shift crossing
midnight keeps its morning. **Nothing reads it to decide anything** — no gate, no
filter, no status. Delete the key and the app behaves identically; Activity just
draws fewer rungs.
### Mocks
`MockBackend` and `MealRunMock` are behind `--dart-define=MOCK_BACKEND`, off by
default, and only tests mutate the flag. `purgeDemoRecords()` runs at launch and
strips anything whose id starts `MOCK-`. **No mock data is reachable in a
production build.**
---
# 6. The design system
> **Content leads. Containers recede.**
Not a style preference — arithmetic. Home went through three versions and each
was killed by the same thing:
| version | permanent furniture before the first stop |
|---|---|
| shift banner + CURRENT SHIFT card + 3 stat cards | **~351pt** |
| brief card + NEXT ACTION glass card | **~270pt** |
| brief line + run head | **~100pt** |
At 351pt on a ~650pt scrollable region, over half the screen was spent before
the rider saw a single stop. Every element was individually defensible. **That is
the failure mode: a screen does not get heavy in one commit, it gets heavy a
line at a time.**
### Typography
**Poppins**, bundled, all seven static weights.
> It had never rendered a glyph. `pubspec.yaml` declared the family as
> `poppins` with a lowercase path on every asset while the files are
> `assets/fonts/Poppins/Poppins-*.ttf`. Asset bundling is case-sensitive on both
> platforms, so nothing resolved and everything fell back to Roboto, silently.
All seven weights are declared because Poppins is not a variable font. The app
leans on `w700`/`w800` for headings; without explicit entries Flutter
synthesises them by smearing SemiBold, which is what makes a heading look muddy
at exactly the size it is meant to be read from.
**The weight scale is calibrated for this family.** Poppins is geometric — wide
bowls, thick stems — so its `w800` is far darker than a variable Manrope's. The
whole scale came across a grade too heavy on the swap and every weight in `lib/`
(outside `lib/xpress`) was stepped down one: 900→800, 800→700, 700→600, 600→500.
Relative hierarchy untouched. **If the family changes again, re-tune this** — a
weight is a number, not a design.
`MilerType` is the scale. Nothing sets `fontFamily` by hand.
**Poppins carries no U+2192 or U+2713.** The route line was `'$from → $to'` in a
single `Text`, so the app's own typeface drew the most-read line on the Activity
record as `Vidhya Kitchen ▯ Joe Mathew`. Marks are `Icon`s now, drawn from a font
that is always present; only *words* are text. `₹` **is** present — verified by
reading the cmap, which is far cheaper than a render.
`fontFamilyFallback` is `['Manrope', 'Roboto', 'sans-serif']` — Manrope first
because it is bundled, so its coverage is guaranteed rather than depending on
what the platform ships.
### Colour
`ColorConstants` · brand red **#960019**.
| Token | Means |
|---|---|
| `primary` | actions, and the live/current state |
| `primary` @ 0.10–0.12 | selection, and status tints |
| `acceptGreen` | done — and money, via `moneyGreen` |
| `warning` | attention: parked work, late, an exception |
| `secondaryText` | everything waiting on somebody else |
| `pureSurface` | the paper |
**Colour is never the only channel.** Every state carries a word *and* a glyph
*and* a tint — these are read outdoors, in sunlight, on a scratched screen, by
somebody in a hurry.
### Icons
Material **Rounded**, one family, matching Poppins' geometry. Ten stragglers on
the sharp-cornered default set were moved; only `Icons.trip_origin` remains, as
it has no rounded variant.
### Motion
| Gesture | Motion |
|---|---|
| Expand / collapse | `AnimatedSize` 220 ms `easeOutCubic` |
| Chevron, chips, selection | `DesignConstants.motionState` (200 ms) |
| State swap | `SmoothSwap` |
| Page push | `openScreen` |
Everything is quick and functional. Nothing is a reveal.
### Rules that keep recurring
- **`AnimatedSize`, never `AnimatedCrossFade`,** around a collapsing panel. The
crossfade lays out both children at the interpolated height, so the
full-height child is laid out inside a shrinking box — the
`BOTTOM OVERFLOWED BY n PIXELS` stripe.
- **`BottomPage.bottomInset(context)` is the only source** of the floating nav
bar's height. `SafeArea` knows nothing about it, and `bottomInset` already
includes `MediaQuery.padding.bottom` — wrapping something in both
double-counts the gesture inset.
- **Anything in a fixed column must be `Flexible` and ellipsised.** A 21-character
booking reference in a fixed 5/9 column overflowed by 12 px and painted the
debug stripe over a production screen.
- **A fixed-height box around text clips at 2.0× scale.** Size to the type.
---
# 7. The bars
### The material
`milerGlassSurface()` — an 18-sigma `BackdropFilter` under `glassRed`, a
translucent maroon at 7%, passed as `flexibleSpace` so the wash covers the
status-bar inset too. A bar tinted only below the notch reads as a stripe rather
than a surface.
A flat 7% red over white is a colour; glass is a *relationship*. Content scrolls
under these bars, so the blur has something real to work on. 7% is deliberately
weak — at that strength the surface stays light enough for near-black type,
which is what keeps a title readable through a windscreen mount in sun.
Home's header is the exception: nothing scrolls behind it, so a blur has nothing
to work on. It uses `Color.alphaBlend(glassRed, pureSurface)` — same tokens,
composited flat, so the tint at the top of the screen cannot change as the rider
switches tab.
### The top bar — `MilerAppBar`
Solid `primary`, 76pt, title left, optional trailing control. Everything on it
inverts, including the status-bar glyphs.
### The sheet
`MilerSheet` rounds the page's top corners to 22 and **clips**; the scaffold's
background becomes `primary` so the brand shows through behind them.
The bar and the page used to meet on a hard horizontal line the full width of
the phone, which made every tab read as *a coloured strip above a form* — the
bar bolted on rather than the top of the screen the content lives in. All four
tabs use it. It clips rather than merely decorating, because three of the four
hold a scrolling list and a `BoxDecoration` alone would let rows paint over the
corners on the first pixel of a scroll.
> Home cannot set `backgroundColor: primary` (its `Stack` paints on that ground),
> so the brand is a `ColoredBox` directly behind the sheet. Without it the
> corners reveal the page's own colour and the curve is invisible.
### The bottom bar — `BottomPage`
A floating frosted pill, not a `bottomNavigationBar`. **0 Home · 1
Bookings/Deliveries · 2 Activity · 3 Account.**
| | |
|---|---|
| Fill | `pureSurface` at 82% under a 20-sigma blur |
| Radius | 32 at rest → 23 compact |
| Inset | 16 from each edge → 30 compact |
| Height | 66 → 46 |
It is a `Positioned` pill inside the shell's `Stack` — which is exactly why
`bottomInset` exists. Tab 3 used to be Earnings; a pay figure is a
once-or-twice-a-day read and did not need a permanent quarter of the bar.
---
# 8. Home
`lib/views/Dashboard/home/` · tab 0
> **What do I do now?**
Home owns the complete pickup lifecycle: **Accept → Navigate → Arrived →
Picked Up**. Nothing leaves for Deliveries until `pickup-complete` succeeds.
```
┌──────────────────────────────────────────────┐
│ R DOORMILE DELIVERY [ on duty ] │ header (flat glass)
│ Hi, Rajan │
╭──────────────────────────────────────────────╮ ← MilerSheet
│ Trip 1 Trip 2 Trip 3 │ trip selector
│ 91% done Not set Not set │
│ │
│ 2 stops left Today's route │ the brief
│ ≈2h · 9.8 km · 22 parcels · None │
│ │
│ TODAY'S RUN [ Map ] │
│ │
│ ① Vidhya Kitchen ➤ 3.1 km │ the pickup node
│ 5 orders · 5 bags ⏱ ~9 min │
│ [ Accepted 5/5 ] [➤] [☏] [ⓘ] │
│ ┌────────────────────────────────────┐ │
│ │ Select all ⊙ │ │
│ │ ① Joe Mathew Bag 1 › │ │ the steps
│ │ Gandhipuram │ │
│ │ ② Arun Prakash Bag 2 › │ │
│ │ RS Puram │ │
│ └────────────────────────────────────┘ │
│ ② Joe Kitchen ➤ 4.8 km │
│ 3 orders · 3 bags ⏱ ~14 min │
│ [ Pending ] [☏] [ⓘ] │
╰──────────────────────────────────────────────╯
╭─────────────────────────────────╮
│ 21 deliveries in hand › │ handoff pill
╰─────────────────────────────────╯
```
**No cards.** Not "small cards" or "flat cards" — none. Grouping is done by the
timeline's geometry, whitespace and type weight, because those cost nothing and
a container costs a border, a padding, a shadow and the width they take off the
narrowest axis the rider has.
### The pickup node — the kitchen is the card
`route_timeline.dart` · `PickupTimelineGroup`
**The header is the place; the orders are its contents.** One kitchen, one node,
its orders disclosed underneath.
Three responsibilities, three targets, none overlapping:
| Gesture | Does |
|---|---|
| Tap the header | expand / collapse the place |
| Tap an order row | select / deselect it for the batch |
| The bag block on the right | open that order's detail sheet |
The header's tap used to *select* on a flat group and *expand* on a grouped one,
so the same object did two different things depending on data the rider cannot
see — and on a kitchen the only way to reach the orders was a tap that, one
route later, would tick something.
**A flat group** — one order at one address with no counter worth naming — has
nothing to disclose, so its header opens the detail sheet, which is the same
promise the chevron on it makes. A parcel route is always this.
### The name must come from the domain
> **The regression worth writing down.**
>
> Two functions answered "what is this place called" and read different keys:
> `MilkRun.sourceNameOf` took `sourcename`/`kitchenname`, `stopSourceName` took
> those **and** the CamelCase `SourceName`/`KitchenName` the payload sometimes
> carries. `trip_card.dart` uses both in one expression, so a booking with only
> the capitalised key was **not flat** (a counter was found → foldable group)
> but **titled "pickup"** (no counter → `navigationLabel` fell through to its leg
> description).
>
> On a device: a group headed by a backend word, which the rider taps and which
> opens a detail sheet instead of the list. Fixed at the domain layer — one
> reader, four keys, `stopSourceName` delegating to it.
The rider thinks in **places**, not booking terminology. `Vidhya Kitchen`, never
`pickup`.
### The steps
**44pt** — one line of text plus its air, deliberately **lighter than the header
above it**. Five 15sp/w600 names in full slate under a 19sp/w800 header out-weigh
the destination they hang under, and the block reads as six peers rather than one
place with five bags in it.
What a rider reads off a step is **who**, **where it is going**, and **which
bag** — the first two are how he decides whether Joe and Priya are the same trip
out of the door, the third is what he matches against a shelf.
> The row used to carry the booking reference. That is what the office reads
> down a phone once, when something has gone wrong; the **drop area** is what he
> decides on every time he looks at the list. The reference moved to the detail
> sheet and the Activity record. `_areaOf` takes the first non-numeric component
> of the drop address — a door number is what he navigates by last.
The bag sits at the right edge so a column of them lines up against a shelf he
is counting.
### State, visible per group
`Pending` → `Accepted 2/5` → `Accepted 5/5` → `Arrived` → `Picked up`.
`onRungCount / groupSize` is derived, never stored. A rider reading "1 of 3" off
a place he knows holds four would not trust the number again.
### Selection and the command bar
He **ticks** the ones he wants and commits them from one floating bar — never a
per-card Accept, because six stops meant twelve buttons and six separate wire
calls, one mis-tap each. The select-all row at the head of a group takes the
whole kitchen in two taps.
Declining is on the same bar, over the same ticked set: a rider ticking four
stops is declining them for one cause, and asking four times would make him pick
something, anything, four times.
**A selection must never span two kitchens**, or he posts "arrived" for a shop he
is nowhere near.
### The ladder
```
PENDING ──tick + Accept──▶ ACCEPTED ──navigate──▶ (at the counter)
│
▼
[ Mark as Arrived · 5 ] ← floating bar
▼
┌──────────────────────────────┐
│ Arrived at Vidhya Kitchen │ ← bottom sheet
│ 5 orders · 5 bags │
│ 1 Joe Bag 1 │
│ … │
│ ⟶ Slide to confirm arrival │
└──────────────────────────────┘
▼
ARRIVED ──collect the bags──▶
[ Mark as Picked · 5 ] → same sheet, same slide
▼
PICKED ──▶ leaves Home for Deliveries
```
Three rules hold it together:
- **He arrives at a place, not at an order.** The rung acts on a *set*.
- **The sheet lists what it covers.** A number cannot be checked against a shelf;
five named lines with a bag each can, and a missing sixth is visible before he
rides away. **Report missing bag** is where a short pick is recorded — marked
bags leave the route with their own record. Marking *every* bag kills the
slide: an empty crate is a call to the hub, not a state to file silently.
- **Pressing is easy, committing is deliberate.** The bar's button only *opens*
the confirmation; the slide inside the sheet is what writes. "Picked" is the
rider asserting he is holding somebody's lunch, and a resting thumb on a moving
bike must not be able to say that.
### The handoff pill
`AcceptedPill` — the count of work waiting on the other tab, derived from
`WorkBoundary.deliveryQueue` rather than from a stored list. A store written on
accept and cleaned on completion keeps anything that left the day some other way,
and the pill then counts work the rider no longer has.
The verb follows the boundary: **"in hand"** on a round (the count is work
already collected), **"accepted"** on logistics (where the boundary *is*
acceptance).
It uses `bottomInset` and **no `SafeArea`** — the inset already includes the
gesture bar, and a second opinion about it makes a floating control drift between
devices.
---
# 9. Deliveries
`lib/views/Dashboard/pickups/` · tab 1 · titled from
`ServiceProfile.workTabLabel`
> **Which one now?**
Only genuine post-`pickup-complete` work reaches this tab. It never shows pickup
actions or pickup sheets.
```
My Deliveries [ map ]
╭──────────────────────────────────────────────────╮
│ Trip 1 0 of 21 done │
│ ◎──1──2──3──4──5──6 ▒ │ route rail (scrolls,
│ │ edges fade — not clip)
│ On stop 1 of 21 · 1h 50m left │
│ │
│ NOW │
│ ┌────────────────────────────────────────────┐ │
│ │ SEQTEST Ukkadam [PICKED] │ │
│ │ DailyGrubs RS Puram Kitchen │ │
│ │ Bag 8 │ │
│ │ ➤ 1.1 km · 3 min │ │
│ │ [ Start Delivery ] [☏] [⋯] │ │
│ └────────────────────────────────────────────┘ │
│ UP NEXT │
│ … │
╰──────────────────────────────────────────────────╯
│ 4 orders in hand │
│ [ Start round ] │ release bar
```
### The queue
`NOW` / `UP NEXT` / `LATER`, and `SKIPPED` above them all. One live card at a
time — while another stop is running there is no live card, because the one he
is working is on its own screen.
Skipped stops keep their own heading **above** NOW: they are earlier in route
order, still resumable, and burying them hides work he has already been to once.
### The card
**The drop leads.** The destination is the job now; the source drops to a smaller
supporting block that still answers "whose bag is this?". The bag rides with the
customer's name — that is the line that stops Priya's lunch going to Joe.
Bag identity is **stored at the counter, not recomputed**. Deliver Joe and a
position-derived Arun would silently become Bag 1, sending the rider looking for
a bag already in somebody's hallway. `saveBagLabels` fixes the pairing at the one
moment it is true.
### Starting the round
`startRound` is **local only**. It used to `POST /miler/deliveries/start` and
refuse to write anything unless that came back ok — the route does not exist and
404'd every time, so the bar could never succeed.
`pickup-complete` already released the load. What is left is the one thing the
backend cannot know and the rider can: whether he has actually set off. That
drives the PICKED → ACTIVE word on the card and the rung on the record, and
claims nothing about the hub.
> **Two buttons must not share a verb.** The card's said **Start Delivery** and
> the bar's said **Start delivery** — a thumb apart, doing different things. The
> bar is **Start round** and names the set it acts on.
### At the door
**arrive → verify → proof → payment → complete.** A geofence checks he is
actually there, the OTP or photo is taken where the rule demands it, and cash is
collected only on a line that takes cash.
**A refused write moves nothing.** Completing locally after a call that never
landed is what made the app show a done screen while the office still had the
order live. The rider keeps the stop and is told why.
---
# 10. Activity
`lib/views/Dashboard/activity/activity_page.dart` · tab 2
> **Did that go through? What do I still owe?**
```
Activity
╭──────────────────────────────────────────────╮
│ Your recent delivery activity │
│ ( All ) Active Completed Cancelled │
│ │
│ 2/3 on time · 2/3 on route │
│ │
│ NEEDS A RETURN VISIT │
│ ╭────────────────────────────────────────╮ │
│ │ ╭──╮ Sri Balaji Stores │ │
│ │ │↺ │ Skipped · 10:47 AM │ │
│ │ ╰──╯ │ │
│ │ ┃ Customer unreachable │ │
│ │ [ Resume this stop ] │ │
│ ╰────────────────────────────────────────╯ │
│ TODAY │
│ ╭────────────────────────────────────────╮ │
│ │ ╭──╮ Joe Mathew │ │
│ │ │✓ │ Delivered · 11:42 AM │ │
│ │ ╰──╯ 🏪 Vidhya Kitchen │ │
│ │ 4.6 km · 1h 27m #DM101 │ │
│ ╰────────────────────────────────────────╯ │
╰──────────────────────────────────────────────╯
```
### The card
**The tile is the anchor** — 44pt, status-tinted, carrying the state's glyph.
It gives the eye a fixed left edge to run down at speed and carries the outcome
in shape and colour before a word is read. The status is then a *word* beside the
clock rather than a second badge — a tile and a pill saying the same thing is one
too many.
**The name leads.** The card carried `ORDER ID` / `CUSTOMER` / `COLLECTED AT` as
eyebrows over their values — six lines for three facts — and the loudest was a
21-character routing reference. A rider does not know a stop by its booking id;
he knows it by who was at the door. The reference sits at caption size on the
right end of the measurements line — the card's one empty edge. It had a footer
band of its own once: a hairline, ~22pt of gaps, and a red bold `Details ›`
that **was not a control** — the whole card is the gesture and always was. The
loudest element on a finished stop was a link that could not be pressed, and
the band cost ~41pt on every row of a page whose entire job is scanning.
### Filters
Four slices, each with a real predicate over data the page already holds:
| Chip | Predicate |
|---|---|
| All | everything |
| Active | `isSkipped` — parked work, the only rows carrying an action |
| Completed | finished and not cancelled |
| Cancelled | cancelled or declined |
**Only the selected chip wears a container.** Four outlined chips are four boxes
competing to say which one is on. No counts — a number beside each word doubles
the text to answer what the list below answers by being that long. It scrolls and
is never a fixed-height box, which clips its own labels at 2.0×.
### Grouping
Parked work leads in `All` only — a promise to go back is the one row carrying an
action, and in date order a morning skip sinks below everything finished since.
Then `TODAY` / `YESTERDAY` / `AUG 18`, cut out of one sorted run.
> Currently single-band in practice: both stores prune to today, so only the API
> path can return older rows.
### The day line
`2/3 on time · 2/3 on route · ₹1,240 collected`. One line, no tiles, no chart.
The only figure wearing a colour is the cash, because Miler is the carrier rather
than the retailer — that money is the rider's personal liability until he
reconciles it at the hub — and it is absent when there was none.
---
# 11. Delivery details
`lib/views/Dashboard/activity/delivery_details_page.dart`
Pushed from an Activity card. **It reads; it does not fetch.** A screen that
re-fetched could disagree with the row that opened it, which is the one thing
history must never do.
```
← Delivery details
✓ Delivered
Vidhya Kitchen ⟶ Joe Mathew
11:42 AM · Handed to Joe Mathew
┌──────────┬────────────┬────────┐
│ DISTANCE │ TOTAL TIME │ ORDERS │
│ 4.6 km │ 2h 32m │ 2 │
└──────────┴────────────┴────────┘
▌TRACKING
9:02 AM 📍 Assigned 8m before you took it
9:10 AM ☑ Accepted 18m to reach
9:28 AM 🏪 Arrived 7m at the counter
9:35 AM 📦 Picked up Collected from Vidhya Kitchen
10:15 AM 🛵 Out for delivery 1h 22m on the road
11:42 AM ✓ Delivered 5m at the door
▌ROUTE ▌DELIVERY INFO ▌EARNINGS ▌ORDER
```
### Why a page, not an expanding row
The record used to open *inside* the list. An open record is several screens
tall, so the row under it left the viewport — and left what a lazy `ListView` had
built — and the list got harder to scan in exact proportion to how much it was
used. And the two jobs want opposite things: the list answers *did that go
through?* in one word per row; this answers *what happened on that one?*.
### The timeline
Six rungs, oldest at the top, outcome last and loudest — the direction every
order-tracking screen a rider has used reads.
**A glyph per event, not one tick six times.** A rail of identical marks says
only "these all happened", which the rail says by being solid. A rider scanning
for the moment he took the bag is looking for a *shape*.
**The clock is its own left column.** Right-aligned against the page edge, every
time sat at a different distance from the event it belonged to and the set was
unreadable as a set. In a column the journey can be read twice — once down the
events, once down the clock — and the gap between two rungs is visible without
arithmetic.
**The note under a rung is the gap to the next one.** That is the part a rider
cannot work out by subtracting two clocks on a moving bike.
> **Every rung is a real event.** A rung with no timestamp is **not drawn** — no
> placeholder, no "pending", no invented clock. A crate collected in one gesture
> genuinely has one event in its history. The single exception is the step a
> *skipped or cancelled* record never reached, drawn as an open ring with no
> clock, because there the absence is itself the fact.
**The node sits on the title and the rail runs behind it**, in two halves, so the
line passes through rather than stopping either side of every disc.
**No timeline package.** `timelines_plus` was a dependency and was removed:
`TimelineTile` centres its node over the whole tile and constrains its contents,
which put a dot level with nothing in the middle of an opened record and
overflowed the row by ~600 pt.
### The route hangs each address on its own end
`_route` used to read `pickupaddress` **only** and hang it under the *drop*
end. Right by construction on a parcel booking — one address, and the
collection point is the customer — but a milk-run payload carries both, so the
record printed the kitchen's street under the customer's name: the most
checkable fact on the one screen that exists to be checked, usually because the
office is asking. The reader now follows the same five-key precedence as
`_areaOf` in `route_timeline.dart` (`dropaddress` → `DropAddress` →
`deliveryaddress` → the pickup keys), because two readers answering "where did
this go" from different keys is the regression this codebase already had once
over the source *name*. With no distinct drop address the one address still
belongs to the door, exactly as before. Held by `delivery_details_test.dart`'s
`route` group.
### Earnings, and the number that is not invented
There is **no per-stop payout anywhere in this app**. `deliver` computes
`riderkms` and `ridercharges` server-side and returns neither; `/miler/earnings`
answers for a *period*. A figure here would be pay derived from a rate the app
does not hold — the one invention a rider would act on, argue about, and be wrong
about. The section shows the on-time bonus and the cash, else
`Stop payout · See Account`.
---
# 12. Account
`lib/views/Dashboard/profile/Profilepage.dart` · tab 3
> **Who am I to this app, what did I make, and how do I get help?**
```
Account 🔔
╭──────────────────────────────────────────────╮
│ ◯ Rajan A › │
│ 9787698259 · Doormile Delivery │
│ ┌────────────────────────────────────────┐ │
│ │ 0 │ 19 │ 24/7 │ │
│ │ Rewards │ Deliveries │ Help │ │
│ └────────────────────────────────────────┘ │
│ MONEY │
│ 💳 Earnings › │
│ Today, this week and this month │
│ ACCOUNT │
│ 👤 Edit Profile › │
│ 📍 Saved Address › │
│ 🔔 Alert Sound › │
│ SUPPORT │
│ 🎧 Help & Support › │
╰──────────────────────────────────────────────╯
```
**Section labels align to the row titles, not the icons.** Text-to-glyph
alignment is optical and never exact — an `Icon` does not paint flush to the left
of its box — so three headings sat ~8pt out of true, which is what makes a
settings page look assembled rather than laid out. The icons become a clean
margin rail.
**The icon does not outrank the title.** Every glyph was drawn in the same
near-black as the words beside it, so a column of twelve read as loudly as the
twelve things they labelled. They are markers; the title leads.
**The list clears the floating nav.** It reserved 28pt for a ~90pt bar, so the
last rows were permanently unreachable and read as text truncated mid-sentence.
---
# 13. Bottom sheets
A sheet is an **action surface**, not an information or navigation surface. If
tapping a thing opens a sheet that then needs another tap to act, the flow is
`screen → sheet → action` and that is too slow for field work.
| Sheet | Opens from | One primary action |
|---|---|---|
| Stop detail | the bag block on an order row | Navigate |
| Arrived / Picked | the command bar | slide to confirm |
| Update status | the map screen | Delivered / Skip / Cancelled |
| Skip reason | the outcome | the reason, then confirm |
| Duty | the header switch | on / off |
They share: a real blur (something *is* underneath), a title that says what is
being confirmed, the list of what it covers, and **one** obvious commit. A slide
where the write is consequential; a button where it is not.
### The sheet kit (2026-08-20)
`lib/views/helpers/widgets/miler_sheet_kit.dart`. An audit found **six
presentation styles** around one kind of surface: glass at 28; opaque white at
24 (stop detail, preview); default white at 16 with no `isScrollControlled`
(reject/cancel); an ad-hoc container with its own shadow (the dead trip-map
sheet); `Get.bottomSheet` flat 16 (auth errors); and a floating card with
margins (payment confirm). Plus a hand-rolled drag handle per file — usually
`borderSubtle`, which on frosted glass is close to invisible.
One presenter and four primitives now:
| | |
|---|---|
| `showMilerSheet()` | the only `showModalBottomSheet` config: transparent route, scroll-controlled, `kMilerSheetStyle` / `large:` for the tall entrance |
| `MilerSheetScaffold` | glass + handle + padding + **one inset rule**: bottom pays `max(keyboard, gesture bar)`, never the sum — the double-SafeArea CTA drift, closed structurally |
| `MilerSheetHandle` | 40×4, `borderStrong` |
| `MilerSheetHeader` | title, one-line subtitle, optional tinted badge |
| `MilerSheetChoiceRow` | one answer in a reason list — promoted from `sheet.dart`'s private `_ReasonRow` when Reject became its third caller; selection is a soft fill + tick, never a solid slab |
Every live sheet goes through it: duty, rung confirm, stop detail, pickup
preview, skip, pickup confirm, map, reject/cancel, location prompt, reject
reason, product details, auth errors, payment confirm.
What the same pass fixed while rendering (`sheet_shots_test.dart`,
`sheet_layout_test.dart` — 4 widths × 3 scales × 4 sheets):
- **Stop detail was leg-blind**: `DELIVER TO` over the *pickup* address, the
map pinned to the counter, the distance measuring the ride back to it — on
the tab where every stop is a drop. Same family as the record's Route bug;
fixed the same way, with a leg-aware read and an in-sheet distance to the
leg's own coordinates. It also gained the status word and a Bag row (stored
pairing first, payload `baglabel` fallback).
- The rung sheet was the one sheet still on **Flutter's default entrance**.
- Rigid text rows overflowed at 2.0× (`_journey`, the detail distance line) —
now Flexible; the rung sheet scrolls when a 2.0× manifest outgrows the
screen; the slide control clamps its type at 1.4× because a fixed 52pt pill
cannot grow.
- The Activity `Details ›`-style fake affordances in sheets went the same way:
the location prompt's Close row, the product sheet's 32pt cancel icon and
the reject sheet's Close button are gone — the handle and the barrier are
the dismissal.
- `_showmapDetailsSheet` ("Trip Map", ~350 lines) deleted: zero callers, and
its job has belonged to the stop detail sheet since the timeline rework.
---
# 14. States every screen must survive
| State | Rule |
|---|---|
| **Loading** | a skeleton shaped like the page it stands in for, never a spinner over the screen. `SkeletonBone` paints in `pureSurface` and is invisible outside `MilerShimmer` — without the wrapper it renders as a blank white screen indistinguishable from a hung load |
| **Empty** | a caption, not a screen: one glyph, one headline, one line, one way out |
| **Empty filter** | a fact about the slice with a way back — never the day's empty state, which would tell the rider he had done nothing |
| **Error** | `ErrorRetry`, and only when there is no cached day. A stale day beats an error page |
| **Offline** | the banner, and every store keeps working. The app is designed to be right before the network agrees |
| **2.0× text** | nothing clips, nothing overflows. `text_scale_sweep_test.dart` |
---
# 15. What holds it
| Test | Holds |
|---|---|
| `lifecycle_boundary_test.dart` | Home/Deliveries disjointness on both lines; acceptance never crosses the boundary; only pickup-complete does; consignment-id storage; one reader for a place's name |
| `kitchen_group_test.dart` | every key spelling names and groups identically; "pickup" never surfaces as a name; the drop area replaces the reference; one-order-one-bag; partial acceptance; no overflow at 390/375/320 × 1.0/2.0 |
| `picked_not_active_test.dart` | `Out_for_Delivery` from the backend is the consignment's state, not the rider's |
| `start_delivery_test.dart` | collecting does not release the round; an unreleased order still belongs on Deliveries |
| `activity_page_test.dart` | the chrome, the four slices, one filled chip, the row hierarchy, the parked band, the shimmer wrapper |
| `delivery_details_test.dart` | the six rungs oldest-first; the absent-rung rule; no fabricated payout; capability gating |
| `activity_format_test.dart` | the false `Z`; no arrow character in the route string |
| `one_bag_per_order_test.dart` | nothing starts counting something other than orders |
| `text_scale_sweep_test.dart` | 2.0× on the screens that had no coverage |
| `home_shots_test.dart` | 15 golden references for Home's route block |
`flutter test` · `flutter analyze lib`
---
# 16. Known gaps
**Functional**
- `assets/images/cancel.png` is referenced from `lib/xpress` and does not exist —
`delivery_shell_smoke_test.dart` fails on it.
- `PATCH /miler/bookings/:id/addresses` is still called and is off-contract.
- The hub console renders its delivery badge from the **booking** status, whose
enum ends at `Converted_To_Consignment`. It can never show *active* or
*delivered* until it joins the consignment by `booking.consignmentid`.
- `ErrorRetry` is close to unreachable — `_safe()` swallows endpoint failures and
the stores swallow their own parse errors, so a broken load shows the empty
state.
- Compliance and the event ledger are computed from **local** stamps. A phone
with a wrong clock produces a wrong verdict, and nothing reconciles it.
- **`RouteMetricsHelper.metersToStop` reads only the pickup coordinates**
(`route_metrics.dart`), so on a delivery leg every distance and ETA fed
through it — the delivery card's "1.1 km · 3 min", the queue ordering hints —
measures the ride back to the kitchen the rider already left. The stop
detail sheet now computes its own leg-true distance; the cards still read
through `metersToStop`. Fixing it there touches every card, ETA and the
rail's time-left arithmetic, so it is recorded rather than slipped into a
UI pass.
**Design, not yet done**
- (empty — the bottom sheets went through the visual pass on 2026-08-20; see
§13's sheet kit.)
**Design gaps closed 2026-08-20** (kept here because the old list claimed them)
- ~~Deliveries: progress not state-aware~~ — the per-stop mapping is
`stopProgressFor` in `trip.dart` now: pure, line-aware through
`isWorkComplete`, and it recognises **both legs'** "physically on" rungs.
It used to test `isActive || arrived` — pickup vocabulary — on the one tab
that only ever shows the delivery leg, so the customer's door the rider was
standing at (`deliveryArrived`) drew grey and the scooter parked behind him.
Note the half of the old claim that was *wrong*: `0 of 21 done` while a stop
is merely picked is **correct** on a milk run — picked is the middle of the
morning, not done — and the rail has counted through the line-aware
predicate all along.
- ~~a 21-stop route overflows the rail~~ — stale since the nodes rework: the
rail scrolls and auto-centres the scooter. What was real was the viewport
edge slicing a node down its middle, which read as a clipping bug rather
than "more route this way"; the scrolling branch now dissolves at both edges
(`ShaderMask`, `dstIn`). A short route ends where it ends, unfaded. Worth
knowing: with the two framing nodes and the 22pt gap floor, only **four**
stops fit a 390pt phone unscrolled — the fade is nearly every real round.
- ~~Home reads as four widgets~~ — the brief was the last box standing: a
`pureSurface` fill in a `borderSubtle` outline on a `daylightSurface` page,
a four-percent step doing no separating. Dissolved; and its 14pt inner
padding — content inset *inside a box* — went with it, because it was
parking the brief's text at 42 while `TODAY'S RUN` sat at 32, inverting the
28-summary / 32-route gutter scale.
**Verification**
- Home's use of `WorkBoundary` is verified by code inspection, not a widget test:
`_HomepageState` cannot be pumped (Get, Geolocator, live fetches).
- Nothing in this document has been verified on a physical device by the author
of the current changes — only by tests and rendered goldens.