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:
2026-09-15 16:07:33 +05:30
parent 6c7d656de5
commit 0d66627c3c
305 changed files with 21930 additions and 11056 deletions

187
docs/API_READINESS.md Normal file
View 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.

View File

@@ -0,0 +1,782 @@
# Doormile — Backend Requirements (Customer App v1)
**Audience:** backend team
**Client:** `doormile_cx` — Flutter customer app, Android/iOS, app id `in.doormile.customer`
**Client status:** UI complete, `flutter analyze` clean, 20 widget tests green. It runs
entirely against an in-app mock (`lib/data/doormile_api.dart`) — every value on every
screen comes from that one file. This document specifies the service that replaces it.
**Match these shapes and no screen changes are required.**
**Read §2 first.** A production Doormile backend already exists and the Miler app is
live against it. The customer app is a *new namespace on that same service*, not a new
system, and it must read the records the Miler app writes. Anything that treats the
customer API as greenfield will produce two databases and a reconciliation problem.
---
## 1. The product model you are implementing
This is not the usual courier model. Read this before the endpoints.
> **The customer books a pickup, not a shipment.**
```
Pickup booking (reference: DM-482913)
├── destination 1 : Chennai, Tamil Nadu · 2 packages
├── destination 2 : Ernakulam, Kerala · 1 package
└── destination 3 : Bengaluru Urban, KA · 1 package
│
the Miler collects everything in ONE visit
│
▼
Orders are created HERE — one per destination
order 1: DMX10482913 order 2: DMX10559120 order 3: DMX10662004
each with its own tracking number and its own journey
```
Consequences you must honour:
1. **No tracking number exists at booking time.** `POST /customer/bookings` returns a
`reference` only. Tracking numbers are minted **per destination** when the Miler
completes pickup (stage `order_created`).
2. **One booking → 1..N destinations → 1..N orders.** A single-destination booking is
the common case and must not be a special case in the schema.
3. **Weight is never collected from the customer.** The Miler weighs and photographs
each package at the door — that is also when the **price settles**. Everything the
customer sees before then is an estimate range.
4. **Only state + district are required per destination.** Street, building, landmark,
recipient name/phone and instructions are optional at booking time and may be
completed by the Miler at pickup. The UI shows missing ones as *"Not added — the
Miler can confirm this at pickup."*
5. **Doormile is the carrier, not the seller.** The goods belong to the customer or to
the customer's own buyer. Any money at the door is the customer's, collected on
their behalf. Never write copy or schema that treats Doormile as the merchant.
6. **Cancellation is whole-pickup only**, and only up to and including `arrived`.
### Glossary
| Term | Meaning |
| --- | --- |
| **Miler** | The agent who collects from the customer's door. Also used for the delivery agent. |
| **Pickup booking** | What the customer creates. Identified by `reference`, format `DM-######`. |
| **Destination (group)** | One place inside a booking + how many packages go there. |
| **Order** | Created per destination at pickup completion. Identified by `trackingId`, format `DMX########`. |
| **Consignment** | The existing backend's name for what the customer calls an order (delivery leg). |
| **Stage** | Operational status, 9 values (§8). |
| **Milestone** | The rolled-up status the customer sees, 7 values (§8). |
---
## 2. Where this fits: the backend that already exists
The Miler (rider) app runs against **`https://api.doormile.com/api/v1`** with a bearer
JWT, and uses ~40 endpoints, all under `/miler/*`:
```
/miler/login /miler/verify-pin /miler/profile
/miler/duty/current /miler/duty/start /miler/duty/end
/miler/assignments /miler/assignments/{id}/accept | /reject
/miler/bookings /miler/bookings/status
/miler/bookings/{id}/reached /miler/bookings/{id}/parcel
/miler/bookings/{id}/pickup-complete /miler/bookings/{id}/payment
/miler/bookings/{id}/addresses /miler/bookings/{id}/cancel | /skip
/miler/consignments/{id} /miler/consignments/{id}/start-delivery
/miler/consignments/{id}/deliver /miler/consignments/{id}/inward-at-hub
/miler/location /miler/notifications /miler/device-token /miler/support …
```
**Therefore:**
- The customer API is a **sibling namespace, `/customer/*`, on the same host and the
same `booking` / `consignment` records.** A customer booking must become the same
row the Miler app later reads from `/miler/bookings`; a customer "order" must be the
same row as a `consignment`.
- **§9 ("what ops must be able to write") is mostly already built.** `reached`,
`parcel`, `pickup-complete`, `start-delivery`, `deliver`, `inward-at-hub` already
exist. What is missing is not the writes — it is the **derivation of the customer's
9 stages from those writes** (§8.3) and the read endpoints that expose them.
- Conventions below are **the real ones**, taken from the live service, not invented.
### 2.1 Corrections to the previous draft of this document
The earlier version of this file was written before the live backend was inspected. If
you were given that version, these four things were wrong:
| Was written | Reality |
| --- | --- |
| Base URL `https://api.doormile.in/v1` | `https://api.doormile.com/api/v1` |
| Envelope `{ "data": … }` | `{ "success": bool, "data": …, "message": string }`; lists add `total` |
| A greenfield ops/"Miler app" section | Those endpoints exist; only the customer projection is missing |
| Timestamps assumed epoch millis end-to-end | The live service sends **naive IST wall-clock strings**, sometimes with a spurious trailing `Z` (§3.3) |
---
## 3. Conventions
| Item | Requirement |
| --- | --- |
| Base URL | `https://api.doormile.com/api/v1` + a staging host with the same contract |
| Namespace | **`/customer/*`** for everything in this document |
| Transport | HTTPS only, TLS 1.2+ |
| Format | JSON, UTF-8 |
| Success envelope | `{ "success": true, "data": <payload>, "message": "" }` |
| List envelope | `{ "success": true, "data": [...], "total": <int>, "nextCursor": <string|null> }` |
| Error envelope | `{ "success": false, "message": "<customer-safe English>", "error": { "code": "…" } }` |
| Auth | `Authorization: Bearer <accessToken>` on everything except §4.1–§4.3 |
| Money | Integer **rupees**, not paise. `min: 49` renders as `₹49` |
| Client headers | `X-Client: doormile-cx/<version>+<build>`, `X-Platform: android\|ios` — accept and log |
| Request id | Echo `X-Request-Id` on every response, including errors |
| Display strings | Any human string (`day`, `window`, `expectedDelivery`) is formatted **server-side in IST** |
### 3.1 Envelope consistency — one thing not to copy
`/miler/verify-pin` returns its payload **outside** `data`, as
`{success, token, user:{…, profile:{…}}}`. That inconsistency cost the Miler client a
release to discover. **Do not repeat it here.** Every `/customer/*` response — auth
included — puts its payload in `data`.
### 3.2 Field naming
The `/miler/*` surface uses lowercase run-together keys (`bookingid`, `createdat`,
`slotstarttime`). The customer client models are **camelCase** (`districtCode`,
`packageCount`, `trackingId`).
**Preferred: serve `/customer/*` in camelCase** exactly as specified below. If your ORM
makes that expensive, say so — the client will add one mapping layer — but decide now
and freeze it, because a mixed-case response is what forces the client to guess.
### 3.3 Timestamps — decide this explicitly
The client currently parses `DateTime.fromMillisecondsSinceEpoch(int)`.
The live service emits naive IST wall-clock strings, occasionally suffixed `Z` for a
timezone they are not in.
- **Preferred:** `/customer/*` sends **epoch milliseconds, UTC, integer** for
`createdAt`, `history[].at`, `verification.capturedAt`, `deliveredAt`.
- **If you cannot:** send **ISO-8601 with a real offset** (`2026-09-04T14:30:00+05:30`).
Tell us and the client adds a tolerant parser.
- **Never** send a naive local string with a `Z` on it. That is a wrong instant, not a
formatting nit, and it has already produced "yesterday's work shown as today" bugs on
the Miler app.
### 3.4 Error contract
Every failure returns the error envelope. The app funnels all failures into one error
state with a Retry, and shows `message` **verbatim** — so it must be customer-safe
English, never an enum key, stack trace or HTML page.
| HTTP | `error.code` | When | `message` shown |
| --- | --- | --- | --- |
| 400 | `invalid` | Validation failed | "Every destination needs a serviceable state and district" |
| 400 | `invalid_name` | Name < 2 chars at signup | "Enter your full name" |
| 401 | `invalid_otp` | Wrong or expired code | "That code did not match" |
| 401 | `unauthorized` | Missing/expired access token | "Please sign in again" |
| 403 | `forbidden` | Token valid, resource not the caller's | "You do not have access to this" |
| 404 | `not_found` | Unknown reference / state / order | Context-specific |
| 409 | `conflict` | Cancelling after the window; slot filled | "This pickup can no longer be cancelled" |
| 422 | `unserviceable` | District closed between selection and booking | "That district is no longer available" |
| 429 | `rate_limited` | Throttled; include `Retry-After` | "Too many attempts. Try again in a minute" |
| 5xx | `server_error` | Anything else | "Something went wrong" |
| — | `network` | Client-side only | "We could not reach Doormile" |
### 3.5 Non-functional
- **Latency:** p95 ≤ 400 ms for every GET in §5 (each sits behind a loading skeleton);
p95 ≤ 1.2 s for `POST /customer/bookings`.
- **Idempotency:** `POST /customer/bookings` and `POST /customer/auth/otp/verify` must
accept `Idempotency-Key` and replay the original response for 24 h. The client
retries on flaky networks; duplicate pickups are unacceptable.
- **Caching:** serviceability supports `ETag`/`If-None-Match`. Slots are volatile —
`Cache-Control: max-age=30` at most.
- **Rate limits:** OTP request ≤ 5 per number per hour; ≤ 3 verify attempts per code.
- **Pagination:** `?limit=&cursor=`, default 20, newest first, `nextCursor` in the
envelope.
- **PII:** phone, email, recipient name/phone and delivery instructions are PII.
Encrypt at rest, redact in logs, never leak across customers.
- **Audit:** every stage transition recorded with actor (miler id / ops user / system),
timestamp and source. The customer timeline is derived from this, so it must be real.
- **Tenancy:** the JWT already carries a `tenantid` claim. Customer tokens must carry
it too, and every read must be tenant-scoped server-side.
---
## 4. Auth & session
4-digit OTP over phone (primary) or email. **No password anywhere in the app.**
Note this is deliberately *not* the Miler's phone+PIN flow — do not reuse `verify-pin`.
Phone is currently sent as `+91 98765 43210` (with spaces). **Normalise to E.164
server-side and accept both**; the client will be tightened to send E.164.
### 4.1 `POST /customer/auth/otp/request`
```jsonc
// request
{ "identifier": "+919876543210" } // or "you@example.com"
// 200
{ "success": true, "data": { "sent": true, "resendAfterSeconds": 30, "codeLength": 4 } }
```
- The resend countdown is server-driven; the client stops hardcoding 30 s once this ships.
- Code TTL 5 minutes, single use.
- Errors: `rate_limited`, `invalid`.
### 4.2 `POST /customer/auth/signup`
```jsonc
// request
{ "name": "Joe Oommen", "phone": "+919876543210", "email": "joe@example.com" } // email optional
// 200 — creates the account AND sends the OTP
{ "success": true, "data": { "sent": true, "resendAfterSeconds": 30 } }
```
- `name` ≥ 2 chars, else `invalid_name`.
- If the phone already exists, **do not error** — treat it as a sign-in and send the
code. The UI has no "account exists" state. Object now if you disagree.
### 4.3 `POST /customer/auth/otp/verify`
```jsonc
// request
{ "identifier": "+919876543210", "code": "4821", "name": "Joe Oommen" } // name only on signup
// 200
{
"success": true,
"data": {
"accessToken": "…", "refreshToken": "…", "expiresIn": 3600,
"customer": { "id": "cust_10241", "name": "Joe Oommen",
"phone": "+919876543210", "email": "joe@example.com" }
}
}
```
- `name`, `phone`, `email` are all rendered on Account and drive the avatar initials.
- **`email` must not be null** — the client types it as `String`. Send `""` if unknown.
- Errors: `invalid_otp`.
### 4.4 `POST /customer/auth/refresh` · `POST /customer/auth/logout` · `GET /customer/auth/me`
- Refresh: `{ "refreshToken": "…" }` → new access token; rotation preferred; refresh TTL
60 days so the customer stays signed in.
- Logout: revokes the refresh token **and** unregisters the device push token.
- `GET /customer/auth/me` → the same `customer` object, for cold-start session restore.
Session persistence is client work (§Appendix B) but it cannot start until refresh
exists. Ship §4.3 and §4.4 together.
---
## 5. Catalogue & configuration
These four drive the entire booking form. **Highest priority — nothing else works
without them.**
### 5.1 `GET /customer/serviceability/states`
```jsonc
{ "success": true, "data": [
{ "code": "TN", "name": "Tamil Nadu", "districtCount": 6, "transitTag": "Ultra-fast transit" },
{ "code": "KL", "name": "Kerala", "districtCount": 3, "transitTag": "Next-day transit" },
{ "code": "PY", "name": "Puducherry", "districtCount": 0, "transitTag": "Opening soon" }
]}
```
| Field | Type | Req | Notes |
| --- | --- | --- | --- |
| `code` | string | yes | Stable; stored on the booking |
| `name` | string | yes | Display name |
| `districtCount` | int | yes | **Count of `available` districts only.** The client hides any state with `0` |
| `transitTag` | string | no | ≤ 22 chars |
Return `[]` when nothing is serviceable — the app has a designed "no service" state.
### 5.2 `GET /customer/serviceability/states/{stateCode}/districts`
```jsonc
{ "success": true, "data": [
{ "code": "TN-CBE", "name": "Coimbatore", "available": true,
"hub": "Coimbatore Central Hub", "promise": "Next-day delivery",
"lat": 11.0168, "lng": 76.9558 },
{ "code": "TN-MDU", "name": "Madurai", "available": false, "note": "Opening soon",
"lat": 9.9252, "lng": 78.1198 }
]}
```
| Field | Type | Req | Notes |
| --- | --- | --- | --- |
| `code` / `name` | string | yes | Stable code + display name |
| `available` | bool | yes | `false` districts are not offered |
| `note` | string | no | Why not: "Opening soon", "Paused this week" |
| `hub` | string | no | Serving hub, shown on the destination card |
| `promise` | string | no | "Next-day delivery" / "2-day delivery" |
| `lat` / `lng` | number | no | **Where the hub is.** The route map draws pickup → hub from these. Omit them and the map draws the pickup end only — no line, no destination marker |
**Return the full list including unavailable districts** — the client filters them out
of the picker but uses their names for a quiet "Coming soon" line.
Unknown `stateCode` → `404 not_found`, "That state is no longer serviceable".
### 5.3 `GET /customer/pickup-slots?lat=&lng=`
```jsonc
{ "success": true, "data": [
{ "id": "slot_t_1", "day": "Today", "window": "2:00 – 4:00 PM", "available": true,
"tag": "Fastest pickup", "milersNearby": 4, "caption": "Arriving in approx. 45 mins" },
{ "id": "slot_t_3", "day": "Today", "window": "6:00 – 8:00 PM", "available": false,
"note": "Fully booked" }
]}
```
| Field | Type | Req | Notes |
| --- | --- | --- | --- |
| `id` | string | yes | Opaque; sent back as `slotId` |
| `day` | string | yes | "Today" / "Tomorrow" / "Mon, 8 Sep" — **server-formatted, IST** |
| `window` | string | yes | "2:00 – 4:00 PM" (en dash, spaced) |
| `available` | bool | yes | Capacity remaining in the customer's zone |
| `note` | string | no | "Fully booked" |
| `tag` | string | no | At most one slot carries "Fastest pickup" |
| `milersNearby` | int | no | 0 hides the line |
| `caption` | string | no | "Arriving in approx. 45 mins" |
Slots are **capacity- and location-aware** — use the `lat`/`lng` of the pickup point.
Roughly today + tomorrow; the design expects ~6 windows.
The slot ids must resolve to whatever the Miler assignment engine consumes — a customer
slot that ops cannot staff is worse than no slot.
### 5.4 `GET /customer/config/booking-limits`
```jsonc
{ "success": true, "data": { "maxPackages": 20, "maxDestinations": 5 } }
```
Nothing in the UI hardcodes these; they exist so ops can vary them by city or tier
without an app release. Must degrade safely (client keeps 20/5 on failure). Never `0`.
If they become per-city, key them off the pickup location and tell us — the client will
re-fetch when the pickup point moves.
---
## 6. Location
Maps in the customer app are real as of this revision: `flutter_map` over
OpenStreetMap/CARTO raster tiles, and `geolocator` for the device fix. No key is held
by the client, which matches the decision already taken on the Miler side. The
coordinates reaching these endpoints are real GPS coordinates.
### 6.1 `GET /customer/places/reverse-geocode?lat=&lng=`
```jsonc
{ "success": true, "data": { "title": "12 Nehru Street",
"sub": "Gandhipuram, Coimbatore 641012", "lat": 11.0183, "lng": 76.9725 } }
```
`title` = short label (≤ 32 chars), `sub` = full address line. **Both required, neither
nullable.** Used to pre-fill the pickup point from the device location.
**This is the hottest endpoint in the booking flow.** The pickup screen puts the pin at
the centre of the map and moves the map under it; every time the map comes to rest, this
is called and the answer is what the customer reads in the box below. Target **p95 under
300 ms** and cache by rounded coordinate (~5 decimal places is more precision than any
door needs). The client already debounces 320 ms after the map settles and discards a
response that a later drag has superseded, so you will not get a request per frame — but
you will get one per correction, and each one is on the critical path to "Next".
### 6.2 `GET /customer/places/search?q=&lat=&lng=`
```jsonc
{ "success": true, "data": [ { "title": "Brookefields Mall",
"sub": "Brookebond Road, Coimbatore 641001", "lat": 10.9987, "lng": 76.9628 } ] }
```
- Empty `q` → the customer's recent/saved places (≤ 4). The search sheet opens on this.
- Bias to `lat`/`lng` and to serviceable areas.
- **Proxy the geocoder through the backend** — the Miler app's Google Maps key was
revoked and that side now runs on OSM/OSRM with no key. Do not hand the customer app
a key to hold; serve results and cache them.
---
## 7. Fare estimate
### `POST /customer/fare/estimate`
```jsonc
// request
{ "pickup": { "lat": 11.0183, "lng": 76.9725 },
"destinations": [
{ "stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 2 },
{ "stateCode": "KL", "districtCode": "KL-EKM", "packageCount": 1 } ] }
// 200
{ "success": true, "data": {
"min": 167, "max": 267,
"paymentMethod": "UPI · Cash at doorstep",
"parcel": "3 boxes (up to 3 kg each)",
"routeKm": 6.4 } }
```
| Field | Type | Notes |
| --- | --- | --- |
| `min` / `max` | int (₹) | Rendered "₹167 – ₹267". An **estimate range**, never final |
| `paymentMethod` | string | Shown as-is on Review and the receipt |
| `parcel` | string | What is being priced, e.g. "Standard box (up to 3 kg)" |
| `routeKm` | number | Pickup→destination distance, drives the route outline and receipt |
- Called on every route or package-count change — must be cheap and cacheable.
- A failed estimate **must not block booking**; the client swallows the error.
- Must be the **combined** price for one visit, including multi-stop uplift.
---
## 8. Stages, milestones and how they are derived
### 8.1 The nine stages
The backend owns nine operational stages; the customer sees seven milestones. The
rollup happens on the client — **always send the raw stage key**, lowercase snake_case,
spelled exactly as below. An unknown key silently falls back to `booked` on the client,
so new keys require a client release.
| # | Stage key | Customer milestone | Must also be true |
| --- | --- | --- | --- |
| 0 | `booked` | Pickup booked | Set by `POST /customer/bookings` |
| 1 | `assigned` | Miler assigned | `miler` object present |
| 2 | `on_the_way` | Pickup in progress | `milerDistanceKm`, `milerEtaMinutes` set |
| 3 | `arrived` | Pickup in progress | Distance 0, ETA 0. **Last cancellable stage** |
| 4 | `picked_up` | Package collected | `verification` populated; `amountPaid` settles |
| 5 | `order_created` | Package collected | **`trackingId` minted per destination**; `expectedDelivery` set |
| 6 | `in_transit` | In transit | Per-order from here on |
| 7 | `out_for_delivery` | Out for delivery | `deliveryAgent` present |
| 8 | `delivered` | Delivered | `deliveredAt` set; booking `status` → `completed` |
- **Stages 0–5 belong to the booking; 6–8 belong to each order** and may differ between
destinations of the same booking. The client already renders independent journeys.
- The timeline is built from a **history array** — one entry per stage actually reached,
with the real timestamp. **Do not synthesise or backfill times.** (The mock does; that
is a prototype affordance, not a spec.)
- `status` (`active` / `completed` / `cancelled`) is derived but must be sent
explicitly. Do not make the client infer it.
### 8.2 Milestone rollup (client-side, for your reference)
```
booked → Pickup booked
assigned → Miler assigned
on_the_way | arrived → Pickup in progress
picked_up | order_created → Package collected
in_transit → In transit
out_for_delivery → Out for delivery
delivered → Delivered
```
### 8.3 Derivation from the writes that already exist ← **the actual work**
| Existing Miler write | Customer stage it must produce |
| --- | --- |
| assignment created / `POST /miler/assignments/{id}/accept` | `assigned` (+ `miler` from the accepting rider) |
| rider goes on-route (duty + location stream) | `on_the_way` (+ distance/ETA from `/miler/location`) |
| `POST /miler/bookings/{id}/reached` | `arrived` — **cancellation closes here** |
| `POST /miler/bookings/{id}/parcel` (weight + photos) | populates `verification` |
| `POST /miler/bookings/{id}/pickup-complete` | `picked_up`, then `order_created`; **mint one consignment + `trackingId` per destination**; settle `amountPaid` |
| `POST /miler/consignments/{id}/inward-at-hub` | `in_transit` (per order) |
| `POST /miler/consignments/{id}/start-delivery` | `out_for_delivery` (+ `deliveryAgent`) |
| `POST /miler/consignments/{id}/deliver` | `delivered`, `deliveredAt`, booking → `completed` |
| `POST /miler/bookings/{id}/cancel` \| `/skip`, or ops cancel | `cancelled` + `cancelReason` |
If any of these does not currently emit an event the customer projection can read, that
is the gap to close first.
### 8.4 Known gaps in the existing backend that block this
Found while integrating the Miler app; each one has a customer-visible consequence:
| Gap | Customer-app consequence |
| --- | --- |
| Booking carries no `assignmentid` / `consignmentid` (the app assumes `== bookingid`) | Multi-destination bookings cannot mint N orders; §1 breaks |
| No COD / collection amount on the booking object | `amountPaid` and the receipt cannot settle |
| No per-stop `type` (pickup vs delivery) or `step` ordering | Stages 6–8 cannot be attributed to the right order |
| No skip/resume and no customer-side cancel endpoint | §9.4 cannot be built |
| Parcel weight/photos not exposed on any read | `verification` block stays empty; the receipt loses its evidence |
---
## 9. Bookings
### 9.1 `POST /customer/bookings` — create the pickup
```jsonc
// request (Idempotency-Key header required)
{
"pickup": { "title": "12 Nehru Street", "sub": "Gandhipuram, Coimbatore 641012",
"lat": 11.0183, "lng": 76.9725 },
"slotId": "slot_t_1",
"destinations": [
{ "stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 2,
"details": { // every field optional, may be omitted entirely
"street": "12th Main", "building": "3B", "landmark": "Near bus stand",
"recipientName": "Meera S", "recipientPhone": "+919884412210",
"instructions": "Call before delivery",
"pin": { "lat": 13.0827, "lng": 80.2707 } } },
{ "stateCode": "KL", "districtCode": "KL-EKM", "packageCount": 1 }
],
"estimate": { "min": 167, "max": 267 } // what the customer was shown, for dispute audit
}
```
Server-side validation — mirror exactly, the client displays your `message`:
| Rule | Error |
| --- | --- |
| ≥ 1 destination, each with serviceable `stateCode` + `districtCode` | `400 invalid` — "Every destination needs a serviceable state and district" |
| `slotId` present and still available | `400 invalid` — "Pick a pickup slot" / `409 conflict` — "That pickup window just filled up" |
| `packageCount` ≥ 1, total ≤ `maxPackages` | `400 invalid` — "Up to 20 packages per pickup" |
| destinations ≤ `maxDestinations` | `400 invalid` — "Up to 5 destinations per pickup" |
| district still `available` | `422 unserviceable` |
Response: the **full booking object** (§9.3) with `reference` `DM-######`,
`stage: "booked"`, `status: "active"`, `cancellable: true`, a one-entry `history`, and
**no `trackingId` on any destination**.
### 9.2 `GET /customer/bookings?status=active|completed|cancelled&limit=&cursor=`
Backs the Orders tabs, Home's "Recent" (3 most recent non-active) and pull-to-refresh.
Array of booking objects, newest first.
### 9.3 `GET /customer/bookings/{reference}` — the canonical object
The single most important response in the API: the tracking screen and the receipt are
both rendered from it.
```jsonc
{ "success": true, "data": {
"reference": "DM-482913",
"stage": "in_transit",
"status": "active",
"cancellable": false,
"createdAt": 1757056800000,
"pickup": { "title": "12 Nehru Street", "sub": "Gandhipuram, Coimbatore 641012",
"lat": 11.0183, "lng": 76.9725 },
"slotId": "slot_t_1",
"destinations": [
{ "stateCode": "TN", "stateName": "Tamil Nadu",
"districtCode": "TN-MAA", "districtName": "Chennai",
"packageCount": 2,
"district": { "code": "TN-MAA", "name": "Chennai", "available": true,
"hub": "Chennai Guindy Hub", "promise": "Next-day delivery" },
"details": { "street": "12th Main", "recipientName": "Meera S",
"recipientPhone": "+919884412210" },
"trackingId": "DMX10482913", // null until order_created
"stage": "in_transit", // null until order_created
"verification": { // null until picked_up
"weightKg": 2.8,
"photos": ["https://cdn.doormile.com/pv/abc.jpg"],
"capturedAt": 1757060400000,
"capturedBy": "Arun Kumar" } }
],
"miler": { "name": "Arun Kumar", "vehicle": "TN 37 BX 4412", "phone": "+919000011223",
"rating": 4.9, "trips": 1240, "vehicleType": "E-Scooter" },
"deliveryAgent": null, // populated from out_for_delivery
"milerDistanceKm": null, // set during on_the_way / arrived
"milerEtaMinutes": null,
"milersInZone": 3, // shown while finding a Miler
"routeKm": 6.4,
"expectedDelivery": "Thu, 12 Sep", // server-formatted, IST
"fare": { "min": 49, "max": 64, "paymentMethod": "UPI · Cash at doorstep",
"parcel": "Standard box (up to 3 kg)" },
"amountPaid": 64, // null until picked_up
"deliveredAt": null,
"cancelReason": null,
"history": [
{ "stage": "booked", "at": 1757056800000 },
{ "stage": "assigned", "at": 1757057400000 },
{ "stage": "on_the_way", "at": 1757058900000 },
{ "stage": "arrived", "at": 1757059800000 },
{ "stage": "picked_up", "at": 1757060400000 },
{ "stage": "order_created", "at": 1757060460000 },
{ "stage": "in_transit", "at": 1757062200000 }
]
}}
```
Field notes that matter:
- `destinations[].stateName` / `districtName` are **required** — the client renders
"Chennai, Tamil Nadu" from them and does not look codes up.
- `pickup` and `slotId` must be **present on every booking, including cancelled ones** —
the client parser types them non-nullable and will throw on `null`.
- `verification.photos` must be **fetchable URLs** (signed, ≥ 15 min TTL), one per
package. `weightKg` is the number the price settled on; shown as "2.8 kg".
- `miler.phone` powers a **Call Miler** action. Real dialable number or masked-calling
proxy — say which. Masked preferred.
- `amountPaid` is the settled total in ₹, present from `picked_up`. The receipt renders
`amountPaid − fare.min` as "Weight adjustment", so **keep `fare` on the booking
forever**, even after settlement.
- `expectedDelivery` is a display string, formatted server-side in IST.
- `history` is append-only and ordered; every timestamp on the timeline comes from it.
### 9.4 `POST /customer/bookings/{reference}/cancel`
```jsonc
// request
{ "reason": "Package not ready" } // optional; free text or one of the 5 presets
// 200
{ "success": true, "data": { "reference": "DM-482913", "status": "cancelled",
"cancelReason": "Package not ready" } }
```
- Preset reasons in the UI: *Booked by mistake · Package not ready · Sending it another
day · Changed the destination · Other*. `reason` may be `null`.
- **Allowed only through `arrived`.** From `picked_up` onward return `409 conflict`.
The server is the authority; `cancellable` on the booking mirrors the same policy so
the UI can hide the button, but the server must re-check.
- Cancels the **whole pickup**, every destination. No partial cancellation in v1.
- The UI promises *"You can cancel free of charge until the Miler collects your
package."* — **confirm no fee applies in that window.**
### 9.5 `PATCH /customer/bookings/{reference}/destinations/{index}`
The customer can fill in street / landmark / recipient / instructions / map pin **after**
booking, up to collection.
```jsonc
{ "street": "12th Main", "landmark": "Near bus stand",
"recipientName": "Meera S", "recipientPhone": "+919884412210",
"instructions": "Call before delivery", "pin": { "lat": 13.08, "lng": 80.27 } }
```
- Any subset; `null` clears a field.
- Accept until `picked_up`; after that `409 conflict`.
- **These edits must reach the Miler app in near real time** — that app reads addresses
via `/miler/bookings/{id}/addresses`, so the write must land on the same record.
### 9.6 `GET /customer/orders/{trackingId}`
One order by tracking number, returning the booking object focused on that destination.
Needed for push deep links. A slimmer order object is acceptable if you prefer — say so;
the client currently reuses the booking shape.
---
## 10. Live updates
The tracking screen currently advances through a debug stepper. Production needs real
events. **Required for launch:**
1. **Push (FCM + APNs)** — one notification per customer-visible *milestone* change:
```jsonc
{ "type": "stage_change", "reference": "DM-482913",
"trackingId": "DMX10482913", // null before order_created
"stage": "out_for_delivery",
"title": "Out for delivery",
"body": "Arriving today at the delivery address.",
"deepLink": "doormile://track/DM-482913" }
```
- `POST /customer/devices` registers `{ token, platform, appVersion }`; logout
unregisters. (The Miler side already has `/miler/device-token` — mirror it.)
- Do **not** notify on every operational stage: `on_the_way` and `order_created` roll
up on the timeline. Agree the notification set with product.
- ⚠️ The client has **no deep-link intent filter yet** (§Appendix B) — send the
`deepLink` field from day one; the client will start honouring it.
2. **Polling fallback:** `GET /customer/bookings/{reference}` is polled while tracking
is open. Make it cheap; support `If-None-Match` → `304`.
3. **Phase 2, optional:** SSE/WebSocket for live Miler position while `on_the_way`.
The Miler app already streams to `/miler/location`, so the data exists. The customer
UI shows distance and ETA and will simply not update them without this.
---
## 11. Deliverables from the backend team
1. **OpenAPI 3.1 spec** for §4–§10 plus a Postman collection.
2. **Staging environment** seeded to mirror the mock, because these exact cases back our
widget tests and design QA: Tamil Nadu / Kerala / Karnataka open; **Puducherry
serviceable with no open district**; **Madurai / Kozhikode / Mangaluru unavailable
with a reason**; at least one fully-booked slot.
3. **Test accounts + a fixed staging OTP** (e.g. `1234`) so automated tests can sign in.
4. **A way to force a booking to any stage on staging** (ops endpoint or console button).
Every tracking state must be reachable for QA — this is what lets us delete the debug
stepper.
5. Documented error responses, ideally error injection, so the app's empty / error /
retry states can be verified against the real service.
6. **A written answer to §13.**
## 12. Suggested delivery order
| Phase | Endpoints | Unblocks |
| --- | --- | --- |
| **1** | §5.1 §5.2 §5.3 §5.4 | The whole booking form. Highest priority |
| **2** | §4 auth (request / verify / refresh / me) | Real sign-in + session persistence |
| **3** | §7 estimate, §9.1 create, §9.2 list, §9.3 detail | End-to-end booking |
| **4** | §8.3 stage derivation from the existing Miler writes | Real tracking; removes the debug stepper |
| **5** | §9.4 cancel, §9.5 details patch | Feature-complete v1 |
| **6** | §10 push, §6 places | Live updates and real pickup-point search |
Phase 4 is the one with hidden cost — it depends on §8.4 being closed first.
## 13. Open questions — please answer in writing
1. **Payment.** The app shows "UPI · Cash at doorstep" and an `amountPaid` but has no
payment step. Is v1 cash/UPI-at-the-door, settled outside the app? If an in-app
payment or payment link is coming, we need the contract now. Note the money at the
door may be the customer's own COD collection — confirm who owns that flow.
2. **Masked calling** for `miler.phone` — real number or proxy?
3. **Partial pickup.** What happens if the Miler collects some destinations but not all?
The model has no partial-pickup state today.
4. **Failed delivery / reattempt.** Does it exist operationally? There is no customer
screen for it, and no stage key. If it exists we need both.
5. **Saved addresses, notification preferences, payment methods** — rows exist on the
Account screen with no backend. In v1 scope? If yes we need
`GET/POST/DELETE /customer/addresses` and a preferences endpoint.
6. **Support.** "Need help with this order?" is a dead end today. Chat, ticket API
(the Miler side has `/miler/support`), or a phone number?
7. **Slot capacity semantics.** Is `available` per zone, per Miler count, or a hard cap?
It decides how often the client re-fetches before confirming.
8. **Pricing.** Who owns the estimate formula, and does the customer see a breakdown
when `amountPaid` exceeds `fare.max`?
9. **Cancellation fee** — confirmed free through `arrived`?
10. **Retention** for parcel photos and PII.
11. **Tenancy.** Does the customer app serve both the logistics line and the meal line,
or logistics only in v1? The Miler app switches its entire mode on `tenantid`.
---
## Appendix A — Client source of truth
| Contract element | File |
| --- | --- |
| Every model + `fromJson` | `lib/data/models.dart` |
| Every endpoint the app calls (currently mocked) | `lib/data/doormile_api.dart` |
| Stage side-effects the backend must perform | `AppState._applyStage`, `lib/state/app_state.dart` |
| End-to-end flow the contract must satisfy | `test/booking_flow_test.dart` (18 tests) |
To go live, each method body in `DoormileApi` becomes a network call and the return
types stay identical. No screen changes are needed.
## Appendix B — Client-side work to do alongside this
Not backend scope, listed so nobody plans around capabilities the app does not yet have:
- **`pubspec.yaml` still has no network dependency** — no `http`, no
`shared_preferences`, no push SDK. Those three sections need packages added first.
Maps and geolocation are no longer on this list: `flutter_map` (OpenStreetMap/CARTO
tiles, no key) and `geolocator` are in and working on both maps.
- `District.lat`/`lng` (§5.2) are parsed and drive the route map. Until the API sends
them the app falls back to a built-in table of district centroids.
- `Booking.fromJson` does not yet parse `miler`, `deliveryAgent`, `fare`, `verification`,
`history`, `amountPaid`, `deliveredAt`, `status`, per-destination `details` /
`district` / `stage` (§9.3).
- `Destination` and `DeliveryDetails` have no `fromJson`/`toJson` yet.
- No deep-link intent filter in `AndroidManifest.xml` and no URL types on iOS, so
`doormile://track/…` does nothing today (§10).
- Location permissions **are** declared now (`ACCESS_FINE_LOCATION` /
`ACCESS_COARSE_LOCATION`, `NSLocationWhenInUseUsageDescription`) and the pickup pin
comes from the real device fix; only the address behind it is still mocked.
- Session persistence is unbuilt — it depends on §4.4.
- The tracking screen's `_StageStepper` is the prototype stand-in for real events;
deleting it is the only client change needed once §10 lands.