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