8 Commits

Author SHA1 Message Date
c7a74c57b8 BOOK NOW, OpenStreetMap, and one segmented control instead of two
── The circle says what pressing it does ──

"ONE TOUCH" named the *mechanism* — one gesture, no form — which is something
the product team knows and a customer has to be taught. Nobody arrives at that
screen wanting a touch; they want a parcel collected. The caption under the
sphere still carries what makes it different from the form below.

Renamed in the comments too. A codebase explaining "One Touch" against a button
that says BOOK NOW is a trap for whoever reads it next.

── OpenStreetMap everywhere ──

One line: the default provider was CARTO, it is `osm`. Nothing else moves —
`DmMapTiles` already reads the template, subdomains, retina flag and
attribution off the provider, so the credit line follows on its own.

One thing recorded on the provider itself rather than left to be discovered:
these are donated servers and the OSM Foundation's tile policy does not permit
a distributed app to lean on them. A block looks like every tile turning into
the ground colour at once, with no other symptom. Moving off it is one define —
`DM_MAP_PROVIDER=carto|maptiler|stadia`, all serving OpenStreetMap data — and
the map_config test now asserts the identifying User-Agent rather than only the
URL, because that is what attributable traffic depends on.

── Orders had a second copy of the segmented control ──

Its own `_Tab`, a pill radius, 3pt of padding and the count folded into the
label's text — beside the pickup window's day switcher, which is DmChoiceChip
in a rounded groove with 4pt of padding and the count in a bubble. Two controls
doing one job, drifting apart a padding value at a time.

It is the same control now, and `_Tab` is gone. DmChoiceChip's horizontal
padding drops 12 → 9: three of them split a 390pt phone and "Cancelled"
truncated to "Cancell…" at the old value. The day switcher has two chips and
acres of room, so it loses nothing.
2026-09-30 11:19:30 +05:30
8757b16cf5 PIN sign-in, because the code could never arrive
── What was actually broken ──

The SMS gateway was switched off, and `POST /auth/otp/request` does not fail
when that happens: it still answers `sent: true`, still issues a valid 4-digit
code, and writes it to the **server log**. So the phone path walked customers
to a code screen for a code that could not arrive, and every digit they
eventually typed was wrong. The failure read to them as "I entered it wrong".

Phone sign-in is now a PIN, which needs no gateway.

── Email still sends codes, so email is untouched ──

Email OTP goes over SMTP and works. Deleting a working way in to tidy up a
broken one is a net loss for anyone with an email on their account, so "Use
email instead" and the code screen stay exactly as they were.
`login_otp_guard_test` moves to that path — the `sent: false` guard still
matters there, and that is now the only place it can fire.

── One screen, three entrances ──

`POST /auth/login` says which of them a number is before anything is asked, so
the app never guesses. Guessing is not cosmetic: offer "create a PIN" to a
returning customer and the server answers `pin_already_set` on a screen that
cannot succeed; offer "enter your PIN" to somebody who has never set one and
every attempt is wrong.

The separate sign-up screen is deleted rather than hidden. It asked for a name
and then sent an SMS code — a second entrance asking the same questions and
posting a letter that never lands. A new number now gives its name and PIN on
the same screen.

── A second sign-in path found a latent bug ──

`AppState.signIn` only started `refreshOrders`, and the OTP screen called
`detectPickupLocation` itself afterwards to make up the difference. That held
exactly as long as there was one sign-in screen. PIN sign-in did not know about
the extra call, so Home opened with no pickup and no serviceable cities.

The work belongs to signing in, not to whichever screen happened to be last, so
it moved into `signIn` and the OTP screen's copy is gone.

── What the screen deliberately does not do ──

It does not greet by name. `POST /auth/login` returns the account holder's
name, which tells anybody who types a number who owns it; the field is read but
never displayed, so it disappears quietly when the backend drops it.

It does not say whether the number or the PIN was wrong — the server answers
identically for both on purpose, and narrowing it here would turn sign-in into
a way of testing whether a number has an account.

"Forgot your PIN?" renders only when a support contact is configured. There is
no reset endpoint, so it can only point at a human — and telling somebody
locked out that help exists without saying where is worse than silence.

── The handover note does not reach the Miler ──

The app said "we pass this to your Miler as a note". It does not: `remarks`
reaches the admin console and stops, because the rider app reads a `notes`
field per stop that the backend never sends. A customer could hand their parcel
to a neighbour believing the Miler had been told. Both screens now say it is
recorded on the booking, and that the Miler still calls the account's number.

── Also ──

DmTextField gains `obscure`, and PinScreen carries a back button — without one
the only correction for a mistyped digit was killing the app.
2026-09-30 10:44:22 +05:30
c3e25feaea Five payload bugs, four pages behind dead rows, and one sheet
── The full-address path was reaching the Miler empty ──

