Files
doormile_customer_app/README.md
Thiru-tenext 86b6af48c2 Redesign on the Stitch reference, in Plus Jakarta Sans
Four passes, and the shape they landed on.

The type face is Plus Jakarta Sans (variable, wght 200-800), which brings a
fix with it: it carries the rupee glyph and Switzer does not, so prices stop
being set in Geist Mono to work around a missing character. Mono stays where
it is earned - references and phone numbers, read digit by digit.

Surfaces lift rather than outline. Cards carry two very soft shadow layers
instead of a hairline, because eight outlined boxes down a screen read as a
wireframe. The tab bar floats as a pill again for the same reason it was
right to: it is now the same kind of object as everything above it.

Screens:

* Home is the greeting, the address, the sphere and one card. The card lost
  its progress bar - a filling line says "wait", and a parcel two days into
  a journey is not something anyone is waiting through - and gained the size
  that buys.
* Orders cards are four bands: identity, destination, route, and whatever is
  happening right now. Plus a search field, because the list is the archive.
* Tracking leads with the state at display size, then TRIP MILESTONES with a
  step counter, then the courier.
* Review is a route thread over two particular cards.
* Account opens on the person: avatar, name, and two counted figures.

Three real bugs the redesign surfaced:

* Quick dispatch handed `loadCities()` straight to a FutureBuilder, so the
  catalogue was refetched on every rebuild and Home never settled.
* Order cards showed the whole visit's weight on one destination's row -
  somebody else's parcel. Per group now, and only once actually weighed.
* The pickup window was printed beside "In transit", where it reads as a
  delivery time nobody promised.

Nothing invented. The reference shows EXPRESS PRIORITY, CARBON OFFSET,
CONCIERGE ELITE and hub-to-hub routing; this backend sends none of them, so
they are absent rather than mocked up.

flutter analyze: clean. flutter test: 88 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
2026-09-24 11:08:25 +05:30

