Replace the customer app with Doormile CX

Book a pickup, track it to delivery — the rebuilt customer app.

- Design language from doormile-screens.html: brand #8F0F06, Manrope +
  Geist Mono (variable fonts), bordered cards instead of shadows, crimson
  brand headers, sliding tab indicator, mono for anything read digit by digit.
- lib/data (one live API implementation, plus a debug-only offline fake),
  lib/state, lib/ui (tokens, widgets, screens).
- 84 tests, plus a design snapshot harness that renders every screen with the
  real fonts: flutter test test/design_snapshot_test.dart --run-skipped
  --update-goldens

This replaces the previous app (pubspec 'doormile', app id
com.doormile.customer). That tree remains in history at 6c7d656; note its
android/app/google-services.json is not carried over, and the application id
here is in.doormile.customer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-15 16:07:33 +05:30
parent 6c7d656de5
commit 0d66627c3c
305 changed files with 21930 additions and 11056 deletions

471
design/DESIGN.md Normal file
View File

@@ -0,0 +1,471 @@
# Doormile Customer App — Design Documentation
How every screen is designed, why it is designed that way, and where each
decision lives in the code.
Companion to `README.md` (which covers running and shipping the app).
---
## 1. Principles
Five rules decide almost every argument in this app.
1. **One decision per screen.** A screen asks one thing and offers one dominant
action. Everything else is secondary or absent.
2. **Lift, don't outline.** Surfaces separate through soft shadow on a warm
canvas, not through borders. Borders appear only where an outline is
genuinely load-bearing.
3. **Optional must look optional.** Anything the customer can skip is labelled
*Optional* and, when skipped, described calmly — never with warning styling.
4. **Say it in customer language.** No consignment, waybill, AWB, MPS or piece.
The customer books a pickup and sends packages.
5. **Complexity appears only when asked for.** Multi-destination, multi-package
and every advanced field stay folded away until someone reaches for them.
---
## 2. Foundations
All of this lives in `lib/ui/tokens.dart`. No screen hardcodes a value.
### 2.1 Colour
One accent. Warm neutrals. Semantic colour used sparingly and only with meaning.
| Token | Hex | Used for |
| --- | --- | --- |
| `brand` | `#960019` | Primary actions, selection, live status, links |
| `brandPress` | `#7A0015` | Pressed state of a primary surface |
| `brandSoft` | `#FDF2F4` | Selected tiles, tinted glyph circles, brand chips |
| `brandLine` | `#F3D9DF` | Pressed state of a soft brand surface |
| `ink` | `#14100F` | Primary text — a warm near-black, never `#000` |
| `ink2` | `#5A5350` | Secondary text, lede paragraphs |
| `ink3` | `#8B8480` | Tertiary text, eyebrows, captions |
| `ink4` | `#B5AEAA` | Placeholders, disabled labels, chevrons |
| `canvas` | `#F7F5F3` | Page background — warm off-white |
| `surface` | `#FFFFFF` | Cards, sheets, the tab bar |
| `surfaceAlt` | `#F2EFEC` | Filled inputs, quiet chips, skeleton base |
| `hairline` | `#EFEBE8` | Dividers inside a shared surface |
| `border` | `#E3DEDA` | The rare real outline |
| `ok` / `okSoft` | `#0E6B46` / `#E9F4EF` | Delivered, money settled, confirmations |
| `danger` | `#B3231F` | Cancel and other destructive labels |
**Rules.** Green means completed or paid — never decoration. Red is either the
brand or destruction, and context always disambiguates. Disabled states desaturate
rather than grey out to a different hue.
### 2.2 Typography
**Inter**, bundled as a real asset in four weights (400/500/600/700) so the app
renders identically on every device. Headings carry tight negative tracking;
body text stays open.
| Style | Size / Height / Weight | Tracking | Used for |
| --- | --- | --- | --- |
| `display` | 32 / 1.14 / 700 | −1.1 | The one dominant line on a screen (tracking status, "Pickup booked") |
| `pageTitle` | 28 / 1.18 / 700 | −0.9 | Tab-root titles (Orders, Account) and the Home question |
| `title` | 26 / 1.2 / 700 | −0.8 | Step headings inside the booking flow |
| `heading` | 21 / 1.25 / 600 | −0.5 | Card headings, amounts |
| `sheetTitle` | 21 / 1.25 / 700 | −0.6 | Bottom-sheet titles |
| `cardTitle` | 17 / 1.3 / 600 | −0.35 | Card and row titles |
| `body` | 16 / 1.5 / 400 | −0.1 | Input text, long copy |
| `bodyStrong` | 16 / 1.4 / 600 | −0.25 | Emphasised row titles |
| `lede` | 15 / 1.5 / 400 | −0.1 | The supporting line under a heading |
| `value` | 15 / 1.4 / 500 | −0.15 | Data values in summary rows |
| `label` | 13 / 1.3 / 600 | −0.05 | Buttons-in-text, chips, inline actions |
| `small` | 13 / 1.45 / 400 | −0.05 | Captions, secondary metadata |
| `eyebrow` | 11 / 1.3 / 600 | **+0.85**, uppercase | Micro-labels: `PICKUP`, `SENDING TO`, `STEP 2 OF 4` |
| `sectionHead` | 15 / 1.3 / 600 | −0.2 | Sentence-case section headings ("Recent orders") |
Two label systems on purpose: **uppercase eyebrows** name a *field*, **sentence-case
section heads** name a *group*. Mixing them was the fastest way to make the first
version look like a wireframe.
### 2.3 Shape
`xs 10 · sm 14 · md 18 · lg 22 · xl 28 · pill 999`
Tightest on inputs (14), mid on buttons and tiles (18), wider on cards (22),
widest on sheets and the hero CTA (28). Radius increases with the size of the
surface, which keeps corner curvature visually constant.
### 2.4 Elevation
Four shadows, each with one job.
- **`soft`** — resting elevation for every card. Two layers: a 2px contact
shadow plus a 24px ambient one at −12 spread. The contact layer is what stops
cards floating.
- **`lift`** — brand-tinted, for the single hero CTA on Home.
- **`float`** — controls hovering over content (map badges, toast, locate button).
- **`sheet`** — upward shadow under a bottom sheet.
### 2.5 Spacing
- Page padding **20**, used everywhere so every screen shares a left edge.
- Minimum touch target **48**; primary button height **54**.
- Vertical rhythm in 4s: 4 / 8 / 12 / 16 / 20 / 26 / 28.
- Section gap 26–28, intra-card gap 12–16, label-to-field 8.
### 2.6 Motion
`fast 140ms · base 220ms · slow 340ms`, all on `easeOutCubic`.
Press feedback is a scale to 0.98–0.985 at `fast`. Selection and expansion run
at `base`. Progress rails and route drawing run at `slow`. Continuous animations
(radar, live dot, map pulse, skeleton shimmer) run on their own loops and are
the reason tests pump fixed slices instead of settling.
---
## 3. Component library
`lib/ui/widgets/` — screens are thin because these carry the design.
| Component | File | Role |
| --- | --- | --- |
| `DmButton` | `buttons.dart` | primary / soft / ghost / outline, busy state, optional trailing icon |
| `DmChipButton`, `DmTextAction`, `DmIconButton` | `buttons.dart` | secondary affordances |
| `DmTopBar`, `DmStepHeader`, `DmFooter`, `DmPageTitle`, `DmScreenHeader` | `chrome.dart` | screen scaffolding |
| `DmOptionTile`, `DmActionRow` | `option_tile.dart` | selectable list card, tappable action row |
| `DmTextField`, `DmSegmented`, `DmRow`, `DmRowGroup`, `DmSwitch` | `inputs.dart` | form controls |
| `DmSummaryCard`, `DmSummaryRow`, `DmNote`, `DmConfirmedStrip` | `summary.dart` | fact display |
| `DmAsyncList`, `DmSkeleton`, `DmEmptyState` | `states.dart` | every remote-data state in one wrapper |
| `DmPill`, `DmLiveDot`, `DmAvatar`, `DoormileMark` | `misc.dart` | status and identity |
| `DmTag`, `DmChoiceChip`, `DmGridTile`, `DmStepper`, `DmInfoBanner`, `DmCopyRow` | `pieces.dart` | small primitives |
| `DmRouteMap`, `DmRadarPulse` | `route_map.dart` | painted map, matching animation |
| `DmRouteRail` | `route_rail.dart` | pickup → drop, vertical |
| `DmMilestones` | `milestones.dart` | the journey checklist |
| `DmToast`, `showDmSheet`, `DmSheetHeader` | `feedback.dart` | transient and modal |
Four of these do disproportionate work:
**`DmAsyncList`** wraps every backend-driven list. Loading skeleton, empty state,
error-with-retry and the success builder all live in one place, so a new endpoint
inherits four designed states for free. It is why the empty and error screens
look intentional rather than bolted on.
**`DmRouteRail`** — an origin ring, a hairline connector, a brand pin. The same
component anchors an order card, the tracking facts and the receipt, so a journey
reads identically wherever it appears.
**`DmRouteMap`** — a `CustomPainter`: rotated city blocks, white roads, a cubic
route stroked with a white casing then brand fill, an origin ring, a destination
pin with a blurred drop shadow, and a pulsing live marker positioned along the
path via `PathMetrics`. No map SDK, no API key. Swap it for a real map widget and
nothing around it changes.
**`DmOptionTile`** — white card at rest with `soft` shadow; selected becomes
`brandSoft` with a 1.5px brand border and a filled check; disabled drops to
`surfaceAlt` and shows the backend's reason. One component covers choose,
chosen, and cannot-choose.
---
## 4. Screens
### 4.1 Auth — Login, Sign up, OTP
`lib/ui/screens/auth/`
**Design.** A maroon brand panel at the top, a white sheet rounded 30px over it
holding the form. This is the only full-brand moment in the app: it establishes
the identity once, then the product stays quiet. `AuthScaffold` owns the panel,
the sheet, the pinned footer and the legal line, so all three screens are
identical in structure and differ only in fields.
- **Login** — Phone/Email segmented, `+91` prefix field with a clear button, a
CTA that only enables on a valid input, and *New to Doormile? Create account*.
- **Sign up** — full name, phone, optional email. Validation is the same shape:
the CTA enables, it never scolds.
- **OTP** — its own screen, not an inline step, because it has its own back
semantics. The number is shown with a *Change* affordance, four large boxes
auto-advance, a pasted code spreads itself across them, a resend countdown
ticks from 0:30, and verification fires automatically on the fourth digit.
**Why a brand panel here and nowhere else:** auth is the one screen with no
content of its own, so it is the only place a large colour field costs nothing.
### 4.2 Shell and navigation
`shell_screen.dart` — Home · Orders · Account in an `IndexedStack`.
The bar **floats**: a white surface inset 16 from the edges, radius 28, lifted
by a two-layer shadow, so it sits on the canvas like every other surface rather
than sealing the bottom of the screen. The active tab is a `brandSoft` pill that
**slides** between destinations on `easeOutBack`, its icon switching from
outline to filled with a small upward nudge and its label easing to `w600` in
brand. Inactive icons and labels stay `ink3`.
Booking and tracking screens are **pushed over** the shell, so the tab bar never
appears where it would compete with a step flow.
### 4.3 Home
`home_screen.dart`
Three things, in priority order:
1. **Greeting + question** — "Good afternoon, Joe" in `lede`, then "Where should
we pick up?" in `pageTitle`. The screen asks the question the CTA answers.
2. **Book a Pickup** — the only maroon block in the app, radius 28, `lift`
shadow, title + one supporting line + an arrow in a translucent circle.
3. **What's happening** — an *Active* card (live dot, window tag, stage, who is
coming, Track →) and recent orders as route cards.
Pull-to-refresh re-detects location and refreshes orders. The pickup-address row
was removed once the pin moved into booking step 1 — Home should not carry a
setting.
### 4.4 Booking
Four steps, a shared `DmStepHeader` (`STEP n OF 4` on the left, the step name on
the right, an inset rounded progress rail beneath).
**Step 1 — Pickup point** (`pickup_location_screen.dart`)
Map panel with a pulsing pin and a locate button, the detected address in a card
with *Change* (opens the place-search sheet), and **Next**. Location is detected
first; correcting it is one tap; nothing is typed unless the customer wants to.
**Step 2 — Destination** (`destination_screen.dart`)
The screen that carries the multi-destination model without exposing it.
- Opens on a **state chip row** (horizontal, scrollable). Only states we can
actually serve appear — a state with nothing open is never offered. Choosing
one reveals a **two-column district grid** of serviceable districts only.
Unsupported places are never disabled tiles; they are named once, quietly:
*"Coming soon: Madurai"*. The delivery promise moves to the settled card,
where it informs a decision instead of decorating a picker.
- Choosing a district **collapses the block into a card**: the place, *How many
packages?* with a `− 1 +` stepper defaulting to 1, and *Add exact delivery
details · Optional*.
- Below it, a quiet **+ Send to another place**. That is the entire
multi-destination affordance — one text action, absent until the first place
is settled.
- Returning from Review shows settled cards, not the picker (`initState` opens
the first incomplete block, or none).
Caps come from `BookingLimits` (backend-served, 20 packages / 5 destinations).
When a cap is reached the action is replaced by a plain sentence — *"Up to 5
places in one pickup. Book another pickup for more."* — never an error.
**Step 3 — Pickup time** (`slot_screen.dart`)
The pickup address restated in a compact card with *Change*, then windows
grouped by day. Each slot card can carry badges (*Fastest pickup*, *4 Milers
nearby*) and a caption; full windows are shown disabled with their reason. The
slot belongs to the whole pickup — one visit collects everything.
**Step 4 — Review** (`review_screen.dart`)
Compact by design and identical in shape for one destination or five.
- Route map with a badge that adapts: *Direct door-to-door transit* or *One
pickup · 3 destinations*.
- Summary card: one **connected rail** — the pickup origin, then every
destination hanging off it with its package count, exactly as an order card
reads. `DmRouteRail` takes a list of stops for this. *Edit* sits on the
pickup row, *Change destinations* under the stops, and **Pickup time** is the
row beneath.
- Optional omissions are stated once, calmly: *"Not added — the Miler can
confirm this at pickup."* With several places it becomes a single line rather
than repeating.
- For multi: `6 packages · 3 destinations` · *One Miler visit*.
- Combined estimate with the honest caveat that the final amount settles after
the Miler verifies.
- One dominant CTA: **Book Pickup**.
**Confirmation** (`confirmed_screen.dart`)
A green check that pops in on `easeOutBack`, "Pickup booked", the package
summary as a brand tag, a map with a *Searching for the nearest Miler* badge,
the reference with a copy action, the scheduled window and destinations, and two
quiet informational banners. Then **Track pickup** / **Back to home**.
### 4.5 Tracking
`tracking_screen.dart` — one screen that changes shape by stage.
- **Status header.** Eyebrow (`PICKUP STATUS` / `ORDER STATUS`) with a status
pill, then the dominant line — personalised where it helps: *"Arun Kumar is on
the way"*. Below it one or two fact chips (ETA, window).
- **Hero.** While a Miler is being matched: a **radar pulse** with three
expanding rings. Once assigned: the **route map** with the live marker, badged
*En route · 1.4 km away*.
- **Miler card.** Avatar, name, rating and completed pickups, vehicle and type,
then **Call Miler** (soft) and **Message** (outline).
- **Milestones.** Seven customer stages, not nine backend ones: *Pickup booked
→ Miler assigned → Pickup in progress → Package collected → In transit → Out
for delivery → Delivered*. Operational detail — "Miler on the way", "Miler
arrived at your door", "Order created" — appears as one line of context under
the milestone in progress, never as another permanent row. Completed
milestones carry a filled check and a timestamp; the current one a ringed
marker and a **LIVE** tag.
Status pills everywhere use the same customer language
(`JourneyStage.milestoneLabel`), so a pill never reads "Order created".
- **Your packages.** Appears **only** after `orderCreated` and only when the
pickup carried several destinations — each with its own tracking ID, package
count and status pill. Before collection the customer has one scheduled visit,
not several shipments, and the UI says exactly that.
- **Facts.** Route rail plus pickup window and reference/tracking number.
- **Footer.** *Cancel pickup booking* while backend policy allows it, then
*Need help with this order?*, then *Book another pickup* once finished.
### 4.6 Orders
`orders_screen.dart`, `order_row.dart`
Page title with a live subtitle ("3 bookings in progress"), an Active /
Completed / Cancelled segmented control, and **route cards**: a compact
`DmRouteRail`, a hairline, then the identifier and its facts on the left and a
status pill on the right.
**Ownership changes at collection.** Before the Miler arrives, a booking is one
row — *"Pickup DM-482913 · 3 packages · 2 destinations"* — because the customer
has one scheduled visit. Once verification creates the orders, each destination
stands on its own row with its own tracking number and journey, carrying a quiet
line back to the visit they shared: *"Collected together · Pickup DM-471200"*.
`AppState.entriesOf` produces these rows; the card renders either shape.
Opening a destination row follows **that order** — tracking takes a
`focusedDestination` and shows its own status, milestones and rail, with the
sibling packages listed beneath. Live rows open Tracking; finished ones open the
receipt.
### 4.7 Order details (receipt)
`order_details_screen.dart`
For a finished order: status and delivered date, the full route rail, then
**Collected at your door** — what the Miler actually recorded at pickup:
the photographs they took (one per package, captioned "1 of 3"), the **weighed
weight**, and who weighed it and when, tagged *Miler verified*. This is the
customer's evidence of what was handed over, and the weight the final price was
calculated from.
Then a **Packages** breakdown when there was more than one destination, and a
**Payment** block itemising parcel, base fare, distance, weight adjustment,
verified weight, **total paid** and method. Then the facts (reference, per-destination tracking,
window, recipient, collected by) and the journey milestones.
This screen answers "what did it cost and when did it land" without making
anyone hunt.
### 4.8 Account
`account_screen.dart` — a profile card, then grouped rows: preferences, then
**Prototype controls** (simulate empty service areas / network error / slow
network, reset data). Sign out is a ghost danger button, and the build version
sits quietly beneath.
The prototype controls are deliberately in the product rather than hidden: they
are how anyone reviewing the app reaches the states that are otherwise hard to
produce.
### 4.9 Sheets
`showDmSheet` gives every sheet the same grabber, 28px top radius and padding.
- **Place search** — search field, debounced results, skeletons while loading.
- **Delivery details** — the optional address and recipient fields for one
destination. *Save details* / *Skip for now*.
- **Cancel booking** — the reference and window restated, optional reason chips,
then *Keep my pickup* (primary) and *Yes, cancel pickup* (ghost, danger). The
safe choice is the prominent one.
---
## 5. State design
Every remote list has four designed states, provided by `DmAsyncList`:
| State | Treatment |
| --- | --- |
| Loading | Shimmering skeleton rows at the real row height, so nothing jumps |
| Empty | 64px glyph circle, heading, calm explanation, optional soft action |
| Error | Wi-Fi-off glyph, *"Couldn't load this"*, the server's message, **Retry** |
| Unavailable item | Rendered, dimmed, with the backend's reason attached |
Disabled controls desaturate and stay in place. A capped action is replaced by an
explanatory sentence, never an alert.
---
## 6. Interaction and haptics
- Selection (option tile, chip, grid tile, stepper, segmented, tab) →
`HapticFeedback.selectionClick()`.
- Booking confirmed and booking cancelled → `HapticFeedback.mediumImpact()`.
- Page transitions fade and slide 6% from the right on `easeOutCubic`.
- Pull-to-refresh on Home and Orders.
- Edge-to-edge, portrait-locked, dark status-bar icons — light over the auth
brand panel.
---
## 7. Accessibility and resilience
- **No text-scale clamp.** Components adapt instead: rows grow vertically,
buttons use a minimum height rather than a fixed one, the district grid drops
to one column past 1.15×, and important text wraps. Ellipsis is reserved for
secondary information — a delivery promise may truncate, a place name never
does. Two widget tests drive the whole booking flow, Orders and the receipt at
**1.8× text scale**; any overflow fails them.
- Every interactive surface carries `Semantics` with button/selected/enabled.
- Minimum 48px targets throughout.
- Every text row that can meet long data is `Flexible` with `ellipsis` — the
widget tests run in a font where every glyph is full-width, which is how the
remaining overflows were found and fixed.
- Contrast: `ink` on `surface` and white on `brand` both clear AA.
---
## 8. Voice
- Customer language only: *pickup*, *packages*, *destination*, *Miler*.
- Never: consignment, waybill, AWB, MPS, piece, manifest.
- Optional things are described, not warned about.
- Numbers are stated plainly — *"6 packages · 3 destinations"*, *"₹64 paid"*.
- Buttons are verbs: *Book Pickup*, *Track pickup*, *Cancel pickup booking*.
---
## 9. How design maps to code
```
lib/ui/tokens.dart every colour, type style, radius, shadow, duration
lib/ui/widgets/ the component library — screens compose, never restyle
lib/ui/screens/ layout and copy only
lib/data/doormile_api.dart the only place mock data lives
lib/state/app_state.dart one source of truth; all stage side effects
```
Rules that keep it honest: a screen never hardcodes a colour or size; business
rules (serviceability, caps, slot availability, pricing) never live in a widget;
and anything the backend will own is already served through the API layer so the
swap is a body change, not a redesign.
---
## 10. What is real and what is mocked
**Real:** the whole UI, navigation, state machine, validation, caps enforcement,
optionality rules, and every loading/empty/error path.
**Mocked in `doormile_api.dart`:** serviceable states and districts, pickup
slots, geocoding and place search, fare estimates, booking creation and
cancellation, and booking limits. Each returns the shape a real endpoint would.
**Stand-ins:** `DmRouteMap` and `DmMapPanel` are painted, not a map SDK. Pickup
photos render as honest placeholders — `ParcelVerification.photos` holds the
references, so `Image.network(...)` drops straight in. The *Simulate update*
stepper on Tracking replaces backend push events — deleting that one widget is
the only change needed when real status events arrive.
**Not built yet:** session persistence, push notifications, in-app support chat,
per-destination cancellation (cancellation is whole-pickup, before collection
only).
---
*This document is the CX UI baseline. The visual system, screens and booking
structure are approved and settled.*