`DestinationGroup.toBookingJson` spread its details FLAT across the
destination. The contract nests them under `details{}`, and a destination
carrying keys the server does not recognise is accepted without a word — so
every building number, street, landmark, recipient name, recipient phone and
pin a customer typed was written, answered 201, and thrown away. The Miler
arrived with a district.

Four more on the same call. The destination pin spelled `latitude`/`longitude`
— the same spelling that answered 422 unserviceable for months on the pickup
before it was fixed there and missed here. A PATCH that sent `null` to clear a
field, with a comment saying so, when the server writes only non-nil values, so
a landmark could be added and never removed. Per-destination `instructions`
folded into the visit's one `remarks` line on the belief the contract had no
per-destination note; it has one. And `contactName`/`contactPhone` on the
pickup object, which the create contract has no room for and drops.

The fix ships unverified, deliberately. If `details{}` is also the wrong shape
the fields drop exactly as they do today — it cannot be worse, and holding it
costs every full-address booking in the meantime. docs/BACKEND_CHANGES.md asks
for the confirmation; tool/verify_booking.sh runs it in one command.

── Who the Miler rings ──

One number reaches the rider and it is the account's: `GET /miler/bookings`
returns a single `customerphone`, verified against production and written down
in the rider app's own stop_contact.dart. So "Someone else is handing it over?"
was collecting a number that reached nobody.

Review now shows the number that will actually be dialled, and the handover
person travels in `remarks` with a name, labelled for whoever reads it. Both
screens say plainly that the rider's call button still dials the account —
better than letting somebody hand their parcel to a neighbour believing
otherwise.

── Account's rows led nowhere ──

Two had no `onTap` at all — a chevron pointing at a page that did not exist —
and three answered with a toast. Five rows making a promise, one keeping it.

Notifications, Payment, Help and About are real screens now, written to one
rule: say only what is true of this app today. There is no notification
endpoint, no stored payment instrument and no push SDK wired in, so none of
them pretends to manage any of that. Support shows no contact block at all
rather than a number that rings nowhere — AppConfig carries the fields empty
until somebody fills them in.

── ONE TOUCH is one sheet ──

It was two in sequence with a dismissal between them, and the destination step
made you open a state to see any city — two levels of navigation for something
its own search already flattened. One flat list headed by state, which is also
the answer to "where do you deliver?", and one surface that changes its
question instead of closing so another can open.

Home says the reach in a line, and it needed two fixes to appear at all:
`cachedCities` walked closed states looking for districts that are only fetched
for open ones, and `loadCities` filled two caches while notifying nobody.

── Sending a second parcel ──

`maxDestinations` is 1 in production, so two parcels for two places means
booking twice — and that cost the whole flow twice, re-answering a door the
customer had not moved from. `startBookingFrom` carries the door, carries the
destination only when asked, and never carries the window: a slot fills up, and
a second booking pinned to one that is now full is refused at confirm with
nothing the customer can act on.

Review also says why there is no "add another destination", so a cap reads as a
limit rather than a missing button.

── Bundle ──

pubspec named its images one by one. Declaring `assets/images/` as a folder
shipped a 974 KB launcher-icon master to every customer for a file no code
opens.
2026-09-29 12:31:58 +05:30
49b0de0d8f Pages fall away downward, the pickup comes back, and a real icon set
── Navigation ──

Pages arrived from the right and left to the right. Right is the direction
the system back gesture drags, so a page leaving under a button press read as
a swipe nobody made — and every push in this app is a layer over the one
before it, which arrives from below. The axis is vertical now: the incoming
page rises and covers, the page underneath stays put and dims, and back drops
the top page off the bottom. Full travel rather than the old 14% nudge, which
is what makes the direction nameable. test/page_transition_test.dart holds it.

── Home ──

The pickup pill is back under the greeting. It was cut on the argument that
the form below already carried the pickup; the form is four hundred points
down the screen, and a location you can only see by scrolling to a form is a
location you do not trust. It shows the locality while the form keeps the
door, so the screen answers one question once, and it now has a resolving
state — it used to print the last place you were, confidently, while the fix
was still in flight.

The sphere's caption was falling off the screen. Adding the pill pushed it out
of the scroll viewport, silently, with no overflow warning — the fourth time
Home growing has clipped something under the sphere. DmBookOrb.field is no
longer a constant: the glow gives way to whatever is left, the sphere never
does.

── 74 points of dead canvas, on every root ──

Home, Orders and Account each cleared the floating tab bar by
`MediaQuery.paddingOf(context).bottom + 74`, on the reasoning that the inset is
the home indicator and 74 is the bar above it. Scaffold with extendBody
replaces the body's bottom padding with the bar's whole laid-out height —
measured, a 34pt inset and this bar hand the body 106. They were clearing the
bar, then clearing it again. One helper now, dmTabBarClear().

