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>
322 lines
14 KiB
Markdown
322 lines
14 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 # 79 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
|
|
```
|
|
|
|
### 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
|
|
# login screen → 9876543210 → 1234 → in.
|
|
# add --dart-define=DM_DEV_LOGIN=true to skip the login screen entirely.
|
|
```
|
|
|
|
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=...
|
|
```
|
|
|
|
### 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.
|