Files
doormile_customer_app/design/DESIGN.md
Thiru-tenext 06fa6b797a Redesign: Poppins, a two-step destination, and a splash that says what the app does
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
2026-09-22 17:45:54 +05:30

36 KiB
Raw Blame History

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

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.