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>
This commit is contained in:
187
docs/API_READINESS.md
Normal file
187
docs/API_READINESS.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user