409 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Doormile — Customer App
A Flutter app for the Doormile customer journey:
**Book pickup → Track → Delivered**
State is a `ChangeNotifier` behind an `InheritedNotifier`; navigation is
`Navigator` with named booking steps. Real maps come from `flutter_map` over
OpenStreetMap/CARTO tiles and real positions from `geolocator` — no API key and
no billing account required. Everything else is the Flutter SDK.
```
lib/
main.dart app entry, theme, page transitions
data/
models.dart Booking, Destination, DeliveryDetails, JourneyStage…
doormile_api.dart the service surface — one implementation
live_doormile_api.dart ← the ONLY place the app talks to a server
location_service.dart ← the ONLY place device GPS is touched
map_config.dart ← the ONLY place tile URLs, attribution + keys live
state/
app_state.dart single source of truth + stage transitions
app_scope.dart InheritedNotifier plumbing
ui/
tokens.dart colours, radii, type scale, shadows (Inter)
format.dart greeting / relative time / date labels
widgets/ buttons, chrome, inputs, option tiles, milestones…
map_tiles.dart config → flutter_map layer, + attribution
map_panel.dart real pannable map, pin locked to the centre
route_map.dart real route map: polyline, hubs, live Miler
screens/
auth/ login · sign up · OTP (shared brand scaffold)
shell_screen.dart Home · Orders · Account bottom navigation
home_screen.dart orders_screen.dart account_screen.dart
order_details_screen.dart receipt for a finished order
tracking_screen.dart live tracking, driven by stage
booking/ pickup → destination → time → review → confirmed
sheets/ place search, delivery details, cancel booking
store/ Play Console listing assets
```
## The product model this encodes
The customer creates a **pickup booking**, not a shipment.
Booking is four steps:
```
Home ▸ Book a Pickup
1. Pickup point map pin, pre-filled from the device location
2. Destination state → district, then "How many packages?" (defaults to 1)
"+ Send to another place" adds another destination
3. Pickup time windows the backend still has capacity for
4. Review one line per place, combined estimate, Book Pickup
```
**One pickup booking → one or more destinations → order(s) created after
collection.** A single Miler visit can collect packages bound for several
districts; each destination becomes its own tracked order only once the Miler
completes the pickup. The customer never sees carrier vocabulary — no
consignment, no waybill, no AWB.
* **Required:** pickup location, and for each destination a serviceable
**state** + **district**. Package count defaults to 1.
* **Optional:** exact street, landmark, recipient name and phone, delivery
instructions — per destination, in a sheet opened from the destination card
or Review. Never blocking, and shown as
*"Not added — the Miler can confirm this at pickup."*
* **Weight is never asked.** The Miler verifies it at pickup, which is when the
price settles.
* **Caps** come from `BookingLimits` (backend-served, defaults 20 packages /
5 destinations per pickup). Nothing in the UI hardcodes them.
* **Cancellation** is whole-pickup only, and only while
`DoormileApi.isCancellable(stage)` allows it.
A customer sending one package to one district sees exactly what they saw
before: pick a place, tap Next. The multi-destination machinery appears only
when they ask for it.
Stages shown to the customer (`JourneyStage`; raw backend keys never reach a
screen):
```
Pickup booked → Miler assigned → Miler on the way → Miler arrived →
Package picked up → Order created → In transit → Out for delivery → Delivered
```
**Cancellation** is offered only while `DoormileApi.isCancellable(stage)` is
true — through *Miler arrived*. Once the Miler has custody the footer becomes a
support action instead.
## Maps and location
Both maps are real. `flutter_map` renders OpenStreetMap data through CARTO's
basemaps — no API key, no billing, no per-request cost to develop against.
**No widget knows a tile URL.** `lib/data/map_config.dart` is the only place a
template, a subdomain list, an attribution string or a map API key exists;
`lib/ui/widgets/map_tiles.dart` turns that into a layer. Changing provider is a
build flag, never a map-screen edit — and a test fails the build if a URL ever
reappears under `lib/ui/`.
* **Pickup** (`DmMapPanel`) — the pin is welded to the centre of the map and the
map moves under it. When it settles, the coordinates under the pin are
reverse-geocoded and the address appears in the box below, inside the same
card. Drag, and the address follows.
* **The customer's own position** is a blue dot with an accuracy halo drawn at
true map scale, so it shrinks as they zoom in. It is a real coordinate, not a
screen overlay: dragging moves the crimson pickup pin and leaves the dot
where the customer actually is, which is how they judge how far they have
moved the pin from themselves. No fix means no dot — a dot in the wrong place
is worse than none.
* **Route** (`DmRouteMap`) — pickup and destination hub as real markers joined
by the transit arc, with the Miler's live position along it. Non-interactive
on purpose: it summarises a booking and must never steal a scroll gesture.
* **Position** (`LocationService`) — the only place `geolocator` is touched.
Every platform call is bounded by a timeout and every failure comes back as a
typed `LocationDenial`, so a refused permission or a wedged plugin can never
freeze the booking flow. It falls back to the service-area centre and says
why.
### Choosing a tile provider
CARTO is the development default and is *not* assumed to be the production
contract. Everything is set at build time:
```bash
# CARTO with a proper API key — the likely production step
flutter build appbundle --dart-define=DM_MAP_KEY=$CARTO_API_KEY
# a different vendor
flutter run --dart-define=DM_MAP_PROVIDER=maptiler --dart-define=DM_MAP_KEY=…
flutter run --dart-define=DM_MAP_PROVIDER=stadia --dart-define=DM_MAP_KEY=…
# self-hosted or anything else
flutter run \
--dart-define=DM_MAP_PROVIDER=custom \
--dart-define=DM_MAP_URL=https://tiles.doormile.in/streets/{z}/{x}/{y}.png \
--dart-define=DM_MAP_URL_MUTED=https://tiles.doormile.in/light/{z}/{x}/{y}.png \
--dart-define=DM_MAP_ATTRIBUTION='© Doormile · © OpenStreetMap contributors'
```
| Define | Effect |
| --- | --- |
| `DM_MAP_PROVIDER` | `carto` (default) · `osm` · `maptiler` · `stadia` · `custom` |
| `DM_MAP_KEY` | Appended as the provider's key parameter (`api_key`, `key`) |
| `DM_MAP_URL` / `DM_MAP_URL_MUTED` | Templates for `custom` |
| `DM_MAP_SUBDOMAINS` | Comma-separated `{s}` shards |
| `DM_MAP_ATTRIBUTION` / `_URL` | Credit line and licence link |
| `DM_MAP_MAX_ZOOM` | Deepest native zoom the provider serves |
| `DM_APP_VERSION` | Goes into the `User-Agent` |
A provider that needs a key but was given none falls back to CARTO with a
`warning` on the config rather than rendering a blank map. Every tile request
carries `User-Agent: Doormile/<version> (in.doormile.customer)`, so the provider
can attribute and rate-limit our traffic properly. `keepBuffer`/`panBuffer` stay
at flutter_map's defaults — the app fetches the tiles it is about to draw and
nothing more. There is no bulk prefetch or offline tile scraping, by design.
The credit line is rendered on **every** map, its text taken from whichever
provider is configured, so it stays correct after a swap.
`ACCESS_FINE_LOCATION` / `ACCESS_COARSE_LOCATION` are declared on Android and
`NSLocationWhenInUseUsageDescription` on iOS; GPS hardware is marked optional so
Play does not filter the app off devices without it.
## Running it
```bash
flutter pub get
flutter run # any connected device or simulator
flutter test # 88 tests covering the whole flow
```
`flutter run` talks to `https://api.doormile.com/api/v1`. Point it somewhere
else with a define:
```bash
flutter run --dart-define=DM_API_BASE=https://<staging-host>/api/v1
```
### Which build am I in?
Every debug launch prints it as its first line:
```
[BUILD] DEV DATA (offline) · staging · no network ← the fake, any code works
[BUILD] staging · https://api.doormile.com/api/v1 ← the real server
```
Check that line before anything else. The two builds are identical on screen,
and the whole reason the first offline mode was deleted was that nobody could
tell which one a handset was running. The Account screen says the same thing
under **BUILD**, but a build that cannot get past the login screen cannot show
you the Account screen — which is exactly when you need to know.
**`[API] 401 …/auth/otp/verify · invalid_otp` means you are on the real server**
and it refused the code. `1234` is not a real code; only the offline build
accepts anything. See below.
### Two contract bugs found against production, 2026-09-23
Both were the same mistake — the client spelling a field the way the written
contract spells it, where the server reads something else — and both were
verified one request apart against `api.doormile.com`, same pickup, same slot.
**The pickup coordinates are `lat`/`lng`, not `latitude`/`longitude`.**
| sent | `POST /bookings` |
| --- | --- |
| `latitude` / `longitude` | **422 `unserviceable`** — "We are not collecting from that area yet" |
| `lat` / `lng` | **201**, booking `DM-252803` |
The server could not read the pin, could not place it in a serviceable area,
and blamed the customer's address for a field name. **The app could not create
a booking at all**; every serviceable district's own hub coordinates were
refused as a pickup. Fixed in `Place.toJson`.
**The same bug in `POST /fare/estimate` returns a wrong price instead of an
error**, which is worse:
| sent | local (0.2 km) | Chennai (427 km) |
| --- | --- | --- |
| `latitude` / `longitude` | ₹280–520, `routeKm` 0 | ₹280–520, `routeKm` 0 |
| `lat` / `lng` | ₹45–90 | ₹150–280 |
With the long spelling the server measures no route and answers a flat fallback
band for every journey — roughly six times the real price on a local run, and
the same number quoted for a parcel crossing the state. Every fare this app has
shown was that fallback. Fixed in `LiveDoormileApi.estimateFare`.
This is the third of these (`POST /auth/otp/verify` takes `code` where the
document says `otp`). **Follow the server, not the document**, and
`test/live_api_wire_test.dart` is where each one gets pinned down.
### The app cannot sign in to production
Separate from the above, and still open. The real backend has no SMS gateway,
so the OTP never arrives. The server does have a PIN flow —
```
POST /customer/auth/login {"phone": "+91…"} → {registered, pin_set}
POST /customer/auth/verify-pin {"phone", "pin"} → {accessToken, refreshToken, customer}
```
— but the app does not use it: `login_screen.dart` calls `sendOtp`. Until those
two endpoints are wired into the entrance, a booking cannot be made **from the
app** against production, whatever the payload says. The QA account
`+919999900001` ("Doormile QA") is registered with a PIN for exactly this.
### Signing in for development
A shipping build has **one** API implementation, `LiveDoormileApi`, and it talks
to a server. Nothing invented can reach a customer.
But the real backend has no SMS gateway yet, so the OTP never arrives and you
cannot sign in against it. Two dev-only ways in, both `!kReleaseMode` — no define
can put either in a release build, and the Account screen prints the mode it is
in:
**Offline dev data** — the whole app, no server, any 4-digit code signs in:
```bash
flutter run --dart-define=DM_MOCK=true
```
It opens **straight on Home**, already signed in as Joe Oommen — there is no
server to refuse anybody, so stopping at a login screen would ask for a phone
number and a code that mean nothing. Sign out and you get the real entrance,
where any 10-digit number and **any 4-digit code** get you back in.
This swaps in `DevDoormileApi`: in-memory serviceability, slots, fare and
bookings, so you can walk signup → book → track without a backend. **A booking
made here stays on the device — it reaches no server and no admin console.** The
Account screen reads `DEV DATA (offline)`.
**Real token** — talk to the live server, skip only the OTP round trip. Not a
bypass: the server authorises every request it signs, and a revoked token signs
the build straight back out:
```bash
flutter run --dart-define=DM_DEV_TOKEN=eyJ... \
--dart-define=DM_DEV_REFRESH_TOKEN=...
```
**Real sign-in, no login screen** — runs the actual OTP request and verify pair
at launch, so what it ends up holding is a token the server issued and a
booking it makes reaches `pickupbookings` and the admin console:
```bash
flutter run --dart-define=DM_LOGIN_AS=9876543210 \
--dart-define=DM_LOGIN_CODE=<a code the server will accept>
```
The code has to be one the server will take. Two ways to have one: set
`CX_STAGING_OTP` on a non-production backend (a fixed code, refused outright
when `ENV=production`), or read the code the backend logged — with no SMS
gateway registered its `logSender` writes every code to the application log and
reports the send as successful.
### Walking a booking through its stages
Against dev data, the tracking screen's stepper walks the in-memory booking.
Against a non-production backend it drives the server's own QA helper
(`POST /customer/ops/bookings/{ref}/stage`) — each tap posts and re-reads, so
what appears is the stage the **server** recorded:
```bash
flutter run --dart-define=DM_API_BASE=https://<staging-host>/api/v1 \
--dart-define=DM_ALLOW_STAGE_OVERRIDE=true
```
The backend gates it too, with `CX_ALLOW_STAGE_OVERRIDE`. The app refuses to
send it on production regardless.
### Walking a booking through its stages
On a non-production backend, the tracking screen can offer a stepper that drives
the server's own QA helper (`POST /customer/ops/bookings/{ref}/stage`). Each tap
posts and then re-reads the booking, so what appears is the stage the **server**
recorded:
```bash
flutter run --dart-define=DM_API_BASE=https://<staging-host>/api/v1 \
--dart-define=DM_ALLOW_STAGE_OVERRIDE=true
```
The backend gates it too, with `CX_ALLOW_STAGE_OVERRIDE`. The app refuses to
send it on production regardless.
## The backend
Every screen's data comes from `lib/data/live_doormile_api.dart`, which is a
thin translation between the app's models and the wire contract — the transport
concerns (envelope, error codes, bearer auth and its silent refresh,
idempotency, `ETag`, keyset paging, retries) all live in `api_client.dart`.
`DmAsyncList` wraps every backend-driven list, so loading skeletons, empty
states and retry-on-error come with any new endpoint for free.
`District` carries `lat`/`lng` for its hub, which is what the route map draws
to; without them it simply draws the pickup end.
Still to wire: push notifications (`POST /customer/devices` is implemented but
no SDK produces a token), deep links (`GET /customer/orders/{trackingId}` is
implemented but no intent filter or URL type is registered), and a post-booking
edit screen for `PATCH …/destinations/{i}`. The contract is in
[`docs/API_READINESS.md`](docs/API_READINESS.md).
## Releasing to Google Play
The app is configured for a Play upload: application ID `in.doormile.customer`,
label **Doormile**, portrait only, `INTERNET` declared in the main manifest,
adaptive + monochrome launcher icon, an Android 12+ splash screen, R8 shrinking
on release, and backup/device-transfer disabled.
1. **Create the upload key** (once):
```bash
keytool -genkey -v -keystore android/upload-keystore.jks \
-keyalg RSA -keysize 2048 -validity 10000 -alias upload
```
2. **Point Gradle at it**: copy `android/key.properties.example` to
`android/key.properties` and fill in the four values. Both the keystore and
`key.properties` are git-ignored — never commit them. Without this file the
release build falls back to debug signing so `flutter run --release` keeps
working locally.
3. **Set the version** in `pubspec.yaml` (`version: 1.0.0+1` → `versionName` +
`versionCode`); bump the build number for every upload.
4. **Build the bundle**:
```bash
flutter build appbundle --release
# build/app/outputs/bundle/release/app-release.aab
```
5. **Upload** to Play Console. Listing assets are in `store/`:
`play_store_icon_512.png` and `play_feature_graphic_1024x500.png`. You still
need phone screenshots, a short/full description, a privacy policy URL, the
data-safety form (this build collects nothing — no analytics, no third-party
SDKs) and a content rating.
A JDK 17 is required for Android builds (`flutter doctor` will confirm) — the
one bundled with Android Studio works:
`flutter config --jdk-dir "/Applications/Android Studio.app/Contents/jbr/Contents/Home"`.
## Tests
The test suite is the only place a fake backend exists:
`test/support/fake_doormile_api.dart`, injected through
`DoormileApi.overrideInstance`. It owns its bookings and its stage machine
exactly as the server does — no test reaches into `AppState` to plant one.
`test/live_api_wire_test.dart` checks what the app actually puts on the wire,
field by field, against the customer API document.
`test/booking_flow_test.dart` drives the app end to end: signing in through the
OTP screen; dragging the pickup map and watching the address re-resolve; a
refused location falling back without blocking the booking; booking one package to one district with every optional field
skipped; four packages to one place; three destinations with mixed counts and
the review total; the package cap and destination cap; state→district
serviceability filtering; packages staying together until collection and then
splitting into their own orders; cancelling; a delivered order's receipt; and
the empty/error/retry service-area states.