Thiru-tenext 8757b16cf5 PIN sign-in, because the code could never arrive
── What was actually broken ──

The SMS gateway was switched off, and `POST /auth/otp/request` does not fail
when that happens: it still answers `sent: true`, still issues a valid 4-digit
code, and writes it to the **server log**. So the phone path walked customers
to a code screen for a code that could not arrive, and every digit they
eventually typed was wrong. The failure read to them as "I entered it wrong".

Phone sign-in is now a PIN, which needs no gateway.

── Email still sends codes, so email is untouched ──

Email OTP goes over SMTP and works. Deleting a working way in to tidy up a
broken one is a net loss for anyone with an email on their account, so "Use
email instead" and the code screen stay exactly as they were.
`login_otp_guard_test` moves to that path — the `sent: false` guard still
matters there, and that is now the only place it can fire.

── One screen, three entrances ──

`POST /auth/login` says which of them a number is before anything is asked, so
the app never guesses. Guessing is not cosmetic: offer "create a PIN" to a
returning customer and the server answers `pin_already_set` on a screen that
cannot succeed; offer "enter your PIN" to somebody who has never set one and
every attempt is wrong.

The separate sign-up screen is deleted rather than hidden. It asked for a name
and then sent an SMS code — a second entrance asking the same questions and
posting a letter that never lands. A new number now gives its name and PIN on
the same screen.

── A second sign-in path found a latent bug ──

`AppState.signIn` only started `refreshOrders`, and the OTP screen called
`detectPickupLocation` itself afterwards to make up the difference. That held
exactly as long as there was one sign-in screen. PIN sign-in did not know about
the extra call, so Home opened with no pickup and no serviceable cities.

The work belongs to signing in, not to whichever screen happened to be last, so
it moved into `signIn` and the OTP screen's copy is gone.

── What the screen deliberately does not do ──

It does not greet by name. `POST /auth/login` returns the account holder's
name, which tells anybody who types a number who owns it; the field is read but
never displayed, so it disappears quietly when the backend drops it.

It does not say whether the number or the PIN was wrong — the server answers
identically for both on purpose, and narrowing it here would turn sign-in into
a way of testing whether a number has an account.

"Forgot your PIN?" renders only when a support contact is configured. There is
no reset endpoint, so it can only point at a human — and telling somebody
locked out that help exists without saying where is worse than silence.

── The handover note does not reach the Miler ──

The app said "we pass this to your Miler as a note". It does not: `remarks`
reaches the admin console and stops, because the rider app reads a `notes`
field per stop that the backend never sends. A customer could hand their parcel
to a neighbour believing the Miler had been told. Both screens now say it is
recorded on the booking, and that the Miler still calls the account's number.

── Also ──

DmTextField gains `obscure`, and PinScreen carries a back button — without one
the only correction for a mistyped digit was killing the app.
2026-09-30 10:44:22 +05:30

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:

# 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                      # 88 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

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:

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:

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:

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:

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.

  1. Create the upload key (once):

    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:

    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.

Description
No description provided
Readme 46 MiB
Languages
Dart 96.7%
Python 2.1%
Shell 0.8%
HTML 0.2%
Ruby 0.1%