Files
doormile_customer_app/design/DESIGN.md
Thiru-tenext 0d66627c3c 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>
2026-09-15 16:07:33 +05:30

22 KiB
Raw Blame History

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.