The effort pass, end to end. Every screen was run through one test — if I remove this sentence, does the customer make a worse decision? — and the parts that failed it are gone. The flow Home ▸ BOOK ▸ Where is it going? ▸ When shall we collect? ▸ details ▸ booked BOOK opens a sheet, not a form. The destination is browsed state-then-district because a flat list of every serviceable district survives twelve and not sixty, and search cuts across states because somebody who knows they are sending to Chennai should not have to know which state it is in. Districts multi-select, but only where the server allows it: BookingLimits advertises maxDestinations: 1 until the Miler build keys on consignmentid, and a sheet that ignored that would sell a booking the network cannot complete. The pickup window is now a step the customer answers rather than a slot chosen for them. A pickup window is a promise about somebody's afternoon. What the screens stopped saying Home lost the orb caption for returning customers and a four-cell live card. Send lost the city strip, both address fields, the optional disclosure and three sentences about charging — the route, the packages and the button are what is left. Tracking lost a radar with a bike in it, a Milers-in-your-zone count, a "Step 2 of 7" and a sentence describing the screen you were looking at. The window sheet lost "Fastest pickup", "4 Milers nearby" and "Relaxed evening handover". Type Poppins, which has no variable release — four static cuts, and the sans styles set fontWeight alone because fontVariations on a static font is ignored in silence. Every weight dropped a step and the tracking went deeper: Poppins is built on near-circles and carries more ink than the humanist faces before it. Objects One lit sphere on Home, and the primary button now takes its gradient and rim because a committing action that is not lit like the hero reads as a different material. The tracking rail's connector is crimson as far as the parcel has come, so the line is the progress bar. Confirmation is a white tick on green: crimson is this app's action colour and that screen has nothing left to do. Bugs found on the way The OTP screen dropped digits. Four fields passing focus along lose a keystroke that arrives mid-transition, so "1234" became "124" and the screen answered "That code did not match" — blaming the customer for its own race. One field now, four boxes that only draw. Nothing ever asked for the customer's location: detectPickupLocation was the OTP screen's job, so a restored session or an auto-login never triggered the permission prompt and the pickup map had nothing to centre on. The launcher icon and both splash screens pointed at a house drawn as two vector paths — a placeholder that shipped. The splash clock started when the widget was built rather than when it was visible, so the truck got 0.45s of a 1.8s beat behind Android's own splash. It waits on waitUntilFirstFrameRasterized now, raced against a timeout so a binding that never reports one cannot strand the app. Also: design/screens/ holds all 19 screens under readable names, tool/ has the scripts that refresh them and rebrand the Lottie, and DESIGN.md is current. flutter analyze clean. 88 tests, 1 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
747 lines
36 KiB
Markdown
747 lines
36 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
|
||
|
||
**Poppins for voice, Geist Mono for data.** Poppins (Indian Type Foundry, SIL
|
||
Open Font License) is the app's voice as of 2026-09-22.
|
||
|
||
**Poppins has no variable release.** Google Fonts ships it as static cuts only,
|
||
so `pubspec.yaml` declares one file per weight and the sans styles below set
|
||
`fontWeight` and nothing else — `fontVariations` on a static font is ignored in
|
||
silence, which is worse than useless, because it reads as if it were doing the
|
||
work. Geist Mono is still variable and its three styles still carry the axis.
|
||
|
||
Only the four weights the design uses are bundled — **500, 600, 700, 800** —
|
||
because each is a separate ~155 KB file. That is ~625 KB of sans against the
|
||
165 KB a variable file cost, and it is the price of this typeface. Asking for
|
||
w900 anywhere would not fail; Flutter would quietly synthesise it from Bold,
|
||
and it would look like it.
|
||
|
||
**Every weight is one step lighter than the face before it.** Poppins is built
|
||
on near-circles and carries far more ink at the same nominal weight than a
|
||
humanist sans. What was w900 is w800, w800 is w700, w700 is w600. At the old
|
||
numbers the headings looked inflated rather than strong.
|
||
|
||
**And the tracking is deeper again** — the opposite correction from the last
|
||
pass. Poppins is wide, round and has generous sidebearings, so display sizes
|
||
need pulling in harder than anything this app has used: the header runs at
|
||
**−1.2**. Body sizes stay near zero, because that same roundness is what makes
|
||
it legible small and tracking it in takes that back. Tracking belongs to a
|
||
typeface, not to a design.
|
||
|
||
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.2** | The brand header's own title |
|
||
| `display` | 31 / 1.1 / 800 | −0.95 | The one dominant line on a screen |
|
||
| `title` | 25 / 1.16 / 800 | −0.65 | Screen headings on canvas |
|
||
| `sheetTitle` | 21 / 1.2 / 800 | −0.55 | Bottom-sheet titles |
|
||
| `heading` | 18 / 1.25 / 700 | −0.4 | The head above a group of cards |
|
||
| `cardTitle` | 15 / 1.4 / 600 | −0.15 | A row's own line — the workhorse |
|
||
| `body` | 15 / 1.5 / 500 | −0.1 | 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 every Poppins cut, Geist Mono **and the Lucide icon font**
|
||
first — every cut, because Poppins is static and a `FontLoader` given one file
|
||
renders w800 headings by smearing the one weight it has —
|
||
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. 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.
|