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
409 lines
18 KiB
Markdown
409 lines
18 KiB
Markdown
# 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.
|