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>
14 KiB
API-readiness audit — doormile_cx
Every mocked call still to be replaced, what it must send, what it must return, and which screen breaks if it is wrong.
Rules that hold across all of them
- All 19 live in
lib/data/doormile_api.dart. Replace the method body, keep the return type, and no screen changes. - No widget calls
DoormileApidirectly any more — screens go throughAppState, which holds the injected instance. The UI cannot tell mock from real, which is the property that makes this a body-swap rather than a rewrite. - Every failure must throw
ApiException(code, message)wheremessageis customer-safe:DmAsyncListrenders it verbatim with a Retry. LocationService(device GPS) andDmMapConfig(tile provider) are not on this list — they are platform and build configuration, not backend.
Status legend — Blocking: the flow does not work without it · Wired: seam exists and is called, mock returns a no-op · Delete: prototype-only, remove at go-live.
Auth
| # | Method | Sends | Must return | Consumed by | Status |
|---|---|---|---|---|---|
| 1 | sendOtp(identifier) |
identifier — E.164 phone (+919876543210) or email |
nothing; please add resendAfterSeconds, codeLength so the 30 s timer is server-driven |
login_screen, otp_screen (Resend) |
Blocking |
| 2 | signUp({name, phone, email?}) |
name ≥ 2 chars, phone E.164, email optional |
nothing — creates the account and sends the code | signup_screen |
Blocking |
| 3 | verifyOtp(identifier, code, {name}) |
identifier, 4-digit code, name only on signup |
Customer{id, name, phone, email} — email may be "" but never null. Please add accessToken, refreshToken, expiresIn |
otp_screen → AppState.verifyOtp |
Blocking |
Not yet seamed: token refresh, GET /me, logout. Session persistence is
unbuilt and waits on these.
Catalogue
| # | Method | Sends | Must return | Consumed by | Status |
|---|---|---|---|---|---|
| 4 | getServiceableStates() |
— | List<ServiceArea> · code, name, districtCount (available districts only — the app hides a state at 0), transitTag? ≤ 22 chars |
Step 2 state picker (destination_screen) |
Blocking |
| 5 | getServiceableDistricts(stateCode) |
stateCode |
List<District> · code, name, available, note?, hub?, promise?, lat/lng (hub position — the route map draws to it). Return unavailable districts too; the client filters them but uses the names for "Coming soon" |
Step 2 district picker | Blocking |
| 6 | getPickupSlots({pickup}) |
pickup lat/lng |
List<PickupSlot> · id, day, window, available, note?, tag?, milersNearby, caption?. day/window are server-formatted IST display strings |
Step 3 (slot_screen) |
Blocking |
| 7 | getBookingLimits() |
— | maxPackages, maxDestinations |
Step 2 caps, add-destination button | Blocking (safe defaults 20/5 on failure) |
Location
| # | Method | Sends | Must return | Consumed by | Status |
|---|---|---|---|---|---|
| 8 | reverseGeocode(lat:, lng:) |
lat, lng under the pin |
Place{title, sub, lat, lng} — title primary line ≤ 32 chars, sub supporting street/city. Both required |
Pickup card address box | Blocking · hottest call in the flow — fires on every map settle. p95 < 300 ms, cache by rounded coordinate |
| 9 | searchPlaces(query) |
query (empty ⇒ recent/saved, ≤ 4), bias to lat/lng |
List<Place> — lat/lng are mandatory; a result without them cannot become a pin |
Place search sheet | Blocking |
Booking
| # | Method | Sends | Must return | Consumed by | Status |
|---|---|---|---|---|---|
| 10 | estimateFare({pickup, destinations}) |
pickup lat/lng; per destination stateCode, districtCode, packageCount |
FareEstimate{min, max, paymentMethod, parcel} — ints in ₹, combined for the whole visit |
Review total, receipt lines | Blocking (a failure must not block booking) |
| 11 | createBooking({pickup, destinations, slotId, fare}) |
pickup (title, sub, lat, lng), slotId, destinations[] (stateCode, districtCode, packageCount, optional details), shown estimate |
Booking with reference (DM-######), stage: booked, status: active, cancellable: true, one-entry history, no trackingId anywhere |
Review → Confirmed | Blocking · needs Idempotency-Key |
| 12 | cancelBooking(reference, reason) |
reference, reason? (5 presets or free text, may be null) |
nothing; 409 once past arrived |
Cancel sheet, Tracking footer | Blocking |
| 13 | isCancellable(stage) |
— (pure policy mirror) | true through arrived |
Tracking footer | Blocking · server re-checks regardless |
Reading back
| # | Method | Sends | Must return | Consumed by | Status |
|---|---|---|---|---|---|
| 14 | getBookings({status}) |
status?, limit, cursor |
List<Booking>? — null means "keep the local copy", which is all the mock does. Return rows and AppState adopts them wholesale |
Home "Recent", Orders tabs, pull-to-refresh | Wired |
| 15 | getBooking(reference) |
reference |
Booking? — same null contract. Full object per §9.3 of the requirements doc |
Tracking (on open), Order details | Wired · this is what retires the debug stage stepper |
| 16 | slotById(id) |
— (sync) | PickupSlot for a booking's window |
Review, Confirmed, Tracking, Order details, Home card, Cancel sheet | ⚠️ Design gap — served from whatever getPickupSlots last returned. A booking made yesterday cannot name its window. Fix by putting slot{day, window} on the booking object, then this method disappears |
| 17 | districtByCode(code) |
— (sync) | District with lat/lng for the route map |
Route map on Review, Confirmed, Tracking | Falls back to a built-in centroid table until #5 sends coordinates |
Delete at go-live
| # | Item | Why |
|---|---|---|
| 18 | newReference(), newTrackingId() |
Ids are minted by the backend — DM-###### at booking, DMX######## per destination at order_created |
| 19 | ApiFlags + the Account ▸ Prototype controls |
Empty / error / slow simulation. Replace with real staging error injection |
| — | AppState.seedDemoOrders(), _applyStage side effects |
Every side effect in _applyStage (assigning a Miler, minting tracking ids, settling amountPaid, capturing weight and photos) is something the backend performs. It exists so the prototype can show each stage; it must not survive real status events |
| — | _StageStepper on the tracking screen |
The stand-in for push updates. Deleting it is the only client change once #15 and push land |
Not seamed at all yet
These have no mock method to replace — they need new client work alongside the backend, and are listed so nobody assumes they are covered:
- Push registration (
POST /devices) and notification handling — no seam. - Deep links —
doormile://track/…has no intent filter on Android and no URL types on iOS. - Session persistence — no token storage; depends on auth #3.
- Delivery details write-back (
PATCH …/destinations/{i}) — the sheet edits the draft in memory; there is no method to push a post-booking edit. - Parcel photos —
ParcelVerification.photosare rendered as placeholder tiles; they need fetchable signed URLs before the receipt shows real images.
Contract conformance pass — 8 Sep 2026
Audited against the customer API document (28 routes, /customer/*). The seams
were all in place; what was wrong was the wire shape inside them, which is
the class of mistake that costs nothing at compile time and a whole feature at
runtime. Fixed here:
| What | Was | Now |
|---|---|---|
POST /auth/otp/verify |
sent code |
sends otp. Nothing verified before this |
POST /fare/estimate |
sent lat/lng + packageCount; read min/max |
sends latitude/longitude + packages[]; reads minRupees/maxRupees/routeKm. No price could ever have been shown |
POST /bookings |
nested details{}, lat/lng, no contact, no remarks, extra estimate |
flat destinations, latitude/longitude, contactName/contactPhone from the session, per-destination notes joined into remarks |
| Booking response | destination.label gated on stateCode/districtCode, which the response does not send |
reads the names. Every read-back destination rendered as — before |
pickup.latitude |
read only lat |
reads either. The tracking map had no pin |
error.code |
compared capitals to lowercase constants | translated by ApiException.normalise. UNAUTHORIZED did not satisfy isAuthFailure, so the token refresh never fired and every session died at one hour |
isCancellable |
<= arrived |
< arrived. arrived is the contract's cutoff; offering Cancel there invites a BOOKING_NOT_CANCELLABLE the app then has to explain |
| List envelopes | data had to be a bare array |
also read through data: { items: [...] }, cursor and total from either level |
maxCodAmount, codAmount, destination index |
dropped on the floor | parsed and carried. No screen sets a COD amount yet — see below |
POST /ops/bookings/{ref}/stage |
not wired at all | DoormileApi.setStage, driving the tracking stepper on a staging build (--dart-define=DM_ALLOW_STAGE_OVERRIDE=true) |
GET /pickup-slots |
no revalidation | ETag, keyed per zone |
GET /places/search |
sent q only |
biases by the pickup coordinates |
Open with the backend
packages[].weightKgon the fare estimate. This app never asks the customer what a parcel weighs — the Miler weighs it at the door, which is when the price settles — so it sends one unweighted entry per package. If the field is mandatory, the estimate needs a documented default rather than a number the client invents.- The saved-locations body (
POST/PUT /customer/locations) is not in the document. It is sent as{title, sub, latitude, longitude, label}, matching the coordinate spelling used everywhere the contract is explicit. - List envelopes. Whether a paged route answers with a bare
dataarray ordata: { items, nextCursor }is not specified; the client reads both. - COD.
codAmountis parsed and echoed but no screen offers to set one, andmaxCodAmountdefaults to0= not offered. Needs a product decision before it is more than a field.
Still not wired, and not a contract problem
GET /orders/{trackingId}— implemented, but nothing calls it: there are no deep links (no Android intent filter, no iOS URL types).POST/DELETE /devices— implemented, but no push SDK produces a token.PATCH /bookings/{ref}/destinations/{i}— implemented throughAppState.saveDestinationDetails, but the details sheet only edits the draft. There is no post-booking edit screen to call it from.
Mock removal — 8 Sep 2026
The "Delete at go-live" list above is done, and then some. lib/ now contains
one API implementation.
Removed from lib/ |
Where it went |
|---|---|
data/mock_doormile_api.dart |
test/support/fake_doormile_api.dart, injected via DoormileApi.overrideInstance. Not compiled into the app |
AppConfig.useMock / DM_MOCK / isTest / showPrototypeControls |
gone — there is no define that serves invented data |
AppConfig.skipAuth / DM_SKIP_AUTH and its "Dev Build" customer |
gone — the app is signed into by signing in |
ApiFlags and Account ▸ Prototype controls |
FakeFlags on the test double |
DoormileApi.isMock, newReference(), newTrackingId() |
gone — the server owns both identifiers |
AppState.seedDemoOrders() / resetDemo() / advanceTo() / _applyStage() |
the fake's own _seed() and applyStage(). The app can no longer move a booking's stage at all |
AppState.setFlag() / flag() |
gone |
ui/screens/dev_entry.dart (DM_START) |
deleted — it fabricated a signed-in customer and a pickup point |
getBookings() |
gone — dead since refreshOrders moved to getBookingPage |
AppState.refreshOrders now adopts every page it is given, empty ones included.
The client keeps no booking the backend has not sent.
Invented defaults, also removed
These were the quieter half: fields that read as facts on screen but were client-side constants when the backend said nothing.
| Was | Now |
|---|---|
Person.rating = 4.9, trips = 1240, vehicleType = 'E-Scooter' |
nullable. The "4.9 · 1240+ pickups" line and the vehicle type are absent unless the backend sent them — a default put an invented reputation on a real person's card |
Booking.routeKm = 6.4 → "6.4 km" on the receipt |
nullable; the Distance row is dropped |
Booking.milersInZone = 3 → "3 verified Milers active in your zone" |
defaults to 0 and the banner is not shown |
fare.parcel ?? 'Standard box', fare.paymentMethod ?? 'UPI' |
nullable; both rows dropped |
Account's customer?.name ?? 'Joe Oommen' |
empty |
| Parcel photos drawn as a placeholder tile | Image.network when the reference is a fetchable URL, falling back to the frame |
| "not part of this prototype" | "not available yet" |
What this does not fix
A booking created here reaches the backend and the admin console's booking list.
Its badge still stalls at picked, because the console derives it from
mapBookingStatusToDeliveryStatus(booking.status) and booking status ends at
Converted_To_Consignment — the delivery half of the lifecycle lives on the
consignment, exactly as §4 of the customer API document marks in_transit,
out_for_delivery and delivered as Per Destination. That is a console-side
join (getConsignments() by booking.consignmentid) and cannot be fixed from
this app.