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

54 KiB
Raw Blame History

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
  2. One app, two lines of work
  3. The lifecycle
  4. The API
  5. The data layer
  6. The design system
  7. The bars
  8. Home
  9. Deliveries
  10. Activity
  11. Delivery details
  12. Account
  13. Bottom sheets
  14. States every screen must survive
  15. What holds it
  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:

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.

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 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, 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.