Files
doormile_customer_app/docs/API_READINESS.md
Thiru-tenext 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

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 DoormileApi directly any more — screens go through AppState, 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) where message is customer-safe: DmAsyncList renders it verbatim with a Retry.
  • LocationService (device GPS) and DmMapConfig (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.photos are 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

  1. packages[].weightKg on 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.
  2. 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.
  3. List envelopes. Whether a paged route answers with a bare data array or data: { items, nextCursor } is not specified; the client reads both.
  4. COD. codAmount is parsed and echoed but no screen offers to set one, and maxCodAmount defaults to 0 = 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 through AppState.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.