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>
22 KiB
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.
- One decision per screen. A screen asks one thing and offers one dominant action. Everything else is secondary or absent.
- 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.
- Optional must look optional. Anything the customer can skip is labelled Optional and, when skipped, described calmly — never with warning styling.
- Say it in customer language. No consignment, waybill, AWB, MPS or piece. The customer books a pickup and sends packages.
- 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,
+91prefix 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:
- Greeting + question — "Good afternoon, Joe" in
lede, then "Where should we pick up?" inpageTitle. The screen asks the question the CTA answers. - Book a Pickup — the only maroon block in the app, radius 28,
liftshadow, title + one supporting line + an arrow in a translucent circle. - 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 (
initStateopens 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.
DmRouteRailtakes 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
orderCreatedand 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
Semanticswith button/selected/enabled. - Minimum 48px targets throughout.
- Every text row that can meet long data is
Flexiblewithellipsis— the widget tests run in a font where every glyph is full-width, which is how the remaining overflows were found and fixed. - Contrast:
inkonsurfaceand white onbrandboth 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.