── One journey, one vocabulary ──

Review said ORIGIN/DESTINATION; Home's form and tracking say PICKUP/DROP.
Three screens a customer walks in order. The tracking card had already written
down the argument; the booking screen hadn't followed.

The rail's first milestone read "Booking created" directly above "Order
created" — down the rail: created, created, in transit. It is "Pickup booked",
which is what JourneyStage.milestoneLabel has called that stage all along on
Orders and on Home's live row.

── The launcher icon ──

tool/icons.py renders all twenty-eight files from one master, because the
failure mode of exporting by hand is nineteen replaced and nine left on the
old mark, at the density nobody checks.

The master is a bare mark on a field, so it cannot be dropped into Android's
adaptive canvas whole: crimson is the background, the white mark is the
foreground, and it is sized by its reach from the canvas centre so a round
mask cannot take the arrow tip. Measured on the built file: 63.7dp against a
66dp safe zone. A real monochrome layer replaces the colour foreground that
was standing in for one — the system tints by alpha, so that themed as a blob.
ic_launcher_round.png exists at last; minSdk is 24 and android:roundIcon had
nothing to resolve to below API 26.

The artwork is flattened (the source vignettes over ten values of red) and its
horizontal offset is placed deliberately rather than inherited — geometric
centring made the mark lean, because the arrow carries the bounding box right
while contributing almost none of the ink.

── Bundle ──

pubspec declared assets/images/ as a folder, which shipped the 974 KB icon
master to every customer for a file no code opens. Named explicitly now.
2026-09-25 13:34:52 +05:30
86b6af48c2 Redesign on the Stitch reference, in Plus Jakarta Sans
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
2026-09-24 11:08:25 +05:30
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
8207e27a97 Booking and sign-in flow, and the offline build back under guard
Picks up where 0d66627 left off. Three things.

SIGN-IN, CUT TO THE QUESTION IT ASKS
It opened with a quarter-screen crimson hero carrying a lockup, a CUSTOMER
badge, a display-size promise and a sub-line, then a Phone/Email toggle, then a
labelled field, then a card explaining what a verification code is. Six things
to read before the one thing to do.

Nobody arrives at a sign-in screen needing to be sold the product. It is a
heading, a field and a button now, which is where Uber, Bolt and Porter put
them. The toggle became one quiet line under the field — choosing a method was
the first decision on the screen, before the customer had seen what was being
asked, and almost everyone uses the phone. The privacy card went: it explained
that a code would be sent, which the next screen demonstrates a second later.

`DmTextField.label` is nullable for this — "Enter your mobile number" above a
field captioned "Phone number" is one sentence printed twice.

BOOKING, DOWN TO ONE SCREENFUL
Landmark, recipient name and recipient phone are all optional and were all
drawn at the weight of the two fields that are not, putting six rows of "you
may skip this" between the address and the button. They fold behind one row
that counts what is filled in rather than just saying "optional".

Four crimson section heads became one. An accent used five times on a screen is
not an accent; crimson now marks the destination, which is the only choice that
changes the price.

Together those put the window, the package count and the CTA above the fold.

THE OFFLINE BUILD, BACK, UNDER TWO RULES
Deleted on 15 Sep after it cost two rounds of hunting for bookings in the admin
console that had never left the phone. That was not caused by the fake
existing — it was caused by a fake that did not announce itself and that
nothing stopped from shipping. Both are closed:

  * `useDevData` is false in a release whatever the defines say;
  * `describe` leads with DEV DATA (offline) and shows "no network" rather than
    a host the build never contacts.

It is opt-in — `flutter run` still talks to the real API — which is the
property whose absence caused the original mess. `devAutoLogin` is deliberately
false under FLUTTER_TEST so the widget tests keep driving the real entrance.

    flutter run --dart-define=DM_MOCK=true

86 tests green, analyze clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
2026-09-21 13:10:28 +05:30
0d66627c3c Replace the customer app with Doormile CX
Book a pickup, track it to delivery — the rebuilt customer app.

- Design language from doormile-screens.html: brand #8F0F06, Manrope +
  Geist Mono (variable fonts), bordered cards instead of shadows, crimson
  brand headers, sliding tab indicator, mono for anything read digit by digit.
- lib/data (one live API implementation, plus a debug-only offline fake),
  lib/state, lib/ui (tokens, widgets, screens).
- 84 tests, plus a design snapshot harness that renders every screen with the
  real fonts: flutter test test/design_snapshot_test.dart --run-skipped
  --update-goldens

This replaces the previous app (pubspec 'doormile', app id
com.doormile.customer). That tree remains in history at 6c7d656; note its
android/app/google-services.json is not carried over, and the application id
here is in.doormile.customer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 16:07:33 +05:30