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:
364
README.md
364
README.md
@@ -1,63 +1,321 @@
|
||||
# Doormile — EV-First Parcel Delivery (Flutter)
|
||||
# Doormile — Customer App
|
||||
|
||||
A complete, fully-navigable Flutter app built from the Doormile design brief.
|
||||
EV-first delivery, MileTruth AI routing narrative, corporate-red design system.
|
||||
A Flutter app for the Doormile customer journey:
|
||||
|
||||
## What's inside (all screens implemented)
|
||||
**Book pickup → Track → Delivered**
|
||||
|
||||
| Flow | Screens |
|
||||
|------|---------|
|
||||
| Onboarding | Splash → 3-slide Onboarding |
|
||||
| Auth | Login (phone) → OTP verify |
|
||||
| Home | Home hub (hero, quick actions, active shipment, eco banner) |
|
||||
| Booking | Pickup/Drop → Parcel details & slot → Service & price → Review → Payment sheet → Success |
|
||||
| Tracking | My Parcels (filters) → Live Tracking (map + rider) → Shipment Journey timeline |
|
||||
| Wallet | Balance card, add money, saved methods, transactions |
|
||||
| Profile | Stats, settings list, logout |
|
||||
| Support | FAQ accordion + chat assistant |
|
||||
|
||||
Everything is wired with real navigation and in-memory state (Provider):
|
||||
placing an order adds it to **My Parcels**, paying by wallet deducts the balance,
|
||||
"Add money" updates the wallet, logout returns to login, etc.
|
||||
|
||||
## Design system
|
||||
|
||||
- **Colours / type / spacing** mirror `DESIGN.md` (primary `#960019`, Public Sans).
|
||||
- Public Sans is **bundled** (`assets/fonts/PublicSans.ttf`) — no network fonts.
|
||||
- Reusable widgets in `lib/widgets/` (cards, buttons, chips, progress bars).
|
||||
|
||||
## How to run
|
||||
|
||||
Open the Terminal app, then (the `flutter` tool lives in Homebrew):
|
||||
|
||||
```bash
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
cd /Users/apple/Desktop/Doormile-app/doormile
|
||||
|
||||
# Pick ONE target:
|
||||
flutter run -d chrome # opens in Chrome (fastest, no setup)
|
||||
flutter run -d macos # native macOS window
|
||||
flutter run # phone/emulator if one is connected
|
||||
```
|
||||
|
||||
Demo tips: at login enter **any 10-digit number**, and at OTP enter **any 6 digits** —
|
||||
there's no backend, it's a front-end prototype.
|
||||
|
||||
## Project layout
|
||||
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, named routes
|
||||
theme/ # colours + text styles (design tokens)
|
||||
models/ # Order, AddressEntry, WalletTransaction, ...
|
||||
state/app_state.dart # Provider store + mock data + booking draft
|
||||
widgets/ # shared UI (AppCard, PrimaryButton, StatusChip, logo)
|
||||
screens/ # one file per screen, grouped by flow
|
||||
test/ # smoke, full-journey, and per-screen render tests
|
||||
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
|
||||
|
||||
```bash
|
||||
flutter test # 18 tests: boot, full user journey, every screen renders clean
|
||||
```
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user