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>
54 KiB
Miler
The Doormile rider 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
- What it is
- One app, two lines of work
- The lifecycle
- The API
- The data layer
- The design system
- The bars
- Home
- Deliveries
- Activity
- Delivery details
- Account
- Bottom sheets
- States every screen must survive
- What holds it
- 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:
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/xpressis 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.
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_Consignmentor beyond; - the collected record, written by Home only after
pickup-completereturned 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:
pickupQueuedropsWorkDomain.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/rejectkey onbookingassignmentid, not the booking id.deliver/skipkey on the consignment id.vehicle-requiredandrejectread query strings, not a body.- Telemetry scalars are strings.
latitude,speed,heading,batteryon/logsand/consignments/logsfail to parse as JSON numbers. - Never send
useridin a telemetry body. It once let one rider write another's GPS into the dispatch index. - The break status is
Break, notOn_Break. POST /miler/deliveries/startdoes 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-completealready does it. Removed; do not add it back without a contract change.PATCH /bookings/:id/addressesis 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.yamldeclared the family aspoppinswith a lowercase path on every asset while the files areassets/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 Icons 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, neverAnimatedCrossFade, 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 — theBOTTOM OVERFLOWED BY n PIXELSstripe.BottomPage.bottomInset(context)is the only source of the floating nav bar's height.SafeAreaknows nothing about it, andbottomInsetalready includesMediaQuery.padding.bottom— wrapping something in both double-counts the gesture inset.- Anything in a fixed column must be
Flexibleand 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(itsStackpaints on that ground), so the brand is aColoredBoxdirectly 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.sourceNameOftooksourcename/kitchenname,stopSourceNametook those and the CamelCaseSourceName/KitchenNamethe payload sometimes carries.trip_card.dartuses 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 →navigationLabelfell 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,
stopSourceNamedelegating 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.
_areaOftakes 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 TOover 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, payloadbaglabelfallback). - 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.pngis referenced fromlib/xpressand does not exist —delivery_shell_smoke_test.dartfails on it.PATCH /miler/bookings/:id/addressesis 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 bybooking.consignmentid. ErrorRetryis 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.metersToStopreads 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 throughmetersToStop. 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 isstopProgressForintrip.dartnow: pure, line-aware throughisWorkComplete, and it recognises both legs' "physically on" rungs. It used to testisActive || 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 donewhile 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: apureSurfacefill in aborderSubtleoutline on adaylightSurfacepage, 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 whileTODAY'S RUNsat at 32, inverting the 28-summary / 32-route gutter scale.
Verification
- Home's use of
WorkBoundaryis verified by code inspection, not a widget test:_HomepageStatecannot 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.