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>
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 placegeolocatoris touched. Every platform call is bounded by a timeout and every failure comes back as a typedLocationDenial, 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:
# 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
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:
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:
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:
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:
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:
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.
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.
-
Create the upload key (once):
keytool -genkey -v -keystore android/upload-keystore.jks \ -keyalg RSA -keysize 2048 -validity 10000 -alias upload -
Point Gradle at it: copy
android/key.properties.exampletoandroid/key.propertiesand fill in the four values. Both the keystore andkey.propertiesare git-ignored — never commit them. Without this file the release build falls back to debug signing soflutter run --releasekeeps working locally. -
Set the version in
pubspec.yaml(version: 1.0.0+1→versionName+versionCode); bump the build number for every upload. -
Build the bundle:
flutter build appbundle --release # build/app/outputs/bundle/release/app-release.aab -
Upload to Play Console. Listing assets are in
store/:play_store_icon_512.pngandplay_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.