Four passes, and the shape they landed on. The type face is Plus Jakarta Sans (variable, wght 200-800), which brings a fix with it: it carries the rupee glyph and Switzer does not, so prices stop being set in Geist Mono to work around a missing character. Mono stays where it is earned - references and phone numbers, read digit by digit. Surfaces lift rather than outline. Cards carry two very soft shadow layers instead of a hairline, because eight outlined boxes down a screen read as a wireframe. The tab bar floats as a pill again for the same reason it was right to: it is now the same kind of object as everything above it. Screens: * Home is the greeting, the address, the sphere and one card. The card lost its progress bar - a filling line says "wait", and a parcel two days into a journey is not something anyone is waiting through - and gained the size that buys. * Orders cards are four bands: identity, destination, route, and whatever is happening right now. Plus a search field, because the list is the archive. * Tracking leads with the state at display size, then TRIP MILESTONES with a step counter, then the courier. * Review is a route thread over two particular cards. * Account opens on the person: avatar, name, and two counted figures. Three real bugs the redesign surfaced: * Quick dispatch handed `loadCities()` straight to a FutureBuilder, so the catalogue was refetched on every rebuild and Home never settled. * Order cards showed the whole visit's weight on one destination's row - somebody else's parcel. Per group now, and only once actually weighed. * The pickup window was printed beside "In transit", where it reads as a delivery time nobody promised. Nothing invented. The reference shows EXPRESS PRIORITY, CARBON OFFSET, CONCIERGE ELITE and hub-to-hub routing; this backend sends none of them, so they are absent rather than mocked up. flutter analyze: clean. flutter test: 88 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
860 lines
41 KiB
Markdown
860 lines
41 KiB
Markdown
# Doormile Customer App — Design
|
||
|
||
How this app is designed, why, and where each decision lives in the code.
|
||
|
||
Companion to `README.md`, which covers running and shipping it.
|
||
|
||
> **Rewritten 2026-09-22, then revised the same day after the effort pass
|
||
> (§10).** The version before it described
|
||
> `#960019`, Inter and "lift, don't outline" — a system two rebuilds out of
|
||
> date. Every value below was read out of `lib/ui/tokens.dart` as it stands;
|
||
> nothing here is aspirational. The old document is in git history.
|
||
|
||
---
|
||
|
||
## 0. Three generations, and what each one fixed
|
||
|
||
Reading the code makes more sense with the history, because several comments in
|
||
it argue with a decision you can no longer see.
|
||
|
||
| | What it was | What was wrong with it |
|
||
| --- | --- | --- |
|
||
| **v1** | Inter, `#960019`, soft shadows everywhere, cards for everything | Every surface floated, so nothing could be emphasised. A screen that drops shadows everywhere has no way left to say "this one matters". |
|
||
| **v2** (2026-09-15) | Manrope + Geist Mono, `#8F0F06`, hairline edges, 8–12pt corners | Correct, legible, and inert. Edges made hierarchy possible but made everything rectangular — including the thing the app exists for. |
|
||
| **v3** (2026-09-22) | The BOOK sphere, an opened-up radius scale, entrance motion | This pass. v2's failure mode was that it looked *drawn* rather than *designed*: shapes a program emitted, all terminating in a hard 1px line. |
|
||
|
||
v3 did not throw out v2. The colour, the type and the "edges, not elevation"
|
||
rule all survive. What changed is that the app now has **one lit object** in it,
|
||
the corners stopped reading as a form, and screens arrive instead of appearing.
|
||
|
||
---
|
||
|
||
## 1. Principles
|
||
|
||
Five rules settle most arguments here.
|
||
|
||
1. **One decision per screen.** A screen asks one thing and offers one dominant
|
||
action. Everything else is secondary or absent.
|
||
2. **Edges, not elevation.** Surfaces separate through a 1px warm hairline and a
|
||
corner, not a shadow. Shadow is reserved for three things: the committing
|
||
action, a control that genuinely floats over a map, and a sheet.
|
||
3. **Optional must look optional.** Anything skippable is folded away and, when
|
||
skipped, described calmly — never with warning styling.
|
||
4. **Customer language only.** 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 until someone reaches for them.
|
||
6. **Every sentence earns its place.** If removing a line would not make the
|
||
customer choose worse, the line goes. Hierarchy, spacing, position, state
|
||
and motion carry the meaning first; text carries what is left.
|
||
|
||
---
|
||
|
||
## 2. Foundations
|
||
|
||
All of this is `lib/ui/tokens.dart`. No screen hardcodes a value. The one
|
||
deliberate exception is the BOOK sphere, which mixes its own gradient stops —
|
||
see §4.
|
||
|
||
### 2.1 Colour
|
||
|
||
One accent at three intensities. Warm neutrals, never pure black. Semantic
|
||
colour used sparingly and only with meaning.
|
||
|
||
| Token | Hex | Used for |
|
||
| --- | --- | --- |
|
||
| `brand` | `#8F0F06` | The header field, the primary action, the sphere |
|
||
| `brandPress` | `#730C05` | Pressed state, and crimson text on a `brandSoft` wash |
|
||
| `brandSoft` | `#FBF3F2` | Selected options, wash cards, brand chips |
|
||
| `brandLine` | `#F0DEDC` | That wash's own edge, and quiet brand outlines |
|
||
| `ink` | `#191716` | Primary text |
|
||
| `ink2` | `#5C5654` | Supporting lines, lede paragraphs |
|
||
| `ink3` | `#6E6864` | The quietest line — timestamps, counts, captions |
|
||
| `ink4` | `#B4AEAA` | Placeholders, disabled labels, chevrons |
|
||
| `canvas` | `#F7F6F4` | The page |
|
||
| `surface` | `#FFFFFF` | Cards, sheets, the tab bar |
|
||
| `surfaceAlt` | `#F1EFEC` | Filled inputs, quiet chips, tiles, skeletons |
|
||
| `hairline` | `#F1EFEC` | The rule between two rows of one card |
|
||
| `border` | `#E8E5E2` | A card's own edge — the workhorse of this design |
|
||
| `onBrand2` / `onBrand3` | `#FFD9D4` / `#FFD1CB` | Text on a crimson field |
|
||
| `onBrandSunken` | `14%` black | A recess cut into the header |
|
||
| `onBrandRaise` | `18%` white | A control resting on the header |
|
||
| `ok` / `okSoft` | `#0A6B56` / `#E2F4EF` | Delivered, and only delivered |
|
||
| `danger` | `#B3231F` | Destructive labels |
|
||
| `locationDot` | `#1B6EF3` | "You are here" on a map |
|
||
|
||
Two rules worth stating out loud.
|
||
|
||
**Text on crimson is a tinted rose, not translucent white.** `rgba(255,255,255,.8)`
|
||
over `#8F0F06` goes grey and dirty; `#FFD1CB` keeps the warmth. Both `onBrand`
|
||
tokens are opaque colours for that reason.
|
||
|
||
**The location dot is deliberately not the brand.** On a map crimson already
|
||
means *the pickup pin*, and blue is the convention every customer reads as
|
||
their own position without being told.
|
||
|
||
### 2.2 Typography
|
||
|
||
**Switzer for voice, Geist Mono for data.** Switzer (Indian Type Foundry, via
|
||
Fontshare, ITF Free Font License — commercial use permitted) is the app's voice
|
||
as of 2026-09-23.
|
||
|
||
It is a neue-grotesque: flat-sided bowls, near-vertical terminals, a
|
||
straight-tailed `y`. It was chosen against a reference whose own face is almost
|
||
certainly PP Neue Montreal or Neue Haas Grotesk Display — both commercial, and
|
||
neither identifiable with certainty from a skewed mockup. Switzer is the
|
||
closest thing that can actually be bundled.
|
||
|
||
**One variable file again, 141 KB.** Poppins held this slot for a day and has
|
||
no variable release: four static cuts at ~155 KB each, 625 KB of sans, and for
|
||
that release the sans styles carried `fontWeight` alone because
|
||
`fontVariations` on a static font is ignored in silence. The axis is back, so
|
||
every style below sets both — the axis does the work, the enum keeps the
|
||
semantics and the system-font fallback correct.
|
||
|
||
**Tighter than anything before it.** A grotesque at display size wants pulling
|
||
in hard, and the reference sets its headlines almost touching: the header runs
|
||
at **−1.4**. Body sizes stay near zero. Tracking belongs to a typeface, not to
|
||
a design, and this is the third time these numbers have been rewritten for that
|
||
reason.
|
||
|
||
Letter spacing is authored in em by the design and converted per size, because
|
||
−0.035em is a different number of pixels on a 34pt header than on a 15pt row
|
||
title, and splitting the difference softens exactly the heads meant to feel
|
||
tight.
|
||
|
||
| Style | Size / Height / Weight | Tracking | Used for |
|
||
| --- | --- | --- | --- |
|
||
| `headerTitle` | 34 / 1.0 / 800 | **−1.4** | The tab root's own title |
|
||
| `display` | 31 / 1.1 / 800 | −1.1 | The one dominant line on a screen |
|
||
| `title` | 25 / 1.16 / 800 | −0.8 | Screen headings on canvas |
|
||
| `sheetTitle` | 21 / 1.2 / 800 | −0.65 | Bottom-sheet titles |
|
||
| `heading` | 18 / 1.25 / 700 | −0.45 | The head above a group of cards |
|
||
| `cardTitle` | 15 / 1.4 / 600 | −0.2 | A row's own line — the workhorse |
|
||
| `body` | 15 / 1.5 / 500 | −0.12 | Paragraph text |
|
||
| `lede` | 14.5 / 1.55 / 500 | — | Paragraph under a screen heading |
|
||
| `small` | 13.5 / 1.45 / 500 | — | The supporting line under a row title |
|
||
| `tiny` | 13 / 1.4 / 500 | — | The quietest line |
|
||
| `label` | 12.5 / 1.3 / 700 | — | Field labels, tags, inline actions |
|
||
| `eyebrow` | 10.5 / 1.3 / 700 | +1.16 | Uppercase micro-label above a group |
|
||
| `headerEyebrow` | 11.5 / 1.3 / 800 | +1.84 | That eyebrow on crimson |
|
||
| `mono` | 13 / 1.4 / 500 | −0.26 | References, plates, phone numbers |
|
||
| `monoLg` | 19 / 1.2 / 500 | −0.38 | A reference on its own line, or a fare |
|
||
|
||
**What goes in mono.** Anything a customer might read back aloud or check
|
||
digit-by-digit: a booking reference, a phone number, a fare, a plate, a
|
||
countdown. Mono makes the glyphs line up, so a transposed character is visible.
|
||
Nothing else goes in mono.
|
||
|
||
### 2.3 Shape
|
||
|
||
The whole scale was opened up on 2026-09-22. This is the single change that did
|
||
most for the "it looks boxy" complaint, and it is four lines of token.
|
||
|
||
| Token | Was | Now | Used for |
|
||
| --- | --- | --- | --- |
|
||
| `xs` | 8 | **10** | Tiles, chips, the small square holding a glyph |
|
||
| `sm` | 9 | **12** | Buttons, fields, icon buttons |
|
||
| `md` | 10 | **14** | Cards and every panel that holds rows |
|
||
| `lg` | 12 | **16** | The segmented control, a grabber, small panels |
|
||
| `xl` | 16 | **20** | Bottom sheets, the auth sheet |
|
||
| `header` | 18 | **26** | The brand header's bottom sweep |
|
||
| `book` | = `sm` | **16** | The primary action bar |
|
||
|
||
At 8–12 the app measured correct and looked like a form. Four points across the
|
||
scale is the difference between a panel and a card.
|
||
|
||
`xl` moved to 20 for a second reason: at 16 it had collided with `lg`, and a
|
||
sheet that shares a corner with the cards inside it has stopped being a separate
|
||
surface.
|
||
|
||
`book` was an alias of `sm` and is now its own value, because the committing
|
||
action is the one element on a screen allowed to look like an object rather than
|
||
part of the form.
|
||
|
||
### 2.4 Elevation
|
||
|
||
Four shadows exist. If you are reaching for a fifth, the answer is an edge.
|
||
|
||
| Token | What it is for |
|
||
| --- | --- |
|
||
| `lift` | The one committing action on a screen, and a selected slot |
|
||
| `liftSm` | The same, for chips and half-width controls |
|
||
| `float` | A control genuinely hovering over a map |
|
||
| `sheet` | A bottom sheet's lift off the page |
|
||
|
||
`lift` and `liftSm` are **crimson** shadows (`#8F0F06` at 50%/65%), not black.
|
||
A black shadow under a crimson button reads as dirt; a crimson one reads as the
|
||
button glowing.
|
||
|
||
### 2.5 Spacing
|
||
|
||
`pad` 24 on canvas content, `headerPad` 22 inside the crimson field — the
|
||
header reads tighter than it measures, so it takes the smaller inset. `tap` 48
|
||
minimum target. `button` and `field` are both 54, so a form and its action share
|
||
a rhythm.
|
||
|
||
### 2.6 Motion
|
||
|
||
| Token | Value | For |
|
||
| --- | --- | --- |
|
||
| `fast` | 140ms | A press giving way |
|
||
| `base` | 250ms | Most state changes |
|
||
| `slow` | 460ms | A screen-level reveal |
|
||
| `ease` | `easeOutCubic` | Everything, unless there is a reason |
|
||
| `glide` | `Cubic(0.34, 1.06, 0.36, 1)` | The bottom nav's travelling pill |
|
||
|
||
`glide` carries a touch of overshoot, which reads as the indicator catching up
|
||
rather than teleporting.
|
||
|
||
**Page transitions** (`_DmPageTransitions` in `main.dart`) are a shared axis.
|
||
The incoming page travels 14% from the right, scales 0.97 → 1 and fades over
|
||
the *first third* of the distance; the page it covers slides 9% to the left and
|
||
dims to 60%. Both surfaces move, which is the whole difference between a push
|
||
that reads as movement and one that reads as a slide projector. `DmMotion.enter`
|
||
is an emphasised decelerate — `easeOutCubic` is too even over a distance this
|
||
short. Timing lives on `DmPageRoute`: **420ms forward, 340ms back**, because a
|
||
customer going back already knows what is behind them.
|
||
|
||
**Haptics** are two calls and no more. `selectionClick()` for anything that
|
||
changes a selection — a tab, a chip, a stepper, a radio. `mediumImpact()` for
|
||
committing: booking, cancelling, submitting an OTP, and pressing the sphere.
|
||
|
||
---
|
||
|
||
## 3. Entrance animation
|
||
|
||
Added 2026-09-22 with **`flutter_animate` 4.5.2**, the app's only animation
|
||
dependency (`lucide_icons_flutter` is the other design one — every glyph in
|
||
the UI comes from it, at one 1.5px stroke weight, so an icon sits at the same
|
||
visual weight as the type beside it instead of shouting over it). It exists so content *arrives* instead of being there:
|
||
a screen that paints fully formed in one frame reads as a document, not an app.
|
||
|
||
Home's three elements are choreographed, not animated independently:
|
||
|
||
| Element | Delay | Motion |
|
||
| --- | --- | --- |
|
||
| The sphere | 0 | `fadeIn` 420ms + `scale` 0.82→1 over 620ms, `easeOutBack` |
|
||
| "Happening now" + card | 160ms | `fadeIn` 380ms + `slideY` 0.12, 70ms interval between the two |
|
||
| "How it works" | 320ms | `fadeIn` 520ms + `slideY` 0.06 |
|
||
|
||
The sphere's `easeOutBack` is the only overshoot on the screen. It is there
|
||
because the sphere is the subject: overshoot on a supporting element reads as
|
||
jitter, on the subject it reads as arrival.
|
||
|
||
### The gotcha, so nobody rediscovers it
|
||
|
||
`flutter_animate` starts its controller from a zero-delay `Future.delayed`.
|
||
Under the test binding's fake clock, that timer only fires on the pump **after**
|
||
the widget was built — so the controller begins life part-way through the first
|
||
elapse. The snapshot harness settled in two pumps and photographed a completely
|
||
blank screen, which looked exactly like a layout bug.
|
||
|
||
`_settle()` in `test/design_snapshot_test.dart` now runs four extra 400ms
|
||
frames. Everything on screen finishes inside a second, so the extra frames cost
|
||
nothing and the pictures are of settled screens.
|
||
|
||
---
|
||
|
||
## 4. The BOOK sphere
|
||
|
||
`lib/ui/widgets/book_orb.dart`. Home's only action, and the app's one lit
|
||
object. It is documented at length here because it is the piece most likely to
|
||
be copied, and the details that matter are not obvious from the code.
|
||
|
||
### 4.1 Why a circle, and why one word
|
||
|
||
Home used to be a filled rectangular card holding a heading, a sentence and a
|
||
white button. Correct, and completely inert — three stacked rectangles on a
|
||
screen of stacked rectangles, where the thing the app exists for looked like
|
||
the things it does not.
|
||
|
||
A disc is the one shape nothing else on the screen uses, so it needs no border,
|
||
no label and no arrow to be found. It is also the shape the gesture wants: a
|
||
thumb lands on a circle, and the press scales from the centre without corners
|
||
doing anything awkward.
|
||
|
||
**The word alone is not enough.** An earlier 68pt BOOK bar was removed for a
|
||
reason recorded at the time — *"a word with no object, which on first run told
|
||
a new customer nothing about what the app does"* — and that is still true. The
|
||
caption under the sphere does the explaining. The boldness is in the shape; the
|
||
sentence is what stops it being a shout.
|
||
|
||
### 4.2 The three things that separate designed from drawn
|
||
|
||
The first version of this was a flat crimson circle with two 1px rings around
|
||
it, and it read as exactly what it was: shapes a program emitted. Everything
|
||
below exists to fix that, and the same three rules apply to any object you add.
|
||
|
||
**1. Light has a direction.** Four layers, all obeying one source up and to the
|
||
left:
|
||
|
||
| Layer | Value |
|
||
| --- | --- |
|
||
| Fill | `RadialGradient`, centre `(-0.38, -0.52)`, radius 1.08, `#C0230F` → `#8F0F06` → `#5C0802` at stops 0 / 0.5 / 1 |
|
||
| Specular | `RadialGradient`, centre `(-0.42, -0.58)`, radius 0.62, white 25% → transparent |
|
||
| Rim | `LinearGradient` stroke, top→bottom: white 35% → white 5% → `#4A0602` 20% |
|
||
| Inner hairline | White 8%, inset 15pt |
|
||
|
||
The rim is the layer people skip, and it is the one that sells it: bright where
|
||
the light grazes the top edge, dark where the body turns away at the bottom. A
|
||
uniform 1px border flattens the whole thing back into a circle.
|
||
|
||
The inner hairline is barely visible and is the reason the face reads as a
|
||
*made object* rather than a filled shape.
|
||
|
||
**2. Nothing terminates hard.** Every ring is drawn through
|
||
`MaskFilter.blur`, so it has a soft shoulder instead of a 1px edge. A crisp
|
||
hairline ring *is* a drawn shape; a ring with a shoulder is light.
|
||
|
||
The glow is two shadows at different radii — a tight one for contact with the
|
||
page (`#8F0F06` 28%, blur 26, spread −16, offset y 14) and a wide one for the
|
||
light it throws (`#8F0F06` 10%, blur 52, spread −18, offset y 20). Both are
|
||
pulled in hard with negative spread. At full strength they bloom into a pink
|
||
cloud that reads as a smudge under the object rather than as its shadow — that
|
||
was the first draft, and it looked cheap.
|
||
|
||
**3. Something is always moving, slowly.** Two clocks:
|
||
|
||
- **Pulses** — three rings on a 6s repeat at phases 0 / 0.34 / 0.67, born at
|
||
the sphere's edge so they look emitted rather than drawn around. Travel is
|
||
eased `1 − (1−p)^2.4` (quick to leave, slow to die, which is how a real pulse
|
||
decays; linear travel reads mechanical). Opacity falls as `(1−p)² × 0.42` and
|
||
the stroke thins from 2.2 to 1.0 as it expands, like a wavefront losing
|
||
energy.
|
||
- **Comet** — a short arc of light orbiting 16pt outside the sphere on that
|
||
same clock, with a brighter head at its leading edge. A `SweepGradient` whose
|
||
tail dies over a third of the circle.
|
||
- **Breath** — the sphere itself, 1.0 → 1.012 over 4s, mirrored, on its own
|
||
controller because it reverses and has no business being locked to the
|
||
radar's six.
|
||
|
||
The pulses are not decoration. This app sends somebody to your door, and a
|
||
radar sweep is what that looks like — the same reason a ride app pulses over a
|
||
pickup pin. They are slow and low-contrast on purpose: a fast pulse reads as an
|
||
alarm, and this sits under a thumb for as long as somebody is deciding.
|
||
|
||
### 4.3 The press
|
||
|
||
`AnimatedScale` to 0.94 over 140ms `easeOutCubic` — just past the press, so the
|
||
finger feels the give rather than watching it happen — plus
|
||
`HapticFeedback.mediumImpact()`.
|
||
|
||
The haptic matters more here than on a normal button: a sphere gives no edge
|
||
feedback the way a bordered control does, and this press commits you to a flow.
|
||
|
||
The pulses and comet are wrapped in `IgnorePointer`. They leave the sphere's
|
||
edge, so a tap near them is a tap that was meant for it.
|
||
|
||
### 4.4 Geometry
|
||
|
||
204pt disc in a 320pt field. The 116pt of padding is where the pulses live.
|
||
|
||
---
|
||
|
||
## 5. Component library
|
||
|
||
`lib/ui/widgets/`. A screen composes these and does not draw.
|
||
|
||
| File | What it holds |
|
||
| --- | --- |
|
||
| `book_orb.dart` | The BOOK sphere (§4) |
|
||
| `buttons.dart` | Primary bar, quiet button, icon button, text action |
|
||
| `cards.dart` | `DmCard` + `DmCardCell` + `DmCardRow`, `DmMicroHead`, `DmTile` |
|
||
| `chrome.dart` | `DmTopBar`, `DmStepBar`, `DmBrandHeader`, `DmHeaderTrack`, `DmArcField`, the segmented filter |
|
||
| `inputs.dart` | Text fields, `DmSearchField`, settings rows, the OTP boxes |
|
||
| `option_tile.dart` | A selectable option row |
|
||
| `pieces.dart` | Steppers, chips, the pickup-window pieces |
|
||
| `milestones.dart`, `route_rail.dart` | The journey ladder and the pickup→drop rail |
|
||
| `map_panel.dart`, `route_map.dart`, `map_tiles.dart` | Real maps over OSM/CARTO |
|
||
| `states.dart`, `feedback.dart` | Empty, error, loading, snackbars |
|
||
| `misc.dart` | `DmPill`, `DmLiveDot`, `DmAvatar`, the logomark |
|
||
|
||
### 5.1 DmCard
|
||
|
||
A bordered panel that stacks rows and rules them apart. Two decisions in it are
|
||
load-bearing:
|
||
|
||
**The rule belongs to the card, not the row.** A row does not know whether it is
|
||
first, so the same `DmCardCell` works alone or in a stack of five.
|
||
|
||
**The edge is a foreground decoration.** As a background border it was painted
|
||
first and then covered: a cell with its own tint — the washed status strip at
|
||
the head of an order card, the crimson rows on the booked screen — filled the
|
||
whole box, border included, and the card lost its outline exactly where it
|
||
needed one most.
|
||
|
||
Four tones: `plain` (white, hairline edge — almost everything), `fill` (crimson,
|
||
**one per screen at most**; it is the screen's answer to "what is happening
|
||
right now"), `wash` (a crimson tint for supporting brand information) and
|
||
`sunken` (the canvas itself, edged, for deliberately inert content).
|
||
|
||
### 5.2 DmBrandHeader
|
||
|
||
The crimson field at the top of each tab root: eyebrow, title, an optional
|
||
trailing action, and a control belonging to that destination.
|
||
|
||
It carries a `LinearGradient` top-left to bottom-right (`#A0150A` → `#8F0F06` →
|
||
`#7A0C04`) so the field has a direction like every other brand object. The
|
||
spread is deliberately narrow — a header that reads as *a gradient* rather than
|
||
as *a colour* has stopped being the brand.
|
||
|
||
`DmArcField` strikes thin white arcs from the mark's geometry at 15%. Authored
|
||
on a 340pt square anchored top-right and then scaled, so the same geometry
|
||
reads identically on a 210pt Orders header and a 260pt Account one. Decorative
|
||
only, and never announced to a screen reader.
|
||
|
||
---
|
||
|
||
## 6. Screens
|
||
|
||
### 6.1 Auth
|
||
|
||
`auth_scaffold.dart` gives login, sign-up and OTP one shell.
|
||
|
||
Login was rebuilt to the shape top logistics apps use: **one question, one
|
||
field, one button.** No label above the field, no info banner, no badge, a
|
||
compact banner, and a title that names exactly what is wanted — "Enter your
|
||
mobile number". "Use email instead" sits below as a text action, not a second
|
||
button. Every piece of explanation removed from this screen was explanation
|
||
about a field the customer was already looking at.
|
||
|
||
### 6.2 Shell
|
||
|
||
Home · Orders · Account, with a travelling crimson pill on `DmMotion.glide` and
|
||
a `selectionClick()` per switch.
|
||
|
||
### 6.3 Home
|
||
|
||
The screen this redesign was called in for.
|
||
|
||
```
|
||
DmBrandHeader WELCOME, <first name>
|
||
Send a parcel / Send your first parcel
|
||
▸ pickup address, changeable in place
|
||
|
||
canvas ◉ the BOOK sphere
|
||
caption: what will happen
|
||
|
||
HAPPENING NOW (only when something is live)
|
||
the active booking, tappable through to tracking
|
||
|
||
HOW IT WORKS (first run only)
|
||
three steps on a rail
|
||
```
|
||
|
||
The pickup address lives **in the header as a live value**, not as the first
|
||
question of the flow. It is the one fact every booking starts from, so booking
|
||
begins at the destination — two taps from here to a reserved slot.
|
||
|
||
"How it works" used to be a bordered card holding three numbered rows, which put
|
||
a box around the quietest content on the screen and made it compete with the
|
||
sphere above it. It is a timeline now: glyph, hairline, text, nothing enclosed.
|
||
It recedes, which is the whole job of an explainer a returning customer will
|
||
never see again.
|
||
|
||
### 6.4 Booking
|
||
|
||
`send_screen.dart` is one screen, not four: destination, address, window and
|
||
package count in a single pass with a sticky estimate and `Book pickup`.
|
||
|
||
Optional fields — landmark, recipient name, recipient phone — are folded behind
|
||
one disclosure that reports its own state: *"Add landmark or recipient details"*
|
||
when empty, *"2 of 3 extra details added"* when not. A count is the only summary
|
||
worth showing for a group of optional fields; listing them defeats the fold.
|
||
|
||
### 6.5 Tracking
|
||
|
||
Driven entirely by `JourneyStage`. One crimson `fill` card answers "what is
|
||
happening right now", the milestone ladder answers "where in the journey", and
|
||
the map answers "where physically".
|
||
|
||
### 6.6 Orders, receipts, sheets
|
||
|
||
Orders is a segmented filter recessed into the header — Active / Completed /
|
||
Cancelled, each with its count, the selected tab lifting out as a white pill.
|
||
One control, three states, no separate count row.
|
||
|
||
Sheets (`destination`, `place_search`, `window`, `cancel`) are 20pt-cornered,
|
||
`DmShadow.sheet`, and each is a single decision.
|
||
|
||
---
|
||
|
||
## 7. Accessibility
|
||
|
||
- **No text-scale clamp.** Components adapt instead: rows grow, important text
|
||
wraps, and ellipsis is reserved for secondary information. Three tests in
|
||
`booking_flow_test.dart` render key screens at large accessibility text.
|
||
- The sphere is one `Semantics` node reading *"BOOK. A Miler collects from your
|
||
door, in a window you choose."* — label and caption merged, because two nodes
|
||
would make a screen reader announce a button and then an orphan sentence.
|
||
- Decorative paint (`DmArcField`, the pulses) is never announced.
|
||
- Minimum target 48pt (`DmSpace.tap`).
|
||
|
||
---
|
||
|
||
## 8. How to see the design
|
||
|
||
```bash
|
||
flutter test test/design_snapshot_test.dart --run-skipped --update-goldens
|
||
```
|
||
|
||
Renders every screen at 390×844 @3x with the real fonts and writes them to
|
||
`test/snapshots/`. This is a **design** check, not a regression check — a
|
||
deliberate redesign fails all of them, which is worthless as a test signal, so
|
||
the `snapshot` tag is skipped in a normal run (`dart_test.yaml`).
|
||
|
||
It exists because the only honest way to review a visual rebuild is to look at
|
||
it, and this produces the same pictures on any machine without a device
|
||
attached.
|
||
|
||
**`design/screens/` is the browsable copy** — the same 17 files under readable
|
||
names, with an index. It is a copy, and a stale copy is worse than none, so
|
||
`tool/screens.sh` refreshes it in one command after the goldens are rendered.
|
||
|
||
The harness loads Switzer, Geist Mono **and the Lucide icon font** first —
|
||
without that, Flutter's test harness draws Ahem's black boxes and hollow
|
||
squares and the pictures are worthless.
|
||
|
||
---
|
||
|
||
## 9. Where design lives in code
|
||
|
||
| Decision | File |
|
||
| --- | --- |
|
||
| Every colour, radius, type style, shadow, duration | `lib/ui/tokens.dart` |
|
||
| Theme, page transitions, system bars | `lib/main.dart` |
|
||
| The one lit object | `lib/ui/widgets/book_orb.dart` |
|
||
| Entrance choreography | `lib/ui/screens/home_screen.dart` |
|
||
| Reusable surfaces | `lib/ui/widgets/` |
|
||
| Greeting, relative time, date labels | `lib/ui/format.dart` |
|
||
| The pictures | `test/design_snapshot_test.dart` → `test/snapshots/` |
|
||
|
||
---
|
||
|
||
## 10. The effort pass (2026-09-22)
|
||
|
||
v3 made the app look designed. It was still text-heavy, form-like and
|
||
explanatory, so a second pass ran every screen through one test: *if I remove
|
||
this sentence, does the customer make a worse decision?*
|
||
|
||
### 10.1 The flow changed shape
|
||
|
||
BOOK used to open the whole form, with the destination as a horizontal strip of
|
||
cards halfway down it and a disabled button reading "Pick a city to see the
|
||
price". The destination is the only choice that moves the price, so it is asked
|
||
first, in a sheet that rises from the sphere:
|
||
|
||
```
|
||
Home ▸ BOOK ▸ Where is it going? (sheet: search · recent · all cities)
|
||
▸ Send a parcel (address · packages · window · price)
|
||
▸ Pickup booked
|
||
```
|
||
|
||
Nothing was added. The send screen lost a block and gained a price on arrival.
|
||
|
||
### 10.2 What each screen stopped saying
|
||
|
||
| Screen | Removed |
|
||
| --- | --- |
|
||
| Home | The sphere's caption for returning customers; the live booking's four-cell card (pill, reference, route rail, courier sentence, window, Track button) → one row; the explainer's three subtitles |
|
||
| Send | The city strip and its head; the pickup wash card with icon tile and three lines → one quiet line; "Street address" above "Flat or house number"; the window/package card; three sentences about weighing and charging → one line |
|
||
| Window sheet | "Collecting from …", which the screen behind it was already showing |
|
||
| Confirmed | "We've reserved your slot. A nearby Miler is being assigned…"; the "Keep the parcel ready" banner; the duplicated pickup line |
|
||
| Tracking | The supporting sentence under the headline; "Step 2 of 7"; the milestone label beside it; "Tracking your parcel live" and its Refresh action (now pull-to-refresh); "All 7 stages" → "Full timeline" |
|
||
| Orders | The crimson reference strip → a quiet identifier on the meta line; "Collected on DM-471200" |
|
||
| Account | The four washed crimson icon tiles; the crimson "Preferences" head |
|
||
|
||
### 10.3 The second round
|
||
|
||
**The customer chooses their own window.** Picking a city used to silently take
|
||
the first available slot — the customer arrived at a booking carrying a time
|
||
they had never agreed to. A pickup window is a promise about somebody's
|
||
afternoon. The window sheet is now a step in the flow, between the destination
|
||
and the form, and the button stays disabled until it has been answered.
|
||
|
||
**The primary button is lit.** It was a flat crimson rectangle at the *field*
|
||
radius — `DmRadius.book` existed for exactly this and had never been wired up.
|
||
It now takes the sphere's gradient and rim highlight, the crimson glow it
|
||
already had, a 0.985 press scale (it has no border to shimmer) and a **medium**
|
||
haptic, where everything else gets a selection click.
|
||
|
||
**Floating labels.** An empty field under its own caption is two rows to ask one
|
||
question. `DmTextField(floating: true)` puts the label inside the field and
|
||
floats it out once there is something to caption — so the field is never blank
|
||
and the caption only appears when its job is reminding you what you typed.
|
||
|
||
**FROM / TO instead of two blocks.** The send screen's pickup and destination
|
||
were seven strings and the word "Change" twice. They are one tappable thread
|
||
now: two nodes, two eyebrows, two chevrons.
|
||
|
||
### 10.4 The bars
|
||
|
||
**The top bar knows what is under it.** It was a flat slab of canvas, the same
|
||
colour as the page, with no line: content scrolled beneath it and simply
|
||
vanished. It now reads the `ScrollNotificationObserver` that `Scaffold`
|
||
installs — the same mechanism Material's own `AppBar` uses — and turns from
|
||
canvas to surface with a hairline the moment a list is under it, back to
|
||
invisible at the top of a page. `test/top_bar_test.dart` holds that behaviour,
|
||
because the observer is a framework detail this app does not own.
|
||
|
||
**Back lost its box.** A 40pt white square with a hairline and a 12pt corner,
|
||
on a canvas of white bordered cards, read as *a card containing an arrow*. It
|
||
is the glyph, a 48pt target, and a circular ripple in the brand wash.
|
||
|
||
**The bottom bar's pill stopped glowing.** It carried `DmShadow.liftSm` — the
|
||
65% crimson glow built for a committing button — which at the bottom edge read
|
||
as a smear rather than as lift. A quarter of that now, closer to the shape.
|
||
The glyph also pops to 1.14 and settles on the pill's own overshoot curve, so
|
||
the two read as one gesture, and a pressed tab scales to 0.9: without it, the
|
||
only feedback for a tap was the pill starting to move, which on the tab you are
|
||
already on is no feedback at all.
|
||
|
||
### 10.5 State, then district
|
||
|
||
The destination was one flat list of every serviceable district, on the
|
||
argument recorded in the old code that "nobody picks Tamil Nadu on the way to
|
||
picking Chennai". That is true of twelve districts and false of sixty: the list
|
||
this app will have in a year is a scroll, not a glance.
|
||
|
||
Browsing is two steps now, in the same sheet — states, then that state's
|
||
districts, with a back arrow that returns to the states rather than dismissing
|
||
the question. **But search cuts straight through**: typing at the first step
|
||
searches districts across every state and returns them flat, because somebody
|
||
who knows they are sending to Chennai should not have to know which state it is
|
||
in. Hierarchy for browsing, flat for search — the old insight kept where it was
|
||
actually right.
|
||
|
||
The states are **derived from the district list, not fetched**. `loadCities`
|
||
already returns every serviceable district with its state attached, so grouping
|
||
it is one request instead of two and is the only version that cannot show a
|
||
state with nothing open inside it. The city count per state comes free.
|
||
|
||
**Marks go on states, not districts.** An earlier pass marked every district —
|
||
Chennai the capital, Coimbatore a factory, Tiruppur knitwear. It scanned well
|
||
at twelve and does not survive sixty, and more to the point it put the app in
|
||
the business of having an opinion about every district in India: a table nobody
|
||
can keep honest and a mistake somebody eventually finds offensive. There are
|
||
twenty-eight states; that is a bounded problem. District rows carry the name
|
||
and the delivery promise, and nothing competing with them.
|
||
|
||
`lib/ui/state_marks.dart` holds the table. One thing about the place a person
|
||
living in India would recognise, and never a religious building — a temple
|
||
state drawn as a church is worse than no icon.
|
||
|
||
**The fallback is the contract.** `stateMark()` answers for every code,
|
||
including ones that do not exist yet: a state opened by the backend appears
|
||
here immediately, wearing the pin and looking deliberate, until somebody adds a
|
||
line. Adding a state is one entry; forgetting to is not a bug.
|
||
|
||
### 10.6 The stripping pass
|
||
|
||
**The window sheet is times.** Each row carried up to four things: a "Fastest
|
||
pickup" tag, "4 Milers nearby", the window, and a caption like "Relaxed evening
|
||
handover". Three of those were the backend selling a slot to somebody who had
|
||
already decided to book. An unavailable slot keeps its note, because "Fully
|
||
booked" is the reason the row cannot be tapped.
|
||
|
||
**The send screen stopped asking for a street address.** "Flat or house number"
|
||
and "Street and area" were the last two questions, asked on the screen where
|
||
the customer has already decided. They are optional on the contract — every
|
||
test that books without them has always passed. `DeliveryDetails.building` and
|
||
`.street` still exist and still go out; they are left unset. If they come back
|
||
they belong on a screen *after* the booking, not in front of it.
|
||
|
||
**Several places in one pickup, but only when the server says so.** The
|
||
destination sheet multi-selects: districts tick, the selection survives moving
|
||
between states, and the state rows carry a count. It falls back to single-select
|
||
whenever `BookingLimits.allowsMultipleDestinations` is false, which is what the
|
||
live backend advertises today — multi-destination is undeliverable until the
|
||
Miler build keys on `consignmentid`, and a sheet that let somebody pick three
|
||
places against a server capped at one would sell them a booking the network
|
||
cannot complete. `AppState.setDestinations` clamps again on the way in, because
|
||
the cap can change between the sheet opening and the draft being written.
|
||
|
||
**Recent went.** It was three district rows above the states, which put the
|
||
thing the sheet is *not* about at the top of it.
|
||
|
||
### 10.7 The payoff, and the rail
|
||
|
||
**The tick left the red card.** Confirmation was a filled crimson card holding
|
||
a translucent disc, "Pickup booked" and the window. Crimson is this app's
|
||
*action* colour — the sphere, the button, the live card — so the one screen
|
||
whose job is "this is finished" was wearing the colour that means "do
|
||
something", and a tick on a wash of its own background reads as a watermark.
|
||
It is a white tick on a green disc now, centred, with no box: green already
|
||
means exactly one thing in this system, and this is the only place besides a
|
||
delivered order where the app gets to say it.
|
||
|
||
**The rail became the progress bar.** Every connector used to be the same grey
|
||
hairline, so working out how far along a parcel was meant finding the filled
|
||
dots and counting them. The segment above a node is crimson once that node is
|
||
reached and grey when it is not, so the line fills from the top as the parcel
|
||
moves. Three node shapes, readable without colour: a ticked disc behind you, a
|
||
ring with a lit centre where you are, a hollow outline ahead.
|
||
|
||
It also **runs chronologically** now. It ran newest-first, on the argument that
|
||
"where is my parcel right now" should not be at the bottom — right when it was
|
||
a list of rows, wrong now that it is a rail, because a filling line read
|
||
backwards is nonsense. The answer is no longer down there either: the crimson
|
||
card at the top of tracking states it outright, which frees the rail to answer
|
||
*how far along*. Identity left, time right, on one baseline — the same split
|
||
the Miler app's route rail uses, so the two apps read as one product.
|
||
|
||
The card around it went too. It is on the canvas now.
|
||
|
||
### 10.8 Rules that came out of it
|
||
|
||
**A disabled primary action is a design failure.** Ask for what unlocks it
|
||
first. Both the send screen and the window sheet now open in a state their
|
||
button accepts.
|
||
|
||
**One tap, not two, for a single-select list.** A "Continue" button under a
|
||
list of rows asks the customer to say the same thing twice. Choosing a row in
|
||
the destination sheet closes it.
|
||
|
||
**Recognition over recall.** The destination sheet reads the customer's own
|
||
order history for its Recent group — no request, no new endpoint.
|
||
|
||
**A card holding one control is a border drawn for its own sake.** The package
|
||
stepper and the pickup line are rows on the canvas now.
|
||
|
||
**Commitments belong above the button that agrees to them.** The send screen's
|
||
footer carries the window and the price, and the window half is tappable.
|
||
|
||
**Continuity is cheap.** While the destination sheet is up, Home scales to 0.96
|
||
and fades to 0.45, and the sphere stays compressed at 0.94. The sheet reads as
|
||
having been pushed up by the thing that was tapped.
|
||
|
||
---
|
||
|
||
## 11. The reference pass (2026-09-23)
|
||
|
||
A set of logistics-app references — white cards on a soft canvas, one dark
|
||
accent, big reference numbers, labelled columns — and one instruction: *less
|
||
red, less boxy, more modern*. What follows is what that actually meant in this
|
||
codebase.
|
||
|
||
### 11.1 The redness had one source
|
||
|
||
`DmBrandHeader` was 260pt of solid brand on Home, Orders and Account — three of
|
||
five screens opening on a wall of it. Every `onBrand*` token existed only to
|
||
survive that field, and the accent that is supposed to mean *do this* was the
|
||
background of half the product.
|
||
|
||
**It is the canvas now, with ink type.** What is left of the brand on those
|
||
screens is the sphere, the primary button and a live dot, which is what an
|
||
accent is for. The arcs survive at 3.5% crimson instead of 15% white — they
|
||
were struck from the mark's geometry and were never the colour doing the work.
|
||
|
||
Knock-ons: the header's address track became a bordered field and then a bare
|
||
line (below); the avatar and the Account/Orders actions dropped their on-brand
|
||
variants; the Miler card lost its crimson wash, which on a header-less screen
|
||
was the loudest surface left.
|
||
|
||
### 11.2 The canvas was doing no work
|
||
|
||
At `#F7F6F4` a white card was a **3% step** off the background, so the 1px
|
||
border was separating everything and every screen read as a list of outlined
|
||
boxes. Canvas → `#F1F0EC`, card radius **14 → 18**. The edge is still there; it
|
||
stopped being the only thing holding the layout together.
|
||
|
||
That change immediately exposed two screens whose content was floating on bare
|
||
canvas — the send screen's route and package rows, invisible as a problem at 3%
|
||
contrast and unfinished-looking at the new one. Both are on cards now.
|
||
|
||
**The gutter came in too**, `DmSpace.pad` **24 → 16**. Twenty-four was right
|
||
when the header was a crimson field and the canvas held loose rows; on a page
|
||
of white cards it left a broad margin down both sides and the cards read as
|
||
narrower than the screen.
|
||
|
||
### 11.3 Orders
|
||
|
||
The filter left the header. It was a segmented control recessed into the
|
||
crimson field, which made it look like part of the title bar rather than
|
||
something to press. It is three chips on the canvas, **ink** when selected —
|
||
one of three is always on, and crimson there would be a permanent red block on
|
||
a screen whose crimson is meant to be the status pills, the thing that changes.
|
||
|
||
The card follows the reference: **reference number and status pill, then From /
|
||
To as two columns, then Placed / Contents, then a horizontal journey rail.**
|
||
|
||
Two columns rather than a vertical thread, because the rail cost the full width
|
||
of the card to say two things; side by side, from and to are one glance and the
|
||
same route fits in half the height — which is what lets the journey rail exist
|
||
without the card becoming a page.
|
||
|
||
**The rail labels one node, not five.** The reference labels all three of its
|
||
nodes; that works at three and does not at five — "Booking created / Order
|
||
created / In transit / Out for delivery / Delivered" across 320pt is five
|
||
ellipses. The line carries *how far*, the word carries *where*.
|
||
|
||
"SEPTEMBER 2026" went from the header: it sat above a list that is not grouped
|
||
by month, does not scroll by month, and says "Today" and "Yesterday" on its own
|
||
rows.
|
||
|
||
### 11.4 Tracking, rebuilt
|
||
|
||
**The map went.** It was 178pt of basemap, two labels and a pin, on a screen
|
||
whose question is "where is my parcel". The pin was the *pickup address* — a
|
||
place the customer chose and already knows — not the Miler, who only has a
|
||
position for the twenty minutes between assignment and the door. A picture that
|
||
cannot show the thing it implies invites somebody to stare at a stationary dot
|
||
and conclude nothing is happening. The radar that preceded it went for the same
|
||
reason: animation standing in for an answer.
|
||
|
||
**The crimson head card went.** "Arun Kumar is assigned" at 27pt on a filled
|
||
brand field, with a supporting line, a progress rail and the window under it.
|
||
Crimson is the action colour and tracking is a screen you read. It is a white
|
||
card now: the reference at 26pt, a status pill, and the route as a thread.
|
||
|
||
**No progress bar inside it.** The journey rail below is the same five
|
||
milestones with the same fill, and two of them on one screen is the app
|
||
disagreeing with itself about which is the answer.
|
||
|
||
**The Miler card moved to the foot.** It sat directly under the reference,
|
||
which put *a person* between the customer and the answer they opened the screen
|
||
for. It is **who**; the screen's question is **where**. The journey has the
|
||
middle of the page now.
|
||
|
||
Also gone: a duplicate route card at the foot, the duplicate reference in the
|
||
top bar, and the white bed under the footer buttons.
|
||
|
||
### 11.5 Small things that were wrong
|
||
|
||
**The destination node is a map pin.** A rotated diamond distinguished the ends
|
||
of the thread — its whole argument — and did that by being a shape with no
|
||
meaning attached. A pin is the mark every customer already reads as *a place*,
|
||
so the thread needs no legend: hollow circle where you are, pin where it is
|
||
going.
|
||
|
||
**The status pill was floating mid-row.** `Flexible` gives a child the space it
|
||
needs and then leaves it at the *start* of what it was allotted. It takes an
|
||
`Align(centerRight)` to put a status where a status belongs.
|
||
|
||
**Home's address is a line, not a field.** It was a bordered white box with a
|
||
chevron — a text input that takes no text, which is the shape ride apps stopped
|
||
using years ago. A dot, the place, and a quiet "Change".
|
||
|
||
**BOOK / NOW is stacked**, which bought back size: both words get the sphere's
|
||
full width, so it is 30pt over two lines rather than the 24pt a single line had
|
||
to shrink to.
|
||
|
||
**Settings rows stopped truncating.** Two rounds of flex arithmetic produced
|
||
"Payment metho…" and "38 Mettupalaya…" in turn. The fix was not a third ratio:
|
||
the label never truncates, and a `value` is a word — anything longer belongs on
|
||
the screen the row opens.
|
||
|
||
---
|
||
|
||
## 12. If you are adding something
|
||
|
||
1. Take the value from `tokens.dart`. If it is not there, add it there.
|
||
2. Ask what the screen's one decision is. If your addition is not it, it is
|
||
secondary — make it look secondary.
|
||
3. Give it an edge, not a shadow, unless it is the committing action, floats
|
||
over a map, or is a sheet.
|
||
4. If it is lit, light it from the upper left, and do not let it terminate in a
|
||
hard line.
|
||
5. Re-render the snapshots and look at them.
|