Five payload bugs, four pages behind dead rows, and one sheet
── The full-address path was reaching the Miler empty ──
`DestinationGroup.toBookingJson` spread its details FLAT across the
destination. The contract nests them under `details{}`, and a destination
carrying keys the server does not recognise is accepted without a word — so
every building number, street, landmark, recipient name, recipient phone and
pin a customer typed was written, answered 201, and thrown away. The Miler
arrived with a district.
Four more on the same call. The destination pin spelled `latitude`/`longitude`
— the same spelling that answered 422 unserviceable for months on the pickup
before it was fixed there and missed here. A PATCH that sent `null` to clear a
field, with a comment saying so, when the server writes only non-nil values, so
a landmark could be added and never removed. Per-destination `instructions`
folded into the visit's one `remarks` line on the belief the contract had no
per-destination note; it has one. And `contactName`/`contactPhone` on the
pickup object, which the create contract has no room for and drops.
The fix ships unverified, deliberately. If `details{}` is also the wrong shape
the fields drop exactly as they do today — it cannot be worse, and holding it
costs every full-address booking in the meantime. docs/BACKEND_CHANGES.md asks
for the confirmation; tool/verify_booking.sh runs it in one command.
── Who the Miler rings ──
One number reaches the rider and it is the account's: `GET /miler/bookings`
returns a single `customerphone`, verified against production and written down
in the rider app's own stop_contact.dart. So "Someone else is handing it over?"
was collecting a number that reached nobody.
Review now shows the number that will actually be dialled, and the handover
person travels in `remarks` with a name, labelled for whoever reads it. Both
screens say plainly that the rider's call button still dials the account —
better than letting somebody hand their parcel to a neighbour believing
otherwise.
── Account's rows led nowhere ──
Two had no `onTap` at all — a chevron pointing at a page that did not exist —
and three answered with a toast. Five rows making a promise, one keeping it.
Notifications, Payment, Help and About are real screens now, written to one
rule: say only what is true of this app today. There is no notification
endpoint, no stored payment instrument and no push SDK wired in, so none of
them pretends to manage any of that. Support shows no contact block at all
rather than a number that rings nowhere — AppConfig carries the fields empty
until somebody fills them in.
── ONE TOUCH is one sheet ──
It was two in sequence with a dismissal between them, and the destination step
made you open a state to see any city — two levels of navigation for something
its own search already flattened. One flat list headed by state, which is also
the answer to "where do you deliver?", and one surface that changes its
question instead of closing so another can open.
Home says the reach in a line, and it needed two fixes to appear at all:
`cachedCities` walked closed states looking for districts that are only fetched
for open ones, and `loadCities` filled two caches while notifying nobody.
── Sending a second parcel ──
`maxDestinations` is 1 in production, so two parcels for two places means
booking twice — and that cost the whole flow twice, re-answering a door the
customer had not moved from. `startBookingFrom` carries the door, carries the
destination only when asked, and never carries the window: a slot fills up, and
a second booking pinned to one that is now full is refused at confirm with
nothing the customer can act on.
Review also says why there is no "add another destination", so a cap reads as a
limit rather than a missing button.
── Bundle ──
pubspec named its images one by one. Declaring `assets/images/` as a folder
shipped a 974 KB launcher-icon master to every customer for a file no code
opens.
|
Before Width: | Height: | Size: 418 KiB After Width: | Height: | Size: 418 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 164 KiB |
BIN
design/screens/11-pickup-where.png
Normal file
|
After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 186 KiB |
BIN
design/screens/12-pickup-when.png
Normal file
|
After Width: | Height: | Size: 252 KiB |
|
Before Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 259 KiB |
@@ -34,9 +34,8 @@ addresses and references are invented.
|
||||
| [`08-account.png`](08-account.png) | **Account** | Grouped rows, and the build this handset is running. |
|
||||
| [`09-tracking.png`](09-tracking.png) | **Live tracking** | The live card, the map, the Miler, the journey rail. |
|
||||
| [`10-cancel-pickup.png`](10-cancel-pickup.png) | **Cancel this pickup** | The one destructive action, behind a reason. |
|
||||
| [`11-destination-states.png`](11-destination-states.png) | **Where is it going? · states** | Step one, with the state marks. |
|
||||
| [`12-destination-districts.png`](12-destination-districts.png) | **Where is it going? · districts** | Step two. Multi-select where the server allows it. |
|
||||
| [`13-pickup-window.png`](13-pickup-window.png) | **Pickup window** | The times, and nothing else. |
|
||||
| [`11-pickup-where.png`](11-pickup-where.png) | **Pickup · where** | Every city Doormile serves, headed by state. One list, no level to open — and the answer to "where do you deliver?". |
|
||||
| [`12-pickup-when.png`](12-pickup-when.png) | **Pickup · when** | The same sheet's second question. It never leaves and never changes height. |
|
||||
| [`14-send-a-parcel.png`](14-send-a-parcel.png) | **Send a parcel** | The route, the packages, the two commitments. |
|
||||
| [`15-pickup-map.png`](15-pickup-map.png) | **Pickup point** | The map editor, and who the Miler asks for. |
|
||||
| [`16-pickup-booked.png`](16-pickup-booked.png) | **Pickup booked** | White tick on green. The reference and the live search. |
|
||||
|
||||
284
docs/BACKEND_CHANGES.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# Doormile CX — Backend Changes Needed
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Date | 2026-09-29 |
|
||||
| From | `doormile_cx` (customer app) team |
|
||||
| Checked against | Customer App API Reference v1.0 (backend code at `44ba33e`) |
|
||||
| Evidence | Live probes against `https://api.doormile.com/api/v1` on 2026-09-29, quoted inline |
|
||||
|
||||
**The two most urgent items here need no app work at all — they are deployment
|
||||
configuration.** One of them is a live security exposure. Everything below §3 is
|
||||
ordinary product work and can wait its turn.
|
||||
|
||||
| # | Item | Owner | Effort |
|
||||
|---|---|---|---|
|
||||
| 1 | `ENV` is not set to production | Backend / DevOps | One line |
|
||||
| 2 | SMS is unfunded — new customers cannot sign in | Product decision | One line, or weeks |
|
||||
| 3 | If PIN becomes the only auth, three holes must close first | Backend | Days |
|
||||
| 4–8 | Payload, contacts, multi-destination, push, staging | Both | Ordinary |
|
||||
|
||||
---
|
||||
|
||||
## 1. `ENV` is not production on `api.doormile.com`
|
||||
|
||||
**Verified today.** `POST /customer/auth/otp/request` with a valid phone
|
||||
returns:
|
||||
|
||||
```json
|
||||
{"data":{"codeLength":4,"resendAfterSeconds":30,"sent":true},"success":true}
|
||||
```
|
||||
|
||||
Per API Reference §3.5, when no SMS gateway is configured the log sink is used,
|
||||
and it behaves differently by environment: **in production, sending fails and
|
||||
this endpoint returns `500`.** It only writes the code to the server log and
|
||||
reports `sent: true` when `ENV` is *not* production.
|
||||
|
||||
It reported `sent: true`. The readiness probe confirms the gateway is absent:
|
||||
|
||||
```
|
||||
GET /api/v1/ready → "sms": { "configured": false, "transport": "log" }
|
||||
```
|
||||
|
||||
**Why this matters beyond OTP.** `ENV != production` is the gate on two other
|
||||
things:
|
||||
|
||||
- **`CX_STAGING_OTP`.** If that variable is set in this environment, *every*
|
||||
issued code is a fixed constant — for every account, including real customers.
|
||||
Anyone who knows it can sign in as anyone. We did not test this, because
|
||||
confirming it means signing in to an account we do not own. **Please check the
|
||||
deployment config for it and treat it as urgent if it is set.**
|
||||
- **`POST /customer/ops/bookings/:reference/stage`**, the QA stage override, is
|
||||
one env var (`CX_ALLOW_STAGE_OVERRIDE`) away from being reachable by any
|
||||
customer token.
|
||||
|
||||
**What to do:** set `ENV=production` on `api.doormile.com`, and confirm
|
||||
`CX_STAGING_OTP` is unset there. This is the single highest-priority item in
|
||||
this document.
|
||||
|
||||
---
|
||||
|
||||
## 2. SMS is unfunded — new customers cannot sign in
|
||||
|
||||
**The OTP routes are not removed.** We probed all six auth endpoints with
|
||||
malformed bodies; every one returned a normal `400` validation error rather than
|
||||
a `404`:
|
||||
|
||||
```
|
||||
POST /auth/otp/request → 400 invalid "Enter a valid phone number or email address"
|
||||
POST /auth/signup → 400 invalid_name "Enter your full name"
|
||||
POST /auth/otp/verify → 400 invalid "Enter the code we sent you"
|
||||
POST /auth/login → 400 invalid "Enter a valid phone number"
|
||||
POST /auth/set-pin → 400 invalid "Enter a valid phone number"
|
||||
POST /auth/verify-pin → 400 invalid "Enter your PIN"
|
||||
```
|
||||
|
||||
So the server still issues a valid 4-digit code on every request. **The code
|
||||
goes to the server log instead of the customer's handset.** The practical result
|
||||
is that anyone with log access can sign in as anybody, and no customer can sign
|
||||
in at all.
|
||||
|
||||
**The failure is slow, which is what makes it dangerous.** Refresh tokens live
|
||||
60 days and rotate, so customers already signed in keep working. Only *new*
|
||||
sign-ins break — new installs, reinstalls, new customers, anyone signed out.
|
||||
Nobody reports this for weeks, then everybody does at once as tokens age out.
|
||||
|
||||
**The customer app has no other way in.** Its entire auth surface is
|
||||
`/auth/otp/request`, `/auth/signup` and `/auth/otp/verify`
|
||||
(`lib/data/live_doormile_api.dart:44-95`). There is no PIN code in the app.
|
||||
|
||||
### The decision we need
|
||||
|
||||
| Option | Backend work | App work | Consequence |
|
||||
|---|---|---|---|
|
||||
| **A — Fund SMS again** | Set `SMS_GATEWAY_URL` | **None** | Sign-in works immediately. The app ships unchanged. You inherit none of §3 |
|
||||
| **B — PIN becomes permanent** | §3 first | Three new screens | Weeks. You inherit all three holes in §3, on the only door into the product |
|
||||
|
||||
**We recommend A.** The routes are already live and validating; it is one
|
||||
environment variable against weeks of work on both sides, and it avoids §3
|
||||
entirely. If cost is the blocker, say so and we will price the alternatives —
|
||||
but B is not the cheap option, it only looks like it.
|
||||
|
||||
---
|
||||
|
||||
## 3. If PIN becomes the only auth, three things must change first
|
||||
|
||||
An earlier version of this document asked you to **remove** the PIN routes,
|
||||
because the app did not use them and `set-pin` is an account-takeover path. That
|
||||
ask is withdrawn. If PIN becomes the only door, those same holes stop being a
|
||||
side risk and become the front door.
|
||||
|
||||
| # | Hole | Why it matters as the only auth | What we need |
|
||||
|---|---|---|---|
|
||||
| 1 | **`set-pin` requires no proof of ownership.** It sets the first PIN on *any* account that has none — including every account created by OTP signup, by `otp/verify`, or by the console | Anyone who types a stranger's number owns their account, their address history and their active pickups. Every existing OTP-created account is currently claimable | Proof of ownership before the first PIN. **This is the same question OTP was answering** — it does not disappear when SMS does |
|
||||
| 2 | **No PIN reset or change endpoint** | A customer who forgets a 4-digit PIN is locked out permanently, and support has no lever. OTP always provided a way back; nothing does now | A reset path, and a way for support to trigger it |
|
||||
| 3 | **No per-account lockout on `verify-pin`** | 10,000 combinations. The only limit is 10 req/min per IP, shared with `/miler/login`, `/admin/login` and `/hub/login`, and defeated by rotating IPs | Per-account attempt limit and backoff |
|
||||
|
||||
Also: **`POST /auth/login` tells an anonymous caller whether a number is
|
||||
registered, and returns the account holder's name.** Free customer enumeration.
|
||||
|
||||
---
|
||||
|
||||
## 4. What we found and fixed in the app
|
||||
|
||||
Separately from auth, we audited the booking payload against the API reference.
|
||||
Five things on the wire were wrong. All five are fixed.
|
||||
|
||||
| # | What the app sent | What the contract wants | Consequence |
|
||||
|---|---|---|---|
|
||||
| 1 | Destination details spread **flat** on the destination | Nested under `details{}` | **Every building number, street, landmark, recipient name, recipient phone and pin was dropped.** 201 returned, nothing reached the Miler |
|
||||
| 2 | Destination pin as `latitude`/`longitude` | `pin: {lat, lng}` | Pin discarded. Same spelling that caused months of `422 unserviceable` on the pickup before it was fixed there |
|
||||
| 3 | `PATCH .../destinations/{index}` sent `null` to clear a field | `""` clears; `null` means "leave alone" | A customer could add a landmark and never remove one |
|
||||
| 4 | Per-destination `instructions` folded into the visit's `remarks` | `details.instructions` exists per destination | One note for a multi-stop visit instead of one per door |
|
||||
| 5 | `contactName` / `contactPhone` on the **pickup** object | Pickup is `{title, sub, lat, lng}` only | Written on every booking, read by nobody. See §6 |
|
||||
|
||||
A customer using the full-address path typed a flat number, a street and a
|
||||
recipient, saw them on the review screen, confirmed — and the Miler arrived with
|
||||
only a district. There was no error anywhere in that chain.
|
||||
|
||||
**We are shipping the fix before it is verified**, because it cannot be worse
|
||||
than today: if `details{}` is also the wrong shape, the fields are dropped
|
||||
exactly as they are now. Verification below is confirmation, not a gate.
|
||||
|
||||
---
|
||||
|
||||
## 5. Please confirm the nested shape
|
||||
|
||||
One booking created with the body below, then a read of the stored destination
|
||||
confirming `street`, `building`, `landmark`, `recipientName`, `recipientPhone`,
|
||||
`instructions` and the pin all landed. If they did not, tell us the shape that
|
||||
does work — we will follow the server, not the document.
|
||||
|
||||
```json
|
||||
{
|
||||
"stateCode": "TN",
|
||||
"districtCode": "TN-MAA",
|
||||
"packageCount": 3,
|
||||
"details": {
|
||||
"recipientName": "Meera S",
|
||||
"recipientPhone": "9876543210",
|
||||
"building": "12/A",
|
||||
"street": "MG Road",
|
||||
"landmark": "Opp. bus depot",
|
||||
"instructions": "Call before arriving",
|
||||
"pin": { "lat": 13.085, "lng": 80.21 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Worth fixing regardless:** a destination carrying keys the server does not
|
||||
recognise is accepted silently. That property is what let this run for months.
|
||||
If unknown keys on `destinations[]` returned `400 invalid` naming them, this
|
||||
class of bug would surface on the first request instead of in the field.
|
||||
|
||||
---
|
||||
|
||||
## 6. A real pickup contact field
|
||||
|
||||
The Miler gets exactly one phone number for a collection, and it is the number
|
||||
the customer signed in with. `GET /miler/bookings` returns a single phone field,
|
||||
`customerphone`, which the rider app stores as `pickupcontactno` — verified
|
||||
against production on 21 August 2026 and recorded in the rider app's own source
|
||||
(`miler/lib/data/stop_contact.dart`). Nothing the customer app sends on `pickup`
|
||||
can change it: the create contract's pickup is `{title, sub, lat, lng}` and
|
||||
extra keys are dropped.
|
||||
|
||||
**Why it matters.** A parcel handed over by a spouse, a neighbour, a
|
||||
receptionist or a shop assistant is ordinary in collections. Today the rider
|
||||
rings the account holder, who may be at work, and the collection fails on the
|
||||
doorstep. **A failed pickup costs a whole rider trip — the most expensive unit
|
||||
in this business.**
|
||||
|
||||
**Meanwhile** the app puts a handover person in `remarks`, labelled so a human
|
||||
reads it unambiguously (`Handover contact: Meera S, +91 9003144518`), and both
|
||||
the pickup and review screens tell the customer plainly that the rider's call
|
||||
button still dials their own number.
|
||||
|
||||
**What we are asking for**
|
||||
|
||||
1. `pickup.contactName` and `pickup.contactPhone` accepted on
|
||||
`POST /customer/bookings`, stored on the booking.
|
||||
2. Surfaced to the rider as a **second** contact on the stop — not replacing
|
||||
`customerphone`, because the account holder is still who the rider escalates
|
||||
to.
|
||||
3. Meanwhile, confirm whether `remarks` reaches the rider at all. The rider app
|
||||
reads a `notes` field per stop; we do not know whether the customer app's
|
||||
`remarks` lands there or stops at the admin console.
|
||||
|
||||
**Related:** `details.recipientPhone` is stored raw when normalisation fails and
|
||||
`details.codAmount` accepts a negative number. Both are money-or-contact fields
|
||||
reaching a rider unvalidated.
|
||||
|
||||
---
|
||||
|
||||
## 7. The rest, ranked
|
||||
|
||||
| Ask | Why | What to change |
|
||||
|---|---|---|
|
||||
| **Multi-destination** | A rider makes **one trip** to the door. Three parcels for three cities in one visit is one trip instead of three. Production advertises `maxDestinations: 1` (confirmed live 2026-09-28), so a customer books three times and either three visits get scheduled or the hub merges them by hand | Key the Miler build on `consignmentid`, then add the `customerbookinglimits` row. The customer app already supports N destinations end to end and obeys the cap it reads on every launch. **Raising the number is the only change; none on our side** |
|
||||
| **Push is configured but dead** | `POST /customer/devices` exists and the stage pushes are written, but they reach nobody. Several older paths still write `appcustomers.device_token`, which no `/customer/*` endpoint fills — so a customer cancel sends no push and an ops cancel usually sends none either | Confirm `FIREBASE_SERVICE_ACCOUNT_PATH` is set, or every push is skipped silently. We will wire FCM and register on sign-in. Retire the legacy `device_token` paths |
|
||||
| **Failed delivery is invisible** | RTO, missing, damaged and failed attempts have no customer stage, so the order keeps showing `out_for_delivery` forever. The parcel is in trouble and the app says it is on its way | Add stage keys. Needs an app release first — the client falls back to `booked` for an unknown key — so tell us before shipping them |
|
||||
| **Two responses break the envelope** | We branch on `error.code`. The idempotency `409` puts `code` at the **top level**; the city gate `400` sends `error` as a **string**, not an object. Both fall through to a generic message, so "an identical request is still being processed" renders to a customer as a booking conflict | Move both into `error: {code}` |
|
||||
| **`estimate` on booking create** | Documented with a ±15% tamper rule. We do not send it, so the server always re-quotes — the band the customer saw can differ from the stored `fare` | Confirm you want it and we will send the exact `min`/`max` |
|
||||
| **`maxCodAmount`** | We parse it from `/config/booking-limits`; the reference says it is never returned | Send it, or we drop the field |
|
||||
|
||||
---
|
||||
|
||||
## 8. There is no test environment
|
||||
|
||||
`AppConfig._stagingBase` is `_prodBase`; no staging host has ever been named.
|
||||
Every request this app makes — including anything we do to test it — is
|
||||
production.
|
||||
|
||||
That is why §5 is unanswered. Verifying a JSON field name currently costs a real
|
||||
pickup in a real slot that a real Miler can be dispatched to. With SMS down, the
|
||||
only way to get a session for a test is `set-pin`, which creates a permanent
|
||||
production account with no reset and no delete path.
|
||||
|
||||
**A staging host closes §5 in ten minutes.** Cheapest item here; unblocks the
|
||||
most.
|
||||
|
||||
---
|
||||
|
||||
## 9. Remaining security items
|
||||
|
||||
From API Reference §9.2, excluding the OTP items now covered by §1.
|
||||
|
||||
- **`/ws/bookings/:id/track` has no authentication** and streams a rider's name
|
||||
and live GPS for any sequential booking id. The app does not use WebSockets;
|
||||
we poll `GET /bookings/:reference`. Nobody should be able to enumerate rider
|
||||
positions.
|
||||
- **The rider's real mobile number is exposed** in `miler.phone` unless
|
||||
`MILER_CALL_PROXY` is set. The reference marks this "a setting to close before
|
||||
launch". Please confirm it is set.
|
||||
|
||||
---
|
||||
|
||||
## 10. What we need back
|
||||
|
||||
1. **`ENV=production` set, and `CX_STAGING_OTP` confirmed unset** (§1). Today.
|
||||
2. **A or B on SMS** (§2). Everything about auth waits on this one answer.
|
||||
3. **A staging host** (§8) — cheapest item, unblocks §5 immediately.
|
||||
4. **Yes or no on the pickup contact field** (§6), and whether `remarks` reaches
|
||||
the rider today.
|
||||
5. **A date for multi-destination** (§7), or a note that it is not planned, so we
|
||||
can word the app honestly. It currently says "one destination per pickup for
|
||||
now".
|
||||
|
||||
---
|
||||
|
||||
## Appendix — how to verify a payload change
|
||||
|
||||
Use the method that settled the `lat`/`lng` bug: **two requests, one apart,
|
||||
differing in one field.** That bug ran for months because the app sent
|
||||
`latitude`/`longitude` on the pickup while the server reads `lat`/`lng` — it saw
|
||||
a booking with no coordinates and answered `422 unserviceable`, blaming the
|
||||
customer's address for a field name. Settled on 2026-09-23 with two requests:
|
||||
same pickup, same destination, same slot. `latitude`/`longitude` → 422.
|
||||
`lat`/`lng` → 201, booking DM-252803.
|
||||
|
||||
`tool/verify_booking.sh` in the customer app repo automates exactly that: signs
|
||||
in, takes the first available slot, books one pickup with every detail field
|
||||
filled in and marked `VERIFY …`, reads it back, prints which of the seven fields
|
||||
survived, and cancels the booking. Point it at staging with `--base` the moment
|
||||
there is one.
|
||||
@@ -276,6 +276,37 @@ class AppConfig {
|
||||
|
||||
static String get clientHeader => 'doormile-cx/$appVersion';
|
||||
|
||||
// ------------------------------------------------------------------- contact
|
||||
|
||||
/// ── Empty on purpose ──
|
||||
///
|
||||
/// Account's support and policy rows used to fire a toast reading "Opening
|
||||
/// doormile.com…" and open nothing. Replacing a fake toast with a fake phone
|
||||
/// number is not an improvement, and a support line that rings nowhere is
|
||||
/// worse than no support line — so these default to empty and every block
|
||||
/// that needs one is simply absent until it is filled in.
|
||||
///
|
||||
/// Pass them at build time, the same way [appVersion] is passed:
|
||||
///
|
||||
/// --dart-define=DM_SUPPORT_PHONE=+911234567890
|
||||
/// --dart-define=DM_SUPPORT_EMAIL=help@doormile.com
|
||||
/// --dart-define=DM_TERMS_URL=https://doormile.com/terms
|
||||
/// --dart-define=DM_PRIVACY_URL=https://doormile.com/privacy
|
||||
/// --dart-define=DM_SITE_URL=https://doormile.com
|
||||
static const String supportPhone = String.fromEnvironment(
|
||||
'DM_SUPPORT_PHONE',
|
||||
);
|
||||
static const String supportEmail = String.fromEnvironment(
|
||||
'DM_SUPPORT_EMAIL',
|
||||
);
|
||||
static const String termsUrl = String.fromEnvironment('DM_TERMS_URL');
|
||||
static const String privacyUrl = String.fromEnvironment('DM_PRIVACY_URL');
|
||||
static const String siteUrl = String.fromEnvironment('DM_SITE_URL');
|
||||
|
||||
/// True when there is at least one way for a customer to reach a person.
|
||||
static bool get hasSupportContact =>
|
||||
supportPhone.isNotEmpty || supportEmail.isNotEmpty;
|
||||
|
||||
static String get platformHeader {
|
||||
if (kIsWeb) return 'web';
|
||||
try {
|
||||
|
||||
@@ -443,6 +443,7 @@ class DevDoormileApi extends DoormileApi {
|
||||
required String? slotId,
|
||||
FareEstimate? fare,
|
||||
String? contactPhone,
|
||||
String? contactName,
|
||||
String? idempotencyKey,
|
||||
}) => _respond(() {
|
||||
if (destinations.isEmpty ||
|
||||
|
||||
@@ -134,6 +134,7 @@ abstract class DoormileApi {
|
||||
required String? slotId,
|
||||
FareEstimate? fare,
|
||||
String? contactPhone,
|
||||
String? contactName,
|
||||
String? idempotencyKey,
|
||||
});
|
||||
|
||||
|
||||
@@ -401,50 +401,53 @@ class LiveDoormileApi extends DoormileApi {
|
||||
required String? slotId,
|
||||
FareEstimate? fare,
|
||||
String? contactPhone,
|
||||
String? contactName,
|
||||
String? idempotencyKey,
|
||||
}) async {
|
||||
if (slotId == null || slotId.isEmpty) {
|
||||
throw ApiException(ApiException.invalid, 'Pick a pickup slot');
|
||||
}
|
||||
// [fare] is deliberately not sent. The contract's create request has no
|
||||
// field for what the customer was quoted, and an undocumented extra on the
|
||||
// one call that must not be rejected is not worth the audit trail. The
|
||||
// server prices the booking itself; the shown band lives in the estimate
|
||||
// call's own log.
|
||||
// ── The pickup carries no contact, because nothing reads one ──
|
||||
//
|
||||
// Who the Miler asks for at the door. The contract asks for it on the
|
||||
// pickup, and the signed-in customer is the only answer this app has.
|
||||
final customer = (await client.currentSession())?.customer;
|
||||
|
||||
// This used to send `contactName` and `contactPhone` on the pickup object.
|
||||
// The create contract's pickup is `{title, sub, lat, lng}` and nothing
|
||||
// else, and extra keys are dropped without a word — so those two were
|
||||
// written, accepted, and discarded on every booking.
|
||||
//
|
||||
// The rider's number comes from the **account**: `GET /miler/bookings`
|
||||
// returns one phone field, `customerphone`, derived by the backend from
|
||||
// this booking's customer. A second number cannot reach them through this
|
||||
// call at all, so the app stops pretending it can and puts the handover
|
||||
// person in `remarks`, which is stored and shown.
|
||||
final response = await client.post(
|
||||
'/bookings',
|
||||
idempotencyKey: idempotencyKey ?? client.newIdempotencyKey(),
|
||||
body: {
|
||||
'slotId': slotId,
|
||||
'pickup': pickup.toBookingJson(
|
||||
contactName: customer?.name,
|
||||
// The customer's own number unless they said somebody else is
|
||||
// handing the parcel over. Same field either way — the contract has
|
||||
// always carried it; it simply had one possible source.
|
||||
contactPhone: contactPhone ?? customer?.phone,
|
||||
),
|
||||
'pickup': pickup.toJson(),
|
||||
'destinations': [for (final d in destinations) d.toBookingJson()],
|
||||
// The delivery instructions the customer typed per destination. The
|
||||
// contract carries one `remarks` line for the whole visit, which is
|
||||
// what the Miler reads, so several are joined rather than dropped.
|
||||
'remarks': ?_remarksFrom(destinations),
|
||||
// The visit's one free-text line. Only the handover person goes here
|
||||
// now — per-destination instructions have their own field inside
|
||||
// `details` and were being duplicated into this one.
|
||||
'remarks': ?_handoverNote(name: contactName, phone: contactPhone),
|
||||
},
|
||||
);
|
||||
return _bookingFrom(response.map);
|
||||
}
|
||||
|
||||
static String? _remarksFrom(List<DestinationGroup> destinations) {
|
||||
final lines = [
|
||||
for (final d in destinations)
|
||||
if (d.details.instructions?.trim().isNotEmpty ?? false)
|
||||
d.details.instructions!.trim(),
|
||||
];
|
||||
return lines.isEmpty ? null : lines.join(' · ');
|
||||
/// The one place a different handover person can be recorded.
|
||||
///
|
||||
/// There is no contact field on the create request — the rider's number is
|
||||
/// derived by the backend from the booking's account — so this goes in the
|
||||
/// visit's `remarks`, which is stored and shown. Labelled, so whoever reads
|
||||
/// it knows it is a person to ring and not a note about the parcel.
|
||||
static String? _handoverNote({String? name, String? phone}) {
|
||||
final number = phone?.trim() ?? '';
|
||||
if (number.isEmpty) return null;
|
||||
final who = name?.trim() ?? '';
|
||||
return who.isEmpty
|
||||
? 'Handover contact: $number'
|
||||
: 'Handover contact: $who, $number';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -480,14 +480,6 @@ class Place {
|
||||
/// Carries who the Miler asks for at the door. That is the signed-in
|
||||
/// customer unless the caller names somebody else, and it is sent rather
|
||||
/// than left to the server to look up, because the contract asks for it.
|
||||
Map<String, dynamic> toBookingJson({
|
||||
String? contactName,
|
||||
String? contactPhone,
|
||||
}) => {
|
||||
...toJson(),
|
||||
'contactName': ?contactName,
|
||||
'contactPhone': ?contactPhone,
|
||||
};
|
||||
}
|
||||
|
||||
/// The only required destination information: a serviceable state + district.
|
||||
@@ -567,7 +559,12 @@ class MapPin {
|
||||
return MapPin(lat, lng);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {'latitude': lat, 'longitude': lng};
|
||||
/// `{lat, lng}` — the contract's spelling, and the one that has already
|
||||
/// cost this app a production outage once: `latitude`/`longitude` on a
|
||||
/// pickup made the server see a booking with no coordinates and answer
|
||||
/// **422 unserviceable** for months. The destination pin was left on the old
|
||||
/// spelling when the pickup was fixed.
|
||||
Map<String, dynamic> toJson() => {'lat': lat, 'lng': lng};
|
||||
}
|
||||
|
||||
/// Everything here is optional at booking time. The Miler fills the gaps
|
||||
@@ -671,38 +668,49 @@ class DeliveryDetails {
|
||||
instructions == null &&
|
||||
pin == null;
|
||||
|
||||
/// The recipient and address fields exactly as a booking's destination
|
||||
/// carries them.
|
||||
/// The contents of a destination's `details` object on booking create.
|
||||
///
|
||||
/// [instructions] is deliberately absent: the contract has no per-destination
|
||||
/// note, it has one `remarks` line for the whole visit, and that is where
|
||||
/// [LiveDoormileApi] sends it. Repeating it here would put the same sentence
|
||||
/// on the wire twice under a key the server does not read.
|
||||
/// ── This used to be spread flat onto the destination ──
|
||||
///
|
||||
/// It read well and it was silently discarded. The create contract nests
|
||||
/// these under `details`, and extra keys on a destination are dropped
|
||||
/// without an error — so every building number, street, landmark, recipient
|
||||
/// and pin a customer typed on the full-address path went to the server,
|
||||
/// was accepted with a 201, and never reached the Miler.
|
||||
///
|
||||
/// [instructions] belongs here too. It was being folded into the visit's one
|
||||
/// `remarks` line on the belief that the contract had no per-destination
|
||||
/// note. It has one.
|
||||
Map<String, dynamic> toJson() => {
|
||||
'recipientName': ?recipientName,
|
||||
'recipientPhone': ?recipientPhone,
|
||||
'building': ?building,
|
||||
'street': ?street,
|
||||
'landmark': ?landmark,
|
||||
'latitude': ?pin?.lat,
|
||||
'longitude': ?pin?.lng,
|
||||
'instructions': ?instructions,
|
||||
'pin': ?pin?.toJson(),
|
||||
};
|
||||
|
||||
/// `PATCH /customer/bookings/{reference}/destinations/{index}`.
|
||||
///
|
||||
/// Flat and in the destination's own vocabulary, like every other place the
|
||||
/// contract carries a recipient. A `null` **clears** the field rather than
|
||||
/// being omitted, so every key is sent whether or not it has a value —
|
||||
/// otherwise a customer could add a landmark but never remove one.
|
||||
/// The body *is* the details object, so this one is flat by design — unlike
|
||||
/// create, where it nests.
|
||||
///
|
||||
/// ── Empty string clears; null does not ──
|
||||
///
|
||||
/// This sent `null` to clear a field, and said so in a comment. The server
|
||||
/// writes only non-nil values, so a `null` means "leave it alone" — which
|
||||
/// made removing a landmark or an instruction impossible. Every key is still
|
||||
/// sent, but an unset field goes as `""`, and an unset pin as `{0,0}`, which
|
||||
/// is what the contract documents as clearing them.
|
||||
Map<String, dynamic> toPatchJson() => {
|
||||
'recipientName': recipientName,
|
||||
'recipientPhone': recipientPhone,
|
||||
'building': building,
|
||||
'street': street,
|
||||
'landmark': landmark,
|
||||
'instructions': instructions,
|
||||
'latitude': pin?.lat,
|
||||
'longitude': pin?.lng,
|
||||
'recipientName': recipientName ?? '',
|
||||
'recipientPhone': recipientPhone ?? '',
|
||||
'building': building ?? '',
|
||||
'street': street ?? '',
|
||||
'landmark': landmark ?? '',
|
||||
'instructions': instructions ?? '',
|
||||
'pin': pin?.toJson() ?? const {'lat': 0, 'lng': 0},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -963,12 +971,16 @@ class DestinationGroup {
|
||||
/// `latitude`/`longitude`. Anything the customer left blank is omitted —
|
||||
/// that is the "Not added" state the Miler completes at the door, and it is
|
||||
/// not the same as sending an empty string.
|
||||
Map<String, dynamic> toBookingJson() => {
|
||||
...destination.toJson(),
|
||||
'packageCount': packageCount,
|
||||
...details.toJson(),
|
||||
'codAmount': ?codAmount,
|
||||
};
|
||||
Map<String, dynamic> toBookingJson() {
|
||||
final detail = {...details.toJson(), 'codAmount': ?codAmount};
|
||||
return {
|
||||
...destination.toJson(),
|
||||
'packageCount': packageCount,
|
||||
// Omitted rather than sent empty: One Touch fills none of this in, and
|
||||
// `details: {}` is a key that says nothing.
|
||||
if (detail.isNotEmpty) 'details': detail,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Indicative price for a booking, confirmed at pickup once the Miler weighs
|
||||
|
||||
@@ -178,6 +178,34 @@ class AppState extends ChangeNotifier {
|
||||
void _afterSignIn() {
|
||||
unawaited(refreshOrders());
|
||||
unawaited(detectPickupLocation());
|
||||
// ── The serviceable set, before anybody asks for it ──
|
||||
//
|
||||
// Two things read it, and both want it already there. Home's reach line
|
||||
// states how far Doormile goes and is simply absent until the cities are
|
||||
// known — so warming it only when the booking sheet opens meant the line
|
||||
// appeared *after* the one moment it was written to inform. And the sheet
|
||||
// itself skips its loading skeleton when [cachedCities] is populated.
|
||||
//
|
||||
// It is small, it is the same list every customer gets, and it is the
|
||||
// answer to a question asked on the first screen.
|
||||
unawaited(_warmServiceArea());
|
||||
}
|
||||
|
||||
/// Loads the serviceable cities and tells the screens they arrived.
|
||||
///
|
||||
/// The notify is the point. [loadCities] fills [statesCache] and
|
||||
/// [districtCache] and returns — it changes no observable field, so nothing
|
||||
/// rebuilds, and Home's reach line stayed absent while the data it needed sat
|
||||
/// in the cache beside it. A silent warm-up is only a warm-up for whoever
|
||||
/// asks next.
|
||||
Future<void> _warmServiceArea() async {
|
||||
try {
|
||||
await loadCities();
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('[AREA] could not prefetch the serviceable cities: $e');
|
||||
return;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Called by the API layer when a refresh fails — the chain is dead, so the
|
||||
@@ -511,8 +539,40 @@ class AppState extends ChangeNotifier {
|
||||
/// Null means the signed-in customer, which is the answer nearly every time
|
||||
/// — so the field on the pickup screen opens prefilled with their number and
|
||||
/// this stays null until they change it.
|
||||
/// Who the Miler rings at the door, when it is not the account holder.
|
||||
///
|
||||
/// ── What the rider actually receives ──
|
||||
///
|
||||
/// Nothing the app sends on `pickup` reaches them. `GET /miler/bookings`
|
||||
/// returns exactly one phone field, `customerphone` — verified against
|
||||
/// production and written down in the Miler app's own `stop_contact.dart` —
|
||||
/// and the backend derives it from this booking's **account**. So the number
|
||||
/// on the rider's screen is the number the customer signed in with, always.
|
||||
///
|
||||
/// There is no second contact field in the contract to put this in. It
|
||||
/// travels in `remarks`, which the contract does store and the console does
|
||||
/// show, so a human sees it even though the rider's call button will still
|
||||
/// dial the account. Until the backend carries a real handover contact, that
|
||||
/// is the honest ceiling — and the booking screen says so rather than
|
||||
/// implying the rider will ring this number.
|
||||
String? draftContactPhone;
|
||||
|
||||
/// The handover person's name, so a rider ringing an unfamiliar number knows
|
||||
/// who they are asking for. A number with no name is a cold call.
|
||||
String? draftContactName;
|
||||
|
||||
/// Records who is handing the parcel over, and tells the screens.
|
||||
///
|
||||
/// The two fields were being assigned directly, which is why Review showed
|
||||
/// nothing after the pickup screen popped back to it: `Navigator.pop` does
|
||||
/// not rebuild the route it reveals, so the contact card kept the build it
|
||||
/// had from before the customer typed anything.
|
||||
void setHandoverContact({String? name, String? phone}) {
|
||||
draftContactName = name;
|
||||
draftContactPhone = phone;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<Booking> confirmBooking() async {
|
||||
final key = _bookingIdempotencyKey ??= _newIdempotencyKey();
|
||||
try {
|
||||
@@ -522,6 +582,7 @@ class AppState extends ChangeNotifier {
|
||||
slotId: draftSlotId,
|
||||
fare: draftFare,
|
||||
contactPhone: draftContactPhone,
|
||||
contactName: draftContactName,
|
||||
idempotencyKey: key,
|
||||
);
|
||||
// The intent is spent. A further booking needs a new key or the server
|
||||
@@ -792,11 +853,22 @@ class AppState extends ChangeNotifier {
|
||||
final states = statesCache;
|
||||
if (states == null) return null;
|
||||
|
||||
// ── Both caches hold more than the picker offers ──
|
||||
//
|
||||
// [statesCache] is the whole response, closed states included, and
|
||||
// [loadCities] only ever fetches districts for the open ones — so walking
|
||||
// every cached state looking for its districts finds a hole and concludes
|
||||
// nothing is cached. That is exactly what happened: `loadCities` returned
|
||||
// eleven cities and this getter returned null beside it.
|
||||
//
|
||||
// [districtCache] is the whole response too. The same two filters
|
||||
// `loadStates` and `loadDistricts` apply have to be applied here, or this
|
||||
// would offer a district the picker will not show.
|
||||
final out = <CityOption>[];
|
||||
for (final state in states) {
|
||||
for (final state in states.where((s) => s.hasOpenDistricts)) {
|
||||
final districts = districtCache[state.code];
|
||||
if (districts == null) return null;
|
||||
for (final district in districts) {
|
||||
for (final district in districts.where((d) => d.available)) {
|
||||
out.add(CityOption(state: state, district: district));
|
||||
}
|
||||
}
|
||||
@@ -819,6 +891,41 @@ class AppState extends ChangeNotifier {
|
||||
];
|
||||
}
|
||||
|
||||
/// Starts a new draft carrying over what a previous booking already answered.
|
||||
///
|
||||
/// ── What is carried, and what deliberately is not ──
|
||||
///
|
||||
/// The **pickup** is carried. It is the same door; asking for it again is
|
||||
/// asking a customer to confirm where they are standing.
|
||||
///
|
||||
/// The **destinations** are carried only when the caller asks — "send again"
|
||||
/// from a past order means the same route, "send another from here" means
|
||||
/// the same door and a new route.
|
||||
///
|
||||
/// The **window is never carried.** A slot fills up, and a second booking
|
||||
/// silently pinned to one that is now full would be refused at confirm with
|
||||
/// nothing the customer could act on. It is also the one field that is
|
||||
/// genuinely a fresh decision: the first parcel going at 2pm says nothing
|
||||
/// about when they want the next visit.
|
||||
void startBookingFrom(
|
||||
Booking previous, {
|
||||
bool detailed = false,
|
||||
bool keepDestinations = false,
|
||||
}) {
|
||||
startBooking(detailed: detailed);
|
||||
draftPickup = previous.pickup;
|
||||
if (keepDestinations && previous.destinations.isNotEmpty) {
|
||||
draftDestinations = [
|
||||
for (final group in previous.destinations.take(limits.maxDestinations))
|
||||
DestinationGroup(
|
||||
destination: group.destination.copy(),
|
||||
packageCount: group.packageCount,
|
||||
),
|
||||
];
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Picks a city on the draft's only destination — both codes at once, since
|
||||
/// the contract wants `stateCode` and `districtCode` together.
|
||||
void selectCity(CityOption city, {int index = 0}) {
|
||||
|
||||
@@ -12,6 +12,11 @@ import '../widgets/cards.dart';
|
||||
import '../widgets/feedback.dart';
|
||||
import '../widgets/inputs.dart';
|
||||
import 'auth/login_screen.dart';
|
||||
import 'settings/about_screen.dart';
|
||||
import 'settings/notifications_screen.dart';
|
||||
import 'settings/payment_screen.dart';
|
||||
import 'settings/settings_kit.dart';
|
||||
import 'settings/support_screen.dart';
|
||||
import 'sheets/place_search_sheet.dart';
|
||||
|
||||
/// Account — profile, preferences, and the build's environment line.
|
||||
@@ -181,17 +186,30 @@ class AccountScreen extends StatelessWidget {
|
||||
}
|
||||
},
|
||||
),
|
||||
const DmRow(
|
||||
// ── Both of these were chevrons pointing at nothing ──
|
||||
//
|
||||
// No `onTap` at all: the rows rendered the affordance for
|
||||
// opening a page and then swallowed the tap. A customer
|
||||
// cannot tell that from a page that is slow, so they press
|
||||
// it again.
|
||||
DmRow(
|
||||
icon: LucideIcons.bell,
|
||||
label: 'Notifications',
|
||||
note: 'Real-time SMS and push',
|
||||
// Was "Real-time SMS and push". Push is not wired in —
|
||||
// `registerPushToken` says so itself — so the note was
|
||||
// promising a channel the app does not have.
|
||||
note: 'What you get told, and when',
|
||||
showChevron: true,
|
||||
onTap: () =>
|
||||
pushSettings(context, const NotificationsScreen()),
|
||||
),
|
||||
const DmRow(
|
||||
DmRow(
|
||||
icon: LucideIcons.wallet,
|
||||
label: 'Payment methods',
|
||||
note: 'UPI, on delivery',
|
||||
note: 'Pay after the doorstep weigh',
|
||||
showChevron: true,
|
||||
onTap: () =>
|
||||
pushSettings(context, const PaymentScreen()),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -213,23 +231,31 @@ class AccountScreen extends StatelessWidget {
|
||||
note: 'Talk to the courier desk',
|
||||
showChevron: true,
|
||||
onTap: () =>
|
||||
DmToast.show(context, 'Support is on the way'),
|
||||
pushSettings(context, const SupportScreen()),
|
||||
),
|
||||
// ── One screen, two doors ──
|
||||
//
|
||||
// Both rows land on [AboutScreen] under their own title.
|
||||
// The policies are three links and the build is three
|
||||
// facts; splitting them gives two screens that each look
|
||||
// unfinished, and a customer looking for "the legal bit"
|
||||
// finds it either way.
|
||||
DmRow(
|
||||
icon: LucideIcons.fileText,
|
||||
label: 'Terms and policies',
|
||||
note: 'What Doormile covers',
|
||||
showChevron: true,
|
||||
onTap: () =>
|
||||
DmToast.show(context, 'Opening doormile.com…'),
|
||||
onTap: () => pushSettings(
|
||||
context,
|
||||
const AboutScreen(title: 'Terms and policies'),
|
||||
),
|
||||
),
|
||||
DmRow(
|
||||
icon: LucideIcons.info,
|
||||
label: 'About Doormile',
|
||||
note: 'Version and licences',
|
||||
showChevron: true,
|
||||
onTap: () =>
|
||||
DmToast.show(context, 'Opening doormile.com…'),
|
||||
onTap: () => pushSettings(context, const AboutScreen()),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -8,8 +8,10 @@ import '../../widgets/cards.dart';
|
||||
import '../../widgets/chrome.dart';
|
||||
import '../../widgets/pieces.dart';
|
||||
import '../../widgets/route_rail.dart';
|
||||
import '../sheets/pickup_sheet.dart';
|
||||
import '../tracking_screen.dart';
|
||||
import 'booking_routes.dart';
|
||||
import 'send_screen.dart';
|
||||
|
||||
/// Booking confirmed.
|
||||
///
|
||||
@@ -66,6 +68,30 @@ class _ConfirmedScreenState extends State<ConfirmedScreen>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Books again from the same door.
|
||||
///
|
||||
/// `pushReplacement`, not `push`: the confirmation for the booking just made
|
||||
/// has served its purpose, and leaving it under the new one would put a
|
||||
/// stale reference behind the customer's back button.
|
||||
Future<void> _sendAnother(BuildContext context) async {
|
||||
final app = AppScope.read(context);
|
||||
final previous = app.activeBookings.isEmpty ? null : app.activeBookings.first;
|
||||
if (previous == null) return;
|
||||
|
||||
final asked = await showPickupSheet(context);
|
||||
if (asked == null || asked.places.isEmpty || !context.mounted) return;
|
||||
|
||||
app.startBookingFrom(previous);
|
||||
app.setDestinations(asked.places);
|
||||
// After the reset, for the same reason Home does it after `startBooking`.
|
||||
app.selectSlot(asked.slot);
|
||||
|
||||
if (!context.mounted) return;
|
||||
await Navigator.of(context).pushReplacement(
|
||||
bookingRoute<void>(BookingRoutes.send, (_) => const SendScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context);
|
||||
@@ -228,6 +254,24 @@ class _ConfirmedScreenState extends State<ConfirmedScreen>
|
||||
bookingRoute<void>(BookingRoutes.tracking, (_) => const TrackingScreen()),
|
||||
),
|
||||
),
|
||||
// ── The second parcel, without the walk back ──
|
||||
//
|
||||
// A customer with two parcels for two places had to book the first,
|
||||
// come back to Home, and start again — the pickup, the window and
|
||||
// the whole flow, re-answered at a door they had not moved from.
|
||||
//
|
||||
// One booking still means one destination while the server caps it
|
||||
// there (see [BookingLimits]), so this is the shortest honest route
|
||||
// to a second: the door is carried, the questions are the two that
|
||||
// genuinely changed, and Review replaces this screen rather than
|
||||
// stacking on it.
|
||||
DmButton(
|
||||
label: 'Send another from here',
|
||||
icon: LucideIcons.plus,
|
||||
iconLeading: true,
|
||||
kind: DmButtonKind.ghost,
|
||||
onPressed: () => _sendAnother(context),
|
||||
),
|
||||
DmButton(
|
||||
label: 'Back to home',
|
||||
kind: DmButtonKind.ghost,
|
||||
|
||||
@@ -51,6 +51,11 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
|
||||
/// explain in a remarks box nobody reads.
|
||||
final _contact = TextEditingController();
|
||||
|
||||
/// Who to ask for. A rider dialling an unfamiliar number with no name has to
|
||||
/// open with "is this the Doormile pickup?", which is how a collection turns
|
||||
/// into a wrong number.
|
||||
final _contactName = TextEditingController();
|
||||
|
||||
/// The contact field is folded away until asked for. On a screen whose job
|
||||
/// is a pin, a phone number is the second question.
|
||||
bool _contactOpen = false;
|
||||
@@ -60,6 +65,7 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
|
||||
super.initState();
|
||||
final app = AppScope.read(context);
|
||||
_contact.text = _digitsOf(app.draftContactPhone ?? app.customer?.phone);
|
||||
_contactName.text = app.draftContactName ?? '';
|
||||
_contactOpen = app.draftContactPhone != null;
|
||||
|
||||
// Arriving without a pin — ask the device where we are.
|
||||
@@ -75,6 +81,7 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_contact.dispose();
|
||||
_contactName.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -89,10 +96,13 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
|
||||
/// the account rather than a copy of it that can drift.
|
||||
void _commitContact(AppState app) {
|
||||
final typed = _contact.text.trim();
|
||||
app.draftContactPhone =
|
||||
typed.isEmpty || typed == _digitsOf(app.customer?.phone)
|
||||
? null
|
||||
: '+91 $typed';
|
||||
final own = typed.isEmpty || typed == _digitsOf(app.customer?.phone);
|
||||
final who = _contactName.text.trim();
|
||||
app.setHandoverContact(
|
||||
phone: own ? null : '+91 $typed',
|
||||
// The name is only meaningful beside somebody else's number.
|
||||
name: own || who.isEmpty ? null : who,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _search(AppState app) async {
|
||||
@@ -185,6 +195,7 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
|
||||
resolving: resolving,
|
||||
denial: app.locationDenial,
|
||||
contact: _contact,
|
||||
contactName: _contactName,
|
||||
contactOpen: _contactOpen,
|
||||
onToggleContact: () =>
|
||||
setState(() => _contactOpen = !_contactOpen),
|
||||
@@ -214,7 +225,7 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
|
||||
double _sheetHeight(BuildContext context, AppState app) {
|
||||
final scale = MediaQuery.textScalerOf(context).scale(1);
|
||||
return (app.locationDenial != null ? 268.0 : 202.0) * scale +
|
||||
(_contactOpen ? 86 : 0) +
|
||||
(_contactOpen ? 214 : 0) +
|
||||
MediaQuery.paddingOf(context).bottom;
|
||||
}
|
||||
}
|
||||
@@ -328,6 +339,7 @@ class _ConfirmSheet extends StatelessWidget {
|
||||
required this.resolving,
|
||||
required this.denial,
|
||||
required this.contact,
|
||||
required this.contactName,
|
||||
required this.contactOpen,
|
||||
required this.onToggleContact,
|
||||
required this.onEdit,
|
||||
@@ -340,6 +352,7 @@ class _ConfirmSheet extends StatelessWidget {
|
||||
final bool resolving;
|
||||
final LocationDenial? denial;
|
||||
final TextEditingController contact;
|
||||
final TextEditingController contactName;
|
||||
final bool contactOpen;
|
||||
final VoidCallback onToggleContact;
|
||||
final VoidCallback onEdit;
|
||||
@@ -435,15 +448,44 @@ class _ConfirmSheet extends StatelessWidget {
|
||||
child: contactOpen
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: DmTextField(
|
||||
label: 'Who the Miler asks for',
|
||||
controller: contact,
|
||||
prefix: '+91',
|
||||
keyboardType: TextInputType.phone,
|
||||
digitsOnly: true,
|
||||
maxLength: 10,
|
||||
mono: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DmTextField(
|
||||
label: 'Who the Miler asks for',
|
||||
controller: contactName,
|
||||
keyboardType: TextInputType.name,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
DmTextField(
|
||||
label: 'Their number',
|
||||
controller: contact,
|
||||
prefix: '+91',
|
||||
keyboardType: TextInputType.phone,
|
||||
digitsOnly: true,
|
||||
maxLength: 10,
|
||||
mono: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// ── Said here, not discovered later ──
|
||||
//
|
||||
// The rider's call button dials the account, and
|
||||
// nothing this app sends can change that. Better
|
||||
// to say so beside the field than to let somebody
|
||||
// hand their parcel to a neighbour believing the
|
||||
// Miler has the neighbour's number.
|
||||
Text(
|
||||
'We pass this to your Miler as a note. Their '
|
||||
'call button still dials your own number.',
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12,
|
||||
height: 1.45,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox(width: double.infinity),
|
||||
|
||||
@@ -168,6 +168,51 @@ class _SendScreenState extends State<SendScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
// ── Why there is no "add another destination" here ──
|
||||
//
|
||||
// A pickup can carry several destinations — the contract is "one
|
||||
// booking → 1..N destinations → 1..N orders" and this screen renders
|
||||
// DROP 1 / DROP 2 when it has them. It is the *server* that caps it,
|
||||
// through `GET /customer/config/booking-limits`, because a pickup
|
||||
// that fans out into several orders is undeliverable until the Miler
|
||||
// build keys its work on `consignmentid`.
|
||||
//
|
||||
// Without a word the screen just has no way to add a second place,
|
||||
// which reads as a form that is missing something. One line says it
|
||||
// is a limit rather than an omission, and says it where a customer
|
||||
// with a second parcel goes looking.
|
||||
if (!app.allowsMultipleDestinations) ...[
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 1),
|
||||
child: Icon(
|
||||
LucideIcons.info,
|
||||
size: 13,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'One destination per pickup for now. Book this, then '
|
||||
'“Send another from here” — the Miler makes one trip '
|
||||
'either way.',
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12,
|
||||
height: 1.45,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
// BOOK asks where it is going; the window is asked here. Unset it
|
||||
// takes the brand, so the one row still to be filled is the one the
|
||||
@@ -189,6 +234,36 @@ class _SendScreenState extends State<SendScreen> {
|
||||
color: DmColors.ok,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// ── Who the Miler will ring ──
|
||||
//
|
||||
// Every failed collection starts the same way: the rider arrives,
|
||||
// calls, and nobody picks up. So the number is on the screen the
|
||||
// customer confirms on, not buried in a map fold — and it is the
|
||||
// real one.
|
||||
//
|
||||
// It is the **account's** number, and it is not editable here for a
|
||||
// good reason: that is the only number the rider gets. The backend
|
||||
// derives it from the booking's customer and `GET /miler/bookings`
|
||||
// carries exactly one phone field. A picker that let the customer
|
||||
// change it on this screen would be a control over something the app
|
||||
// does not control.
|
||||
//
|
||||
// A different handover person is offered on the pickup map instead,
|
||||
// and shown here as a second line, worded so nobody expects the
|
||||
// rider's call button to dial it.
|
||||
_ContactCard(
|
||||
name: app.customer?.name ?? '',
|
||||
phone: app.customer?.phone ?? '',
|
||||
handoverName: app.draftContactName,
|
||||
handoverPhone: app.draftContactPhone,
|
||||
onTap: () => Navigator.of(context).push(
|
||||
bookingRoute<Place?>(
|
||||
BookingRoutes.pickup,
|
||||
(_) => const PickupLocationScreen(),
|
||||
),
|
||||
),
|
||||
),
|
||||
// ── Only the long way asks for this ──
|
||||
//
|
||||
// One Touch books on a state, a district and a window; the door is
|
||||
@@ -454,6 +529,103 @@ class _RouteHead extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The pickup contact, as the rider will see it.
|
||||
class _ContactCard extends StatelessWidget {
|
||||
const _ContactCard({
|
||||
required this.name,
|
||||
required this.phone,
|
||||
required this.handoverName,
|
||||
required this.handoverPhone,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String phone;
|
||||
final String? handoverName;
|
||||
final String? handoverPhone;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final handover = (handoverPhone ?? '').trim();
|
||||
final who = (handoverName ?? '').trim();
|
||||
|
||||
return DmCard(
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 14, 14),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 2),
|
||||
child: Icon(
|
||||
LucideIcons.phone,
|
||||
size: 18,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('YOUR MILER WILL CALL', style: DmText.eyebrow),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
phone.isEmpty ? 'Your account number' : phone,
|
||||
style: DmText.cardTitle.copyWith(fontSize: 15.5),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (name.isNotEmpty)
|
||||
Text(
|
||||
name,
|
||||
style: DmText.small.copyWith(color: DmColors.ink3),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (handover.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
who.isEmpty
|
||||
? '$handover is handing it over. We will pass '
|
||||
'this on — your Miler still calls the '
|
||||
'number above.'
|
||||
: '$who ($handover) is handing it over. We will '
|
||||
'pass this on — your Miler still calls the '
|
||||
'number above.',
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12.5,
|
||||
height: 1.45,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 2),
|
||||
child: Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 17,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Stop extends StatelessWidget {
|
||||
const _Stop({
|
||||
required this.label,
|
||||
|
||||
@@ -14,10 +14,9 @@ import '../widgets/book_orb.dart';
|
||||
import '../widgets/misc.dart';
|
||||
import 'booking/booking_routes.dart';
|
||||
import 'booking/send_screen.dart';
|
||||
import 'sheets/destination_sheet.dart';
|
||||
import 'sheets/drop_address_sheet.dart';
|
||||
import 'sheets/pickup_sheet.dart';
|
||||
import 'sheets/place_search_sheet.dart';
|
||||
import 'sheets/window_sheet.dart';
|
||||
import 'tracking_screen.dart';
|
||||
|
||||
/// Home — two questions, and nothing else on the screen.
|
||||
@@ -53,28 +52,37 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
|
||||
/// BOOK. The one action on this screen, and the whole flow's front door.
|
||||
///
|
||||
/// ── Where, then when, then the review ──
|
||||
/// ── One sheet, then the review ──
|
||||
///
|
||||
/// Two sheets in sequence: states → districts, then the pickup window. The
|
||||
/// second rises while the first is still falling, so the handover reads as
|
||||
/// one surface changing its question rather than as a sheet failing and
|
||||
/// another arriving.
|
||||
/// It was two sheets in sequence — destination, then window — with a dismiss
|
||||
/// between them. [showPickupSheet] asks both on one surface that never
|
||||
/// leaves, which is what the old comment here hoped the sequence would look
|
||||
/// like and it never did.
|
||||
///
|
||||
/// Dismissing the window sheet is allowed. The review screen carries the
|
||||
/// window as its own row — brand-coloured while it is unanswered — and the
|
||||
/// button stays disabled until there is one, so backing out of the second
|
||||
/// sheet costs a tap rather than the booking.
|
||||
/// The full form asks for doors afterwards rather than in the middle: the
|
||||
/// two questions every booking needs are answered first and together, and
|
||||
/// the addresses — which only the detailed path wants — come last, next to
|
||||
/// the review that shows them.
|
||||
Future<void> _book({bool detailed = false}) async {
|
||||
final app = AppScope.read(context);
|
||||
final navigator = Navigator.of(context);
|
||||
|
||||
setState(() => _handingOver = true);
|
||||
try {
|
||||
final places = await showDestinationSheet(context);
|
||||
if (places == null || places.isEmpty || !mounted) return;
|
||||
final asked = await showPickupSheet(context);
|
||||
if (asked == null || asked.places.isEmpty || !mounted) return;
|
||||
|
||||
app.startBooking(detailed: detailed);
|
||||
app.setDestinations(places);
|
||||
app.setDestinations(asked.places);
|
||||
// ── The slot is re-selected, not assumed ──
|
||||
//
|
||||
// [WindowPicker] writes it as the customer taps, which is right when it
|
||||
// is opened on its own from Review. Opened as the second step of the
|
||||
// pickup sheet it writes it *before* the draft exists, and
|
||||
// `startBooking` clears `draftSlotId` — so the booking arrived at Review
|
||||
// with no window and a disabled button. Writing it after the reset is
|
||||
// the only ordering that holds for both callers.
|
||||
app.selectSlot(asked.slot);
|
||||
|
||||
// The full form asks for the door before it asks for the window, and it
|
||||
// asks once per destination: a visit that fans out to three places is
|
||||
@@ -93,9 +101,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
await showWindowSheet(context);
|
||||
if (!mounted) return;
|
||||
|
||||
await navigator.push(
|
||||
bookingRoute<void>(BookingRoutes.send, (_) => const SendScreen()),
|
||||
);
|
||||
@@ -292,6 +297,25 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
duration: 620.ms,
|
||||
curve: Curves.easeOutBack,
|
||||
),
|
||||
// ── Where we deliver, said before anybody
|
||||
// commits ──
|
||||
//
|
||||
// A customer cannot tell from a sphere whether
|
||||
// Doormile goes where their parcel needs to go,
|
||||
// and the only place that answered it was two
|
||||
// taps inside the booking flow. One line says
|
||||
// it, and tapping it opens the same list the
|
||||
// booking uses — the answer itself rather than a
|
||||
// second screen repeating it.
|
||||
//
|
||||
// Inside the scroll column, not pinned under it.
|
||||
// Pinned, it took 32 points Home did not have
|
||||
// and overflowed the fold by 11. In here its
|
||||
// room is reserved out of the sphere's glow by
|
||||
// [_captionRoom], which is the mechanism that
|
||||
// already stops the caption falling off — so the
|
||||
// sphere gives way and nothing is clipped.
|
||||
_ReachLine(onTap: () => _book()),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -318,7 +342,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
// nothing between them the sentence read as the card's heading.
|
||||
// It costs the sphere 14 points of glow and nothing else — the
|
||||
// field is whatever is left over now. See [DmBookOrb.field].
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 14, DmSpace.pad, 0),
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 10, DmSpace.pad, 0),
|
||||
child: _PickDropForm(
|
||||
pickup: app.pickup?.title,
|
||||
onPickup: () async {
|
||||
@@ -337,9 +361,20 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// The line under the sphere, plus its gap. Reserved out of the sphere's
|
||||
/// field rather than laid out after it and hoped for.
|
||||
static const _captionRoom = 28.0;
|
||||
/// What sits under the sphere inside the scroll column — its caption, and
|
||||
/// the reach line — plus their gaps.
|
||||
///
|
||||
/// Reserved out of the sphere's field rather than laid out after it and
|
||||
/// hoped for. Anything added below the sphere has to be counted here or it
|
||||
/// will be the thing that falls off the bottom, silently, which has now
|
||||
/// happened often enough to be the rule rather than the exception.
|
||||
///
|
||||
/// Reserved whether or not the reach line renders. When it does not — the
|
||||
/// first seconds of a cold start, before the serviceable cities land — the
|
||||
/// sphere simply has room to spare, which nobody can see. A field that grew
|
||||
/// the moment the cities arrived would be a sphere that resized itself on
|
||||
/// screen for no reason the customer could name.
|
||||
static const _captionRoom = 28.0 + _ReachLine.height;
|
||||
|
||||
static String _firstName(String? name) {
|
||||
final first = (name ?? '').trim().split(RegExp(r'\s+')).first;
|
||||
@@ -367,6 +402,86 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// "Delivering to 11 cities across 3 states" — the reach, in one line.
|
||||
///
|
||||
/// ── Why this is not a page ──
|
||||
///
|
||||
/// The question it answers is binary: *do you go where I need?* A customer who
|
||||
/// gets "yes" needs nothing more, and one who gets "no" needs the list, which
|
||||
/// is one tap away and is the same list the booking step uses. A screen of its
|
||||
/// own would be a second copy of that list to keep in step.
|
||||
///
|
||||
/// Counted from the cache rather than fetched: the numbers are the serviceable
|
||||
/// set the destination step already loaded, so this cannot disagree with what
|
||||
/// the customer sees when they open it.
|
||||
class _ReachLine extends StatelessWidget {
|
||||
const _ReachLine({required this.onTap});
|
||||
|
||||
/// Fixed, and the same number [_HomeScreenState._captionRoom] reserves.
|
||||
///
|
||||
/// A line whose height depended on its own padding and text metrics was a
|
||||
/// number I had to estimate twice and got wrong both times — the fold
|
||||
/// overflowed by eleven points, which is a precise amount of nothing to
|
||||
/// debug. Declared once, consumed once.
|
||||
static const double height = 40;
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cities = AppScope.of(context).cachedCities;
|
||||
if (cities == null || cities.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final states = <String>{for (final c in cities) c.state.code}.length;
|
||||
final label =
|
||||
'Delivering to ${cities.length} '
|
||||
'${cities.length == 1 ? 'city' : 'cities'}'
|
||||
'${states > 1 ? ' across $states states' : ''}';
|
||||
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: Center(
|
||||
child: InkResponse(
|
||||
onTap: onTap,
|
||||
radius: 24,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
// ── The label gives way; the row does not push ──
|
||||
//
|
||||
// A `Row` of `min` size takes its intrinsic width and overflows
|
||||
// whatever it is in when that is wider — and this sentence gets
|
||||
// longer as Doormile opens more states, and longer again at a
|
||||
// large text scale. It is one quiet line: it may ellipsize, but it
|
||||
// must never be the thing that breaks the fold.
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.mapPinned,
|
||||
size: 13,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12.5,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The live booking, at the head of Home.
|
||||
///
|
||||
/// ── Smaller, and without the courier ──
|
||||
|
||||
@@ -8,13 +8,17 @@ import '../tokens.dart';
|
||||
import '../widgets/buttons.dart';
|
||||
import '../widgets/cards.dart';
|
||||
import '../widgets/chrome.dart';
|
||||
import '../widgets/feedback.dart';
|
||||
import '../widgets/milestones.dart';
|
||||
import '../widgets/misc.dart';
|
||||
import '../widgets/pieces.dart';
|
||||
import '../widgets/route_rail.dart';
|
||||
import '../widgets/states.dart';
|
||||
import '../widgets/summary.dart';
|
||||
import 'booking/booking_routes.dart';
|
||||
import 'booking/send_screen.dart';
|
||||
import 'settings/settings_kit.dart';
|
||||
import 'settings/support_screen.dart';
|
||||
import 'sheets/window_sheet.dart';
|
||||
|
||||
/// A finished order, in full: where it went, what happened when, and exactly
|
||||
/// what it cost.
|
||||
@@ -23,6 +27,26 @@ class OrderDetailsScreen extends StatelessWidget {
|
||||
|
||||
final String reference;
|
||||
|
||||
/// Repeats a booking: same door, same destination, a new window.
|
||||
Future<void> _sendAgain(BuildContext context, Booking booking) async {
|
||||
final app = AppScope.read(context);
|
||||
app.startBookingFrom(booking, keepDestinations: true);
|
||||
|
||||
final slot = await showWindowSheet(context);
|
||||
if (slot == null || !context.mounted) {
|
||||
// Nothing is left half-started: a draft with a door and a destination
|
||||
// and no window would sit behind Home's form looking like a booking in
|
||||
// progress that the customer never began.
|
||||
app.startBooking();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
bookingRoute<void>(BookingRoutes.send, (_) => const SendScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context);
|
||||
@@ -336,13 +360,22 @@ class OrderDetailsScreen extends StatelessWidget {
|
||||
bottomNavigationBar: DmFooter(
|
||||
edge: true,
|
||||
children: [
|
||||
// ── The same parcel again, as one question ──
|
||||
//
|
||||
// People send to the same places repeatedly, and the whole of a
|
||||
// repeat is already on this screen: the door it left from and where
|
||||
// it went. Only the window is a fresh decision, so only the window
|
||||
// is asked.
|
||||
DmButton(
|
||||
label: 'Send another like this',
|
||||
icon: LucideIcons.repeat,
|
||||
iconLeading: true,
|
||||
onPressed: () => _sendAgain(context, booking),
|
||||
),
|
||||
DmButton(
|
||||
label: 'Need help with this order?',
|
||||
kind: DmButtonKind.outline,
|
||||
onPressed: () => DmToast.show(
|
||||
context,
|
||||
'Support chat is not available yet',
|
||||
),
|
||||
onPressed: () => pushSettings(context, const SupportScreen()),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
138
lib/ui/screens/settings/about_screen.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../../data/app_config.dart';
|
||||
import '../../tokens.dart';
|
||||
import '../../widgets/cards.dart';
|
||||
import '../../widgets/chrome.dart';
|
||||
import '../../widgets/inputs.dart';
|
||||
import 'settings_kit.dart';
|
||||
|
||||
/// About, and the policies — the two rows that used to toast "Opening
|
||||
/// doormile.com…" and open nothing.
|
||||
///
|
||||
/// ── What is real here and what is a build flag ──
|
||||
///
|
||||
/// The version, the platform and the licences are real: the first two come
|
||||
/// from [AppConfig], and the licence list is Flutter's own `showLicensePage`,
|
||||
/// which enumerates every package actually linked into this binary. Nothing
|
||||
/// about them can go stale.
|
||||
///
|
||||
/// The three links cannot be real without somebody confirming them. There is
|
||||
/// no terms URL in this repository — `doormile.com` appears only as the API
|
||||
/// host — and guessing `/terms` off it is the same class of mistake as
|
||||
/// inventing a support number. They come from `--dart-define` (DM_TERMS_URL,
|
||||
/// DM_PRIVACY_URL, DM_SITE_URL) and each row is simply absent until its URL is
|
||||
/// set, so the screen can never point at a 404.
|
||||
class AboutScreen extends StatelessWidget {
|
||||
const AboutScreen({super.key, this.title = 'About Doormile'});
|
||||
|
||||
/// Both Account rows land here. "Terms and policies" opens it scrolled to
|
||||
/// the same content under its own name rather than on a second screen
|
||||
/// holding two links — see the note in `account_screen.dart`.
|
||||
final String title;
|
||||
|
||||
bool get _hasLinks =>
|
||||
AppConfig.termsUrl.isNotEmpty ||
|
||||
AppConfig.privacyUrl.isNotEmpty ||
|
||||
AppConfig.siteUrl.isNotEmpty;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: DmColors.canvas,
|
||||
appBar: DmTopBar(title: title),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 8, DmSpace.pad, 32),
|
||||
children: [
|
||||
SettingsNote(
|
||||
icon: LucideIcons.package,
|
||||
title: 'Doormile Customer',
|
||||
body:
|
||||
'Book a Miler to collect a parcel from your door, and watch it '
|
||||
'the whole way. Version ${AppConfig.appVersion} on '
|
||||
'${AppConfig.platformHeader}.',
|
||||
),
|
||||
|
||||
if (_hasLinks) ...[
|
||||
const SizedBox(height: 18),
|
||||
const DmMicroHead('Policies', brand: false),
|
||||
DmRowGroup(
|
||||
children: [
|
||||
if (AppConfig.termsUrl.isNotEmpty)
|
||||
DmRow(
|
||||
icon: LucideIcons.fileText,
|
||||
label: 'Terms of service',
|
||||
note: 'What Doormile covers, and what it does not',
|
||||
trailing: const Icon(
|
||||
externalIcon,
|
||||
size: 16,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
onTap: () =>
|
||||
openLink(context, Uri.parse(AppConfig.termsUrl)),
|
||||
),
|
||||
if (AppConfig.privacyUrl.isNotEmpty)
|
||||
DmRow(
|
||||
icon: LucideIcons.lock,
|
||||
label: 'Privacy policy',
|
||||
note: 'What is collected, and why',
|
||||
trailing: const Icon(
|
||||
externalIcon,
|
||||
size: 16,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
onTap: () =>
|
||||
openLink(context, Uri.parse(AppConfig.privacyUrl)),
|
||||
),
|
||||
if (AppConfig.siteUrl.isNotEmpty)
|
||||
DmRow(
|
||||
icon: LucideIcons.globe,
|
||||
label: 'doormile.com',
|
||||
note: 'The website',
|
||||
trailing: const Icon(
|
||||
externalIcon,
|
||||
size: 16,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
onTap: () =>
|
||||
openLink(context, Uri.parse(AppConfig.siteUrl)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 18),
|
||||
const DmMicroHead('Build', brand: false),
|
||||
DmRowGroup(
|
||||
children: [
|
||||
DmRow(
|
||||
icon: LucideIcons.tag,
|
||||
label: 'Version',
|
||||
value: AppConfig.appVersion,
|
||||
),
|
||||
DmRow(
|
||||
icon: LucideIcons.smartphone,
|
||||
label: 'Platform',
|
||||
value: AppConfig.platformHeader,
|
||||
),
|
||||
DmRow(
|
||||
icon: LucideIcons.scale,
|
||||
label: 'Open-source licences',
|
||||
note: 'Every package linked into this build',
|
||||
showChevron: true,
|
||||
// Flutter's own page, which reads the licence registry rather
|
||||
// than a list somebody has to remember to update.
|
||||
onTap: () => showLicensePage(
|
||||
context: context,
|
||||
applicationName: 'Doormile Customer',
|
||||
applicationVersion: AppConfig.appVersion,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
141
lib/ui/screens/settings/notifications_screen.dart
Normal file
@@ -0,0 +1,141 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../../state/app_scope.dart';
|
||||
import '../../tokens.dart';
|
||||
import '../../widgets/cards.dart';
|
||||
import '../../widgets/chrome.dart';
|
||||
import 'settings_kit.dart';
|
||||
|
||||
/// What Doormile tells you, and where it appears.
|
||||
///
|
||||
/// ── Why there is not a single toggle on this screen ──
|
||||
///
|
||||
/// The obvious build for a row labelled "Notifications" is a list of switches:
|
||||
/// push on, SMS on, promotions off. Every one of them would be a lie here.
|
||||
/// There is no notification-preference endpoint in the customer contract, and
|
||||
/// no push SDK is wired into the app at all — `AppState.registerPushToken`
|
||||
/// exists as a seam and its own comment says nothing calls it, because there is
|
||||
/// no token to hand over.
|
||||
///
|
||||
/// A switch that changes nothing is worse than no switch: it is a control the
|
||||
/// customer will trust, turn off, and then be annoyed by. So this screen
|
||||
/// answers the question the row actually asks — *how will I know what is
|
||||
/// happening to my parcel?* — and answers it with what is true today.
|
||||
///
|
||||
/// When push is wired in, the "not yet" card is the thing to delete and the
|
||||
/// toggles belong under it.
|
||||
class NotificationsScreen extends StatelessWidget {
|
||||
const NotificationsScreen({super.key});
|
||||
|
||||
/// The five moments the app has a state for. These are
|
||||
/// `CustomerMilestone`'s own stages, in order — not a list written here that
|
||||
/// could drift from the rail a customer is watching on the tracking screen.
|
||||
static const _moments = [
|
||||
(
|
||||
icon: LucideIcons.calendarCheck,
|
||||
title: 'Pickup booked',
|
||||
body: 'Your visit is arranged and a Miler is being assigned.',
|
||||
),
|
||||
(
|
||||
icon: LucideIcons.packageCheck,
|
||||
title: 'Order created',
|
||||
body: 'The parcel has been weighed and has a tracking number.',
|
||||
),
|
||||
(
|
||||
icon: LucideIcons.truck,
|
||||
title: 'In transit',
|
||||
body: 'It has left your city and is on its way.',
|
||||
),
|
||||
(
|
||||
icon: LucideIcons.mapPin,
|
||||
title: 'Out for delivery',
|
||||
body: 'It is with a Miler at the other end, going to the door.',
|
||||
),
|
||||
(
|
||||
icon: LucideIcons.circleCheck,
|
||||
title: 'Delivered',
|
||||
body: 'Handed over. The receipt is on the order.',
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context);
|
||||
final phone = app.customer?.phone ?? '';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: DmColors.canvas,
|
||||
appBar: const DmTopBar(title: 'Notifications'),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 8, DmSpace.pad, 32),
|
||||
children: [
|
||||
const SettingsNote(
|
||||
icon: LucideIcons.bellRing,
|
||||
title: 'Push is not switched on yet',
|
||||
body:
|
||||
'Until it is, an update appears in the app rather than on your '
|
||||
'lock screen. The live card at the top of Home shows anything '
|
||||
'currently moving, and Orders holds the full history.',
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
const DmMicroHead('What you get told', brand: false),
|
||||
DmCard(
|
||||
children: [
|
||||
for (var i = 0; i < _moments.length; i++)
|
||||
DmCardCell(
|
||||
padding: EdgeInsets.fromLTRB(16, i == 0 ? 14 : 12, 16, 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 1),
|
||||
child: Icon(
|
||||
_moments[i].icon,
|
||||
size: 17,
|
||||
color: DmColors.brand,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_moments[i].title,
|
||||
style: DmText.cardTitle.copyWith(fontSize: 15),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_moments[i].body,
|
||||
style: DmText.small.copyWith(
|
||||
color: DmColors.ink3,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (phone.isNotEmpty) ...[
|
||||
const SizedBox(height: 18),
|
||||
const DmMicroHead('Sent to', brand: false),
|
||||
SettingsNote(
|
||||
icon: LucideIcons.smartphone,
|
||||
title: phone,
|
||||
body:
|
||||
'The number on this account. Changing it means signing in '
|
||||
'with the new one.',
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
120
lib/ui/screens/settings/payment_screen.dart
Normal file
@@ -0,0 +1,120 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../../state/app_scope.dart';
|
||||
import '../../tokens.dart';
|
||||
import '../../widgets/cards.dart';
|
||||
import '../../widgets/chrome.dart';
|
||||
import 'settings_kit.dart';
|
||||
|
||||
/// How paying works, which is the question "Payment methods" is really asking.
|
||||
///
|
||||
/// ── Why this does not manage anything ──
|
||||
///
|
||||
/// A row called "Payment methods" in most apps opens a list of saved cards with
|
||||
/// an *Add* button. There is nothing to list here and nothing to add: Doormile
|
||||
/// takes no money at booking time and stores no instrument. `paymentMethod`
|
||||
/// arrives from `estimateFare` as a string the server decides per booking —
|
||||
/// "UPI · Cash at doorstep" — and the app's own job is to print it.
|
||||
///
|
||||
/// So a management screen would be a screen of empty state forever. What a
|
||||
/// customer wants from this row is the thing the model makes surprising: *when
|
||||
/// do I pay, and how much, given nobody has weighed anything yet.* That is
|
||||
/// answerable, and it is what this says.
|
||||
class PaymentScreen extends StatelessWidget {
|
||||
const PaymentScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context);
|
||||
|
||||
// The most recent method the server actually quoted, rather than a list
|
||||
// written here. Absent until a booking has been priced, which is honest:
|
||||
// before that the app genuinely does not know.
|
||||
String? quoted;
|
||||
for (final booking in app.orders) {
|
||||
final method = booking.fare?.paymentMethod;
|
||||
if (method != null && method.trim().isNotEmpty) {
|
||||
quoted = method.trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: DmColors.canvas,
|
||||
appBar: const DmTopBar(title: 'Payment'),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 8, DmSpace.pad, 32),
|
||||
children: [
|
||||
const SettingsNote(
|
||||
icon: LucideIcons.shieldCheck,
|
||||
title: 'Nothing is charged when you book',
|
||||
body:
|
||||
'No card is saved and no money moves until your parcel has '
|
||||
'been weighed at your door. Cancelling before a Miler collects '
|
||||
'costs nothing.',
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
const DmMicroHead('How it works', brand: false),
|
||||
DmCard(
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||
child: Column(
|
||||
children: const [
|
||||
SettingsStep(
|
||||
index: 1,
|
||||
title: 'You book',
|
||||
body:
|
||||
'Review shows an estimate as a range, because the '
|
||||
'price depends on a weight nobody has taken yet.',
|
||||
),
|
||||
SettingsStep(
|
||||
index: 2,
|
||||
title: 'The Miler weighs it',
|
||||
body:
|
||||
'At your door, in front of you. That is the moment '
|
||||
'the estimate becomes a price.',
|
||||
),
|
||||
SettingsStep(
|
||||
index: 3,
|
||||
title: 'You pay',
|
||||
body:
|
||||
'By the method confirmed on Review before you '
|
||||
'booked. It is on the receipt afterwards.',
|
||||
last: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (quoted != null) ...[
|
||||
const SizedBox(height: 18),
|
||||
const DmMicroHead('Accepted on your bookings', brand: false),
|
||||
SettingsNote(
|
||||
icon: LucideIcons.wallet,
|
||||
title: quoted,
|
||||
body:
|
||||
'What the server quoted for your most recent booking. It is '
|
||||
'confirmed per booking on the Review screen, so check there '
|
||||
'if it matters for a particular parcel.',
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 18),
|
||||
const DmMicroHead('Where to find a receipt', brand: false),
|
||||
const SettingsNote(
|
||||
icon: LucideIcons.receipt,
|
||||
title: 'On the order itself',
|
||||
body:
|
||||
'Open Orders, choose a delivered parcel, and the receipt shows '
|
||||
'the weight it was charged on, the amount and the method.',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
195
lib/ui/screens/settings/settings_kit.dart
Normal file
@@ -0,0 +1,195 @@
|
||||
/// Shared parts of the four screens behind Account's rows.
|
||||
///
|
||||
/// ── What those rows used to do ──
|
||||
///
|
||||
/// Nothing, or a toast. `Notifications` and `Payment methods` had no `onTap` at
|
||||
/// all — a chevron pointing at a page that did not exist. `Help and support`
|
||||
/// answered with "Support is on the way", and both policy rows with "Opening
|
||||
/// doormile.com…", which opened nothing. Five rows that looked like doors.
|
||||
///
|
||||
/// ── The rule these pages are written to ──
|
||||
///
|
||||
/// Say only what is true of this app today. There is no notification-preference
|
||||
/// endpoint, no stored payment method and no push SDK wired in, so none of
|
||||
/// these pages pretends to manage any of that. What they do instead is answer
|
||||
/// the question the row's label asks — *how do I get told?*, *how do I pay?* —
|
||||
/// which is what a customer opening them actually wants, and which happens to
|
||||
/// be answerable honestly.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../tokens.dart';
|
||||
import '../../widgets/cards.dart';
|
||||
import '../../widgets/feedback.dart';
|
||||
import '../booking/booking_routes.dart';
|
||||
|
||||
|
||||
/// Opens one of these pages, with the app's own transition.
|
||||
Future<void> pushSettings(BuildContext context, Widget page) {
|
||||
return Navigator.of(context).push(
|
||||
DmPageRoute<void>(builder: (_) => page),
|
||||
);
|
||||
}
|
||||
|
||||
/// A block of explanatory text under a heading, as a card.
|
||||
class SettingsNote extends StatelessWidget {
|
||||
const SettingsNote({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.body,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String body;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DmCard(
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 17, color: DmColors.brand),
|
||||
const SizedBox(width: 9),
|
||||
Expanded(
|
||||
child: Text(title, style: DmText.cardTitle),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
body,
|
||||
style: DmText.small.copyWith(color: DmColors.ink3, height: 1.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A numbered step in a sequence the customer will actually see happen.
|
||||
class SettingsStep extends StatelessWidget {
|
||||
const SettingsStep({
|
||||
super.key,
|
||||
required this.index,
|
||||
required this.title,
|
||||
required this.body,
|
||||
this.last = false,
|
||||
});
|
||||
|
||||
final int index;
|
||||
final String title;
|
||||
final String body;
|
||||
final bool last;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 26,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
color: DmColors.brandSoft,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
'$index',
|
||||
style: DmText.tiny.copyWith(
|
||||
color: DmColors.brand,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
// The thread belongs to the step above the join, so the last
|
||||
// one ends clean.
|
||||
if (!last)
|
||||
const Expanded(
|
||||
child: VerticalDivider(
|
||||
width: 1,
|
||||
thickness: 1,
|
||||
color: DmColors.border,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: 1, bottom: last ? 0 : 18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: DmText.cardTitle.copyWith(fontSize: 15)),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
body,
|
||||
style: DmText.small.copyWith(
|
||||
color: DmColors.ink3,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a URL, and says so when it cannot.
|
||||
///
|
||||
/// ── Why the failure is reported ──
|
||||
///
|
||||
/// `launchUrl` returns false when nothing on the device can handle the scheme —
|
||||
/// no mail client, no dialler, no browser. Ignoring that return is how the old
|
||||
/// rows behaved: the tap appeared to work and nothing happened. A customer who
|
||||
/// taps a phone number and sees no dialler needs to be told, because the next
|
||||
/// thing they will do is assume the app is broken.
|
||||
Future<void> openLink(BuildContext context, Uri uri, {String? failure}) async {
|
||||
var opened = false;
|
||||
try {
|
||||
opened = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} catch (_) {
|
||||
opened = false;
|
||||
}
|
||||
if (opened || !context.mounted) return;
|
||||
|
||||
DmToast.show(context, failure ?? "Couldn't open that on this device");
|
||||
}
|
||||
|
||||
/// Copies text and confirms it, because a copy with no feedback is a tap that
|
||||
/// did nothing as far as the customer can tell.
|
||||
Future<void> copyText(
|
||||
BuildContext context,
|
||||
String text, {
|
||||
required String confirmation,
|
||||
}) async {
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
if (context.mounted) DmToast.show(context, confirmation);
|
||||
}
|
||||
|
||||
/// The glyph for a row that leaves the app.
|
||||
const IconData externalIcon = LucideIcons.externalLink;
|
||||
166
lib/ui/screens/settings/support_screen.dart
Normal file
@@ -0,0 +1,166 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../../data/app_config.dart';
|
||||
import '../../../state/app_scope.dart';
|
||||
import '../../tokens.dart';
|
||||
import '../../widgets/buttons.dart';
|
||||
import '../../widgets/cards.dart';
|
||||
import '../../widgets/chrome.dart';
|
||||
import '../../widgets/inputs.dart';
|
||||
import 'settings_kit.dart';
|
||||
|
||||
/// Help — what the customer can do themselves, and how to reach a person.
|
||||
///
|
||||
/// ── The contact block is missing on purpose ──
|
||||
///
|
||||
/// This row used to answer with a toast reading "Support is on the way", which
|
||||
/// was a sentence about a thing that was not happening. The obvious fix is a
|
||||
/// phone number — except there is no Doormile support number anywhere in this
|
||||
/// repository, and inventing one replaces a fake toast with a line that rings
|
||||
/// nowhere, which is worse: a customer with a problem would sit listening to it
|
||||
/// fail.
|
||||
///
|
||||
/// So the contact block renders only when [AppConfig.hasSupportContact] is
|
||||
/// true, and that comes from `--dart-define`. Fill in DM_SUPPORT_PHONE or
|
||||
/// DM_SUPPORT_EMAIL and the block appears, wired to the dialler and the mail
|
||||
/// client. Until then this screen does the part it genuinely can: point at the
|
||||
/// things in the app that solve the four problems people actually write in
|
||||
/// about.
|
||||
class SupportScreen extends StatelessWidget {
|
||||
const SupportScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: DmColors.canvas,
|
||||
appBar: const DmTopBar(title: 'Help and support'),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 8, DmSpace.pad, 32),
|
||||
children: [
|
||||
const DmMicroHead('Do it yourself', top: 0, brand: false),
|
||||
DmRowGroup(
|
||||
children: [
|
||||
DmRow(
|
||||
icon: LucideIcons.mapPinned,
|
||||
label: 'Where is my parcel?',
|
||||
note: 'Open it in Orders for a live rail and the Miler',
|
||||
showChevron: true,
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
),
|
||||
DmRow(
|
||||
icon: LucideIcons.phoneCall,
|
||||
label: 'Reach the Miler collecting it',
|
||||
note: 'Their number is on the tracking screen while active',
|
||||
showChevron: true,
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
),
|
||||
DmRow(
|
||||
icon: LucideIcons.circleX,
|
||||
label: 'Cancel a pickup',
|
||||
note: 'Free until a Miler has collected it',
|
||||
showChevron: true,
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
),
|
||||
DmRow(
|
||||
icon: LucideIcons.mapPin,
|
||||
label: 'Wrong pickup address',
|
||||
note: 'Change it on Home before the Miler arrives',
|
||||
showChevron: true,
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (AppConfig.hasSupportContact) ...[
|
||||
const SizedBox(height: 18),
|
||||
const DmMicroHead('Talk to us', brand: false),
|
||||
DmRowGroup(
|
||||
children: [
|
||||
if (AppConfig.supportPhone.isNotEmpty)
|
||||
DmRow(
|
||||
icon: LucideIcons.phone,
|
||||
label: 'Call the courier desk',
|
||||
note: AppConfig.supportPhone,
|
||||
showChevron: true,
|
||||
onTap: () => openLink(
|
||||
context,
|
||||
Uri(scheme: 'tel', path: AppConfig.supportPhone),
|
||||
failure: 'No dialler on this device',
|
||||
),
|
||||
),
|
||||
if (AppConfig.supportEmail.isNotEmpty)
|
||||
DmRow(
|
||||
icon: LucideIcons.mail,
|
||||
label: 'Email us',
|
||||
note: AppConfig.supportEmail,
|
||||
showChevron: true,
|
||||
onTap: () => openLink(
|
||||
context,
|
||||
Uri(
|
||||
scheme: 'mailto',
|
||||
path: AppConfig.supportEmail,
|
||||
// Prefilled, because the first thing any reply asks for
|
||||
// is the reference and the build.
|
||||
query: Uri.encodeFull(
|
||||
'subject=Doormile help'
|
||||
'&body=\n\n---\nApp ${AppConfig.appVersion}'
|
||||
'\nAccount ${app.customer?.phone ?? 'signed out'}',
|
||||
),
|
||||
),
|
||||
failure: 'No mail app on this device',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 18),
|
||||
const DmMicroHead('If you report a problem', brand: false),
|
||||
DmCard(
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Send these with it',
|
||||
style: DmText.cardTitle,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'A report with the build and the account on it can be '
|
||||
'chased. One without them usually cannot.',
|
||||
style: DmText.small.copyWith(
|
||||
color: DmColors.ink3,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: DmChipButton(
|
||||
label: 'Copy details',
|
||||
icon: LucideIcons.copy,
|
||||
onPressed: () => copyText(
|
||||
context,
|
||||
'Doormile Customer ${AppConfig.appVersion}\n'
|
||||
'Platform ${AppConfig.platformHeader}\n'
|
||||
'Account ${app.customer?.phone ?? 'signed out'}',
|
||||
confirmation: 'Details copied',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -46,24 +46,43 @@ import '../../widgets/states.dart';
|
||||
Future<List<CityOption>?> showDestinationSheet(BuildContext context) {
|
||||
return showDmSheet<List<CityOption>>(
|
||||
context: context,
|
||||
builder: (context) => const _DestinationSheet(),
|
||||
builder: (context) => DestinationPicker(
|
||||
onPicked: (cities) => Navigator.of(context).pop(cities),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _DestinationSheet extends StatefulWidget {
|
||||
const _DestinationSheet();
|
||||
/// The *where* half, without a sheet around it.
|
||||
///
|
||||
/// ── Why this is a widget and not a sheet ──
|
||||
///
|
||||
/// It is used twice: on its own behind Review's "Change", and as the first
|
||||
/// step of [showPickupSheet], where it shares one surface with the pickup
|
||||
/// window rather than closing so a second sheet can open over it. Extracting
|
||||
/// the body is what lets the merged flow reuse this list instead of owning a
|
||||
/// second copy of it.
|
||||
class DestinationPicker extends StatefulWidget {
|
||||
const DestinationPicker({
|
||||
super.key,
|
||||
required this.onPicked,
|
||||
this.showHeader = true,
|
||||
});
|
||||
|
||||
/// Called with the chosen cities. The caller decides what that means — pop a
|
||||
/// sheet, or move to the next step of one.
|
||||
final ValueChanged<List<CityOption>> onPicked;
|
||||
|
||||
/// False when the host is drawing the title and the close itself.
|
||||
final bool showHeader;
|
||||
|
||||
@override
|
||||
State<_DestinationSheet> createState() => _DestinationSheetState();
|
||||
State<DestinationPicker> createState() => _DestinationPickerState();
|
||||
}
|
||||
|
||||
class _DestinationSheetState extends State<_DestinationSheet> {
|
||||
class _DestinationPickerState extends State<DestinationPicker> {
|
||||
final _query = TextEditingController();
|
||||
String _q = '';
|
||||
|
||||
/// The state being browsed, or null while the states themselves are.
|
||||
ServiceArea? _openState;
|
||||
|
||||
/// Chosen districts, by district code, in the order they were picked.
|
||||
final _chosen = <String, CityOption>{};
|
||||
|
||||
@@ -73,27 +92,10 @@ class _DestinationSheetState extends State<_DestinationSheet> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _open(ServiceArea area) {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() {
|
||||
_openState = area;
|
||||
_query.clear();
|
||||
_q = '';
|
||||
});
|
||||
}
|
||||
|
||||
void _back() {
|
||||
setState(() {
|
||||
_openState = null;
|
||||
_query.clear();
|
||||
_q = '';
|
||||
});
|
||||
}
|
||||
|
||||
void _tap(CityOption city, {required bool multi, required int cap}) {
|
||||
HapticFeedback.selectionClick();
|
||||
if (!multi) {
|
||||
Navigator.of(context).pop([city]);
|
||||
widget.onPicked([city]);
|
||||
return;
|
||||
}
|
||||
final code = city.district.code;
|
||||
@@ -126,7 +128,6 @@ class _DestinationSheetState extends State<_DestinationSheet> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context);
|
||||
final browsing = _openState;
|
||||
final cap = app.limits.maxDestinations;
|
||||
final multi = app.allowsMultipleDestinations;
|
||||
|
||||
@@ -134,66 +135,44 @@ class _DestinationSheetState extends State<_DestinationSheet> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Back inside the sheet, not out of it: the customer is one level
|
||||
// into a question they are still answering, and dismissing the
|
||||
// whole thing to change their mind about a state would throw away
|
||||
// both the step they got right and everything they have ticked.
|
||||
if (browsing != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: InkResponse(
|
||||
onTap: _back,
|
||||
radius: 22,
|
||||
highlightShape: BoxShape.circle,
|
||||
splashColor: DmColors.brandSoft,
|
||||
highlightColor: DmColors.brandSoft,
|
||||
child: const SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: Icon(
|
||||
LucideIcons.arrowLeft,
|
||||
size: 20,
|
||||
color: DmColors.ink,
|
||||
),
|
||||
),
|
||||
if (widget.showHeader)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: DmSheetHeader(
|
||||
title: 'Where is it going?',
|
||||
// ── One level, and the subtitle says what the list is ──
|
||||
//
|
||||
// This used to open on states and make the customer pick one
|
||||
// to see any city, so the subtitle had to explain that tapping
|
||||
// a row opened it rather than chose it — a sentence that only
|
||||
// existed because the shape needed defending.
|
||||
//
|
||||
// The list is now every city Doormile serves, headed by state.
|
||||
// That is also the answer to "where do you deliver?", which is
|
||||
// the question a customer opening this actually has, and it
|
||||
// was previously two taps down.
|
||||
subtitle: multi
|
||||
? 'Every city we serve. Choose one or more.'
|
||||
: 'Every city we serve',
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: DmSheetHeader(
|
||||
title: browsing?.name ?? 'Where is it going?',
|
||||
// ── A line of orientation ──
|
||||
//
|
||||
// The sheet asked a question and then showed a list, and a
|
||||
// customer who has never used it cannot tell whether tapping
|
||||
// a state chooses it or opens it. One sentence says which,
|
||||
// and at the district step says how many they may pick.
|
||||
subtitle: browsing == null
|
||||
? 'Pick a state to see the cities it serves'
|
||||
: (app.allowsMultipleDestinations
|
||||
? 'Choose one or more districts'
|
||||
: 'Choose a district'),
|
||||
// A close, because a sheet that can only be dismissed by
|
||||
// dragging is a sheet somebody will get stuck in.
|
||||
DmIconButton(
|
||||
icon: LucideIcons.x,
|
||||
tooltip: 'Close',
|
||||
background: DmColors.surface,
|
||||
border: Colors.transparent,
|
||||
size: 38,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
// A close, because a sheet that can only be dismissed by dragging
|
||||
// is a sheet somebody will get stuck in.
|
||||
DmIconButton(
|
||||
icon: LucideIcons.x,
|
||||
tooltip: 'Close',
|
||||
background: DmColors.surface,
|
||||
border: Colors.transparent,
|
||||
size: 38,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
DmSearchField(
|
||||
controller: _query,
|
||||
hint: browsing == null
|
||||
? 'Search for a city'
|
||||
: 'Search in ${browsing.name}',
|
||||
hint: 'Search for a city',
|
||||
onChanged: (v) => setState(() => _q = v.trim().toLowerCase()),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
@@ -215,30 +194,45 @@ class _DestinationSheetState extends State<_DestinationSheet> {
|
||||
// Sized against the screen rather than fixed in points, and floored
|
||||
// above the skeleton's own height so it can never be the thing that
|
||||
// decides how tall this is.
|
||||
SizedBox(
|
||||
height: (MediaQuery.sizeOf(context).height * 0.44).clamp(240.0, 420.0),
|
||||
child: DmAsyncList<CityOption>(
|
||||
key: const ValueKey('destinationList'),
|
||||
// Three, not four: 3 x 68 plus gaps is 224, which fits inside the
|
||||
// smallest box the clamp above can produce. Four did not.
|
||||
skeletonRows: 3,
|
||||
// Skips the skeleton entirely when the cities are already known,
|
||||
// which after the first open they always are. A shimmer that
|
||||
// appears and vanishes inside one frame is noise.
|
||||
initialItems: app.cachedCities,
|
||||
load: ({bool refresh = false}) => app.loadCities(refresh: refresh),
|
||||
emptyIcon: LucideIcons.mapPinOff,
|
||||
emptyTitle: 'No cities open yet',
|
||||
emptyMessage:
|
||||
"We're not accepting new pickups right now. Please check "
|
||||
'back shortly.',
|
||||
builder: (context, cities) => SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
..._body(cities, multi: multi, cap: cap),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
// ── Flexible around the fixed box, not instead of it ──
|
||||
//
|
||||
// The box is what stops the sheet resizing as its content loads. The
|
||||
// `Flexible` is what stops it overflowing when something *else* on the
|
||||
// sheet grows — the multi-select confirm button appearing, a large
|
||||
// text scale, a two-line header — on a sheet already at the modal's
|
||||
// 86% cap. A strip of recent destinations sat here briefly and
|
||||
// overflowed it by twenty points, which is how the need was found.
|
||||
//
|
||||
// Loose fit: the box asks for its height and gets it whenever there is
|
||||
// room, and gives way rather than overflowing when there is not. Fixed
|
||||
// in the common case, never the thing that breaks the layout.
|
||||
Flexible(
|
||||
child: SizedBox(
|
||||
height: (MediaQuery.sizeOf(context).height * 0.44)
|
||||
.clamp(240.0, 420.0),
|
||||
child: DmAsyncList<CityOption>(
|
||||
key: const ValueKey('destinationList'),
|
||||
// Three, not four: 3 x 68 plus gaps is 224, which fits inside the
|
||||
// smallest box the clamp above can produce. Four did not.
|
||||
skeletonRows: 3,
|
||||
// Skips the skeleton entirely when the cities are already known,
|
||||
// which after the first open they always are. A shimmer that
|
||||
// appears and vanishes inside one frame is noise.
|
||||
initialItems: app.cachedCities,
|
||||
load: ({bool refresh = false}) => app.loadCities(refresh: refresh),
|
||||
emptyIcon: LucideIcons.mapPinOff,
|
||||
emptyTitle: 'No cities open yet',
|
||||
emptyMessage:
|
||||
"We're not accepting new pickups right now. Please check "
|
||||
'back shortly.',
|
||||
builder: (context, cities) => SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
..._body(cities, multi: multi, cap: cap),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -250,21 +244,31 @@ class _DestinationSheetState extends State<_DestinationSheet> {
|
||||
? 'Send to ${_chosen.values.first.district.name}'
|
||||
: 'Send to ${_chosen.length} places',
|
||||
icon: LucideIcons.arrowRight,
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(_chosen.values.toList()),
|
||||
onPressed: () => widget.onPicked(_chosen.values.toList()),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Every serviceable city, headed by its state.
|
||||
///
|
||||
/// ── One level, not two ──
|
||||
///
|
||||
/// This used to be a list of states that had to be opened to reach a city.
|
||||
/// Two levels of navigation, a back button inside the sheet, and — by the
|
||||
/// file's own admission — a search box that already cut across every state
|
||||
/// from the first step, which is the path anybody who knew their destination
|
||||
/// actually took. The browse existed for the customer who did *not* know,
|
||||
/// and that customer is better served by seeing the whole list.
|
||||
///
|
||||
/// The state is a heading now. It still groups, it still carries its mark,
|
||||
/// and it is no longer somewhere you have to go.
|
||||
List<Widget> _body(
|
||||
List<CityOption> cities, {
|
||||
required bool multi,
|
||||
required int cap,
|
||||
}) {
|
||||
final browsing = _openState;
|
||||
|
||||
Widget district(CityOption city) => _DistrictRow(
|
||||
city: city,
|
||||
multi: multi,
|
||||
@@ -272,13 +276,8 @@ class _DestinationSheetState extends State<_DestinationSheet> {
|
||||
onTap: () => _tap(city, multi: multi, cap: cap),
|
||||
);
|
||||
|
||||
// Searching from the top level goes straight to districts, across every
|
||||
// state. Searching inside one stays inside it.
|
||||
if (_q.isNotEmpty) {
|
||||
final pool = browsing == null
|
||||
? cities
|
||||
: cities.where((c) => c.state.code == browsing.code);
|
||||
final matches = pool
|
||||
final matches = cities
|
||||
.where((c) => c.label.toLowerCase().contains(_q))
|
||||
.toList();
|
||||
if (matches.isEmpty) {
|
||||
@@ -293,77 +292,50 @@ class _DestinationSheetState extends State<_DestinationSheet> {
|
||||
return [for (final city in matches) district(city)];
|
||||
}
|
||||
|
||||
if (browsing != null) {
|
||||
return [
|
||||
for (final city in cities.where((c) => c.state.code == browsing.code))
|
||||
district(city),
|
||||
];
|
||||
final out = <Widget>[];
|
||||
for (final area in _statesOf(cities)) {
|
||||
final inArea = cities.where((c) => c.state.code == area.code).toList();
|
||||
out.add(_StateHead(area: area, first: out.isEmpty));
|
||||
out.addAll(inArea.map(district));
|
||||
}
|
||||
|
||||
return [
|
||||
for (final area in _statesOf(cities))
|
||||
_StateRow(
|
||||
area: area,
|
||||
// The count is the reason to open it, and it is free: the list it
|
||||
// came from is the list the next step shows.
|
||||
count: cities.where((c) => c.state.code == area.code).length,
|
||||
// How many of this state's districts are already in, so a customer
|
||||
// who has ticked two in Tamil Nadu can see that from the top.
|
||||
chosen: cities
|
||||
.where(
|
||||
(c) =>
|
||||
c.state.code == area.code &&
|
||||
_chosen.containsKey(c.district.code),
|
||||
)
|
||||
.length,
|
||||
onTap: () => _open(area),
|
||||
),
|
||||
];
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/// One state: its mark, its name, and how many cities are open inside it.
|
||||
class _StateRow extends StatelessWidget {
|
||||
const _StateRow({
|
||||
required this.area,
|
||||
required this.count,
|
||||
required this.chosen,
|
||||
required this.onTap,
|
||||
});
|
||||
/// A state's name over the cities inside it. A heading, not a row: nothing
|
||||
/// happens when it is tapped, because there is nowhere left to go.
|
||||
class _StateHead extends StatelessWidget {
|
||||
const _StateHead({required this.area, required this.first});
|
||||
|
||||
final ServiceArea area;
|
||||
final int count;
|
||||
final int chosen;
|
||||
final VoidCallback onTap;
|
||||
final bool first;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _Row(
|
||||
onTap: onTap,
|
||||
// The mark, with nothing behind it. It sat in a 38pt washed-crimson
|
||||
// tile, so a list of six states was six pink squares in a column — six
|
||||
// containers and six spots of brand to hold six glyphs that read
|
||||
// perfectly well on the sheet's own surface.
|
||||
leading: Icon(stateMark(area.code), size: 21, color: DmColors.ink3),
|
||||
title: area.name,
|
||||
subtitle:
|
||||
'$count ${count == 1 ? 'city' : 'cities'}'
|
||||
'${area.transitTag == null ? '' : ' · ${area.transitTag}'}',
|
||||
// The number of districts already chosen in this state. It was a filled
|
||||
// crimson pill — a badge, for a figure that is only ever 1, 2 or 3.
|
||||
trailing: chosen == 0
|
||||
? null
|
||||
: Text(
|
||||
'$chosen',
|
||||
style: DmText.cardTitle.copyWith(color: DmColors.brand),
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(2, first ? 6 : 18, 2, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(stateMark(area.code), size: 15, color: DmColors.ink4),
|
||||
const SizedBox(width: 8),
|
||||
Text(area.name.toUpperCase(), style: DmText.eyebrow),
|
||||
if (area.transitTag != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
area.transitTag!,
|
||||
style: DmText.tiny.copyWith(color: DmColors.ink4),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One district. No mark — the name and the promise are the whole row, and a
|
||||
/// glyph beside every one of them would be decoration competing with the only
|
||||
/// two things on it that differ.
|
||||
class _DistrictRow extends StatelessWidget {
|
||||
const _DistrictRow({
|
||||
required this.city,
|
||||
@@ -419,13 +391,15 @@ class _Check extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The shape both rows share: a target, a title, a quiet line, a trailing mark.
|
||||
/// A city: a target, its name, its transit promise, and a tick.
|
||||
///
|
||||
/// It carried a `leading` slot for the state rows' marks. There are no state
|
||||
/// rows any more — the state is a heading — so the slot went with them.
|
||||
class _Row extends StatelessWidget {
|
||||
const _Row({
|
||||
required this.onTap,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.leading,
|
||||
this.trailing,
|
||||
this.selected = false,
|
||||
});
|
||||
@@ -433,7 +407,6 @@ class _Row extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget? leading;
|
||||
final Widget? trailing;
|
||||
final bool selected;
|
||||
|
||||
@@ -468,7 +441,6 @@ class _Row extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (leading != null) ...[leading!, const SizedBox(width: 14)],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
181
lib/ui/screens/sheets/pickup_sheet.dart
Normal file
@@ -0,0 +1,181 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../../data/models.dart';
|
||||
import '../../tokens.dart';
|
||||
import '../../widgets/buttons.dart';
|
||||
import '../../widgets/feedback.dart';
|
||||
import 'destination_sheet.dart';
|
||||
import 'window_sheet.dart';
|
||||
|
||||
/// What ONE TOUCH asks: where, and when — on one surface.
|
||||
///
|
||||
/// ── What this replaced ──
|
||||
///
|
||||
/// Two sheets in sequence. The destination sheet rose, the customer answered
|
||||
/// it, it fell, and the window sheet rose behind it. The file that drove it
|
||||
/// hoped this would read "as one surface changing its question rather than as
|
||||
/// a sheet failing and another arriving", and it did not: two present
|
||||
/// animations and a dismiss between them is three pieces of motion for two
|
||||
/// questions, and the customer sees a sheet go away before they have finished.
|
||||
///
|
||||
/// One sheet now, two steps inside it. The surface never leaves, it does not
|
||||
/// change height — see the fixed box in [DestinationPicker] — and stepping back
|
||||
/// is an arrow rather than a dismissal that would throw away the answer.
|
||||
///
|
||||
/// ── Why the destination is still asked at all ──
|
||||
///
|
||||
/// It is fair to ask why: a Miler rides to the customer's door, and nothing
|
||||
/// about that trip depends on where the parcel is going afterwards. The
|
||||
/// destination earns its place for three reasons that are not about the trip:
|
||||
///
|
||||
/// * It is the only thing that can price the job. `estimateFare` takes
|
||||
/// `stateCode`/`districtCode`; without them Review has no figure on it at
|
||||
/// all, not even a range.
|
||||
/// * It is the serviceability gate. The expensive thing here is a wasted
|
||||
/// rider trip, and finding out at the door that the parcel cannot be
|
||||
/// carried costs the whole visit.
|
||||
/// * Orders are minted per destination when the Miler completes pickup, so
|
||||
/// the booking has to know how many there are.
|
||||
///
|
||||
/// What was wrong was the ceremony, not the question. Two levels of browsing
|
||||
/// became one list, the list is now also the answer to "where do you deliver?",
|
||||
/// places sent to before are one tap, and both questions share a surface.
|
||||
class PickupRequest {
|
||||
const PickupRequest({required this.places, required this.slot});
|
||||
|
||||
final List<CityOption> places;
|
||||
final PickupSlot slot;
|
||||
}
|
||||
|
||||
/// Asks both questions and returns both answers, or null if dismissed.
|
||||
Future<PickupRequest?> showPickupSheet(BuildContext context) {
|
||||
return showDmSheet<PickupRequest>(
|
||||
context: context,
|
||||
builder: (context) => const _PickupSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
class _PickupSheet extends StatefulWidget {
|
||||
const _PickupSheet();
|
||||
|
||||
@override
|
||||
State<_PickupSheet> createState() => _PickupSheetState();
|
||||
}
|
||||
|
||||
class _PickupSheetState extends State<_PickupSheet> {
|
||||
List<CityOption>? _places;
|
||||
|
||||
bool get _onWhen => _places != null;
|
||||
|
||||
void _back() {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() => _places = null);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final places = _places;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Back to the first question, not out of the sheet. Dismissing to
|
||||
// change a destination would throw away the step already answered.
|
||||
if (_onWhen)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: InkResponse(
|
||||
onTap: _back,
|
||||
radius: 22,
|
||||
highlightShape: BoxShape.circle,
|
||||
splashColor: DmColors.brandSoft,
|
||||
highlightColor: DmColors.brandSoft,
|
||||
child: const SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: Icon(
|
||||
LucideIcons.arrowLeft,
|
||||
size: 20,
|
||||
color: DmColors.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: DmSheetHeader(
|
||||
title: _onWhen ? 'When should we come?' : 'Where is it going?',
|
||||
// On the second step the subtitle is the answer to the first,
|
||||
// so the customer can see what they are booking against
|
||||
// without going back for it.
|
||||
subtitle: _onWhen
|
||||
? _summarise(places!)
|
||||
: 'Every city we serve',
|
||||
),
|
||||
),
|
||||
DmIconButton(
|
||||
icon: LucideIcons.x,
|
||||
tooltip: 'Close',
|
||||
background: DmColors.surface,
|
||||
border: Colors.transparent,
|
||||
size: 38,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
// ── Flexible, because a cross-fade is briefly both steps ──
|
||||
//
|
||||
// `AnimatedSwitcher` stacks the outgoing child under the incoming one
|
||||
// and takes the height of the taller, so for the length of the fade
|
||||
// this is as tall as *where* plus nothing to give. Unconstrained that
|
||||
// is eleven points past the sheet's cap and a rendering assertion on a
|
||||
// frame no screenshot catches — it only ever exists mid-animation.
|
||||
//
|
||||
// Constrained, the overshoot clips for those few frames, which is
|
||||
// invisible under a cross-fade, and each step still gets the height it
|
||||
// asks for once it is the only one there.
|
||||
Flexible(
|
||||
child: AnimatedSwitcher(
|
||||
duration: DmMotion.base,
|
||||
switchInCurve: DmMotion.ease,
|
||||
switchOutCurve: DmMotion.ease,
|
||||
child: _onWhen
|
||||
? Padding(
|
||||
key: const ValueKey('when'),
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: SizedBox(
|
||||
height: _stepHeight(context),
|
||||
child: WindowPicker(
|
||||
confirmLabel: 'Confirm pickup',
|
||||
onPicked: (slot) => Navigator.of(context).pop(
|
||||
PickupRequest(places: places!, slot: slot),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: DestinationPicker(
|
||||
key: const ValueKey('where'),
|
||||
showHeader: false,
|
||||
onPicked: (chosen) => setState(() => _places = chosen),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// The same box [DestinationPicker] gives its list, so the two steps are the
|
||||
/// same height and the sheet does not jump when it changes its question.
|
||||
static double _stepHeight(BuildContext context) =>
|
||||
(MediaQuery.sizeOf(context).height * 0.44).clamp(240.0, 420.0);
|
||||
|
||||
static String _summarise(List<CityOption> places) {
|
||||
if (places.length == 1) return 'Going to ${places.first.label}';
|
||||
return 'Going to ${places.length} places';
|
||||
}
|
||||
}
|
||||
@@ -28,21 +28,11 @@ Future<PickupSlot?> showWindowSheet(BuildContext context) {
|
||||
);
|
||||
}
|
||||
|
||||
class _WindowSheet extends StatefulWidget {
|
||||
class _WindowSheet extends StatelessWidget {
|
||||
const _WindowSheet();
|
||||
|
||||
@override
|
||||
State<_WindowSheet> createState() => _WindowSheetState();
|
||||
}
|
||||
|
||||
class _WindowSheetState extends State<_WindowSheet> {
|
||||
String? _day;
|
||||
PickupSlot? _picked;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -58,8 +48,52 @@ class _WindowSheetState extends State<_WindowSheet> {
|
||||
),
|
||||
],
|
||||
),
|
||||
Flexible(
|
||||
child: DmAsyncList<PickupSlot>(
|
||||
// The same box the pickup sheet gives its steps, so this sheet is one
|
||||
// height whether it is loading, listing or empty — and so `Expanded`
|
||||
// inside the picker has something to divide.
|
||||
SizedBox(
|
||||
height: (MediaQuery.sizeOf(context).height * 0.44).clamp(240.0, 420.0),
|
||||
child: WindowPicker(
|
||||
onPicked: (slot) => Navigator.of(context).pop(slot),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The *when* half, without a sheet around it.
|
||||
///
|
||||
/// ── Why this is a widget and not a sheet ──
|
||||
///
|
||||
/// It is used twice: on its own behind Review's "Change", and as the second
|
||||
/// step of [showPickupSheet], where it shares one surface with the destination
|
||||
/// rather than arriving as a second sheet over the first. Extracting the body
|
||||
/// is what lets the merged flow reuse this instead of owning a second copy of
|
||||
/// day chips and slot rows that would drift.
|
||||
class WindowPicker extends StatefulWidget {
|
||||
const WindowPicker({super.key, required this.onPicked, this.confirmLabel});
|
||||
|
||||
/// Called with the chosen slot. The caller decides what that means — pop a
|
||||
/// sheet, or move to the next step of one.
|
||||
final ValueChanged<PickupSlot> onPicked;
|
||||
|
||||
/// Overrides the button's text. Null uses "Use 2:00 – 4:00 PM".
|
||||
final String? confirmLabel;
|
||||
|
||||
@override
|
||||
State<WindowPicker> createState() => _WindowPickerState();
|
||||
}
|
||||
|
||||
class _WindowPickerState extends State<WindowPicker> {
|
||||
String? _day;
|
||||
PickupSlot? _picked;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context);
|
||||
|
||||
return DmAsyncList<PickupSlot>(
|
||||
reloadToken: app.slotsEpoch,
|
||||
load: ({bool refresh = false}) => app.loadSlots(refresh: refresh),
|
||||
emptyIcon: LucideIcons.clock,
|
||||
@@ -81,64 +115,75 @@ class _WindowSheetState extends State<_WindowSheet> {
|
||||
_slotById(slots, app.draftSlotId) ??
|
||||
shown.where((s) => s.available).firstOrNull;
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// One row of days rather than a heading per day: the list
|
||||
// under it stays short enough to take in at a glance, which
|
||||
// is the whole reason this is a sheet.
|
||||
// The segments sit in a groove, which is what makes the
|
||||
// chosen one read as raised rather than as merely white.
|
||||
Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.groove,
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
),
|
||||
child: Row(
|
||||
// ── Days and the button are pinned; only the slots scroll ──
|
||||
//
|
||||
// This was one scroll view holding all three. At a large text
|
||||
// scale the slot rows grow until the button is below the fold,
|
||||
// and the primary action on a sheet must never be something you
|
||||
// have to find. The day switcher is a control too, so it stays
|
||||
// put as well.
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// One row of days rather than a heading per day: the list
|
||||
// under it stays short enough to take in at a glance, which
|
||||
// is the whole reason this is a sheet.
|
||||
// The segments sit in a groove, which is what makes the
|
||||
// chosen one read as raised rather than as merely white.
|
||||
Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.groove,
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final d in days) ...[
|
||||
if (d != days.first) const SizedBox(width: 4),
|
||||
DmChoiceChip(
|
||||
label: d,
|
||||
selected: d == day,
|
||||
expand: true,
|
||||
onTap: () => setState(() => _day = d),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final d in days) ...[
|
||||
if (d != days.first) const SizedBox(width: 4),
|
||||
DmChoiceChip(
|
||||
label: d,
|
||||
selected: d == day,
|
||||
expand: true,
|
||||
onTap: () => setState(() => _day = d),
|
||||
for (final slot in shown) ...[
|
||||
_SlotRow(
|
||||
slot: slot,
|
||||
selected: selected?.id == slot.id,
|
||||
onTap: () => setState(() => _picked = slot),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
for (final slot in shown) ...[
|
||||
_SlotRow(
|
||||
slot: slot,
|
||||
selected: selected?.id == slot.id,
|
||||
onTap: () => setState(() => _picked = slot),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
DmButton(
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
DmButton(
|
||||
label: selected == null
|
||||
? 'Choose a window'
|
||||
: 'Use ${selected.window}',
|
||||
: (widget.confirmLabel ?? 'Use ${selected.window}'),
|
||||
onPressed: selected == null
|
||||
? null
|
||||
: () {
|
||||
app.selectSlot(selected);
|
||||
Navigator.of(context).pop(selected);
|
||||
widget.onPicked(selected);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
66
pubspec.lock
@@ -716,6 +716,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.9"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.33"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.2"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.3"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.6"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -798,4 +862,4 @@ packages:
|
||||
version: "3.1.4"
|
||||
sdks:
|
||||
dart: ">=3.13.2 <4.0.0"
|
||||
flutter: ">=3.41.0"
|
||||
flutter: ">=3.44.0"
|
||||
|
||||
@@ -62,6 +62,7 @@ dependencies:
|
||||
flutter_secure_storage: ^9.2.4
|
||||
flutter_animate: ^4.5.2
|
||||
lottie: ^3.6.1
|
||||
url_launcher: ^6.3.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
168
test/account_rows_test.dart
Normal file
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:doormile_cx/data/dev_doormile_api.dart';
|
||||
import 'package:doormile_cx/data/doormile_api.dart';
|
||||
import 'package:doormile_cx/data/models.dart';
|
||||
import 'package:doormile_cx/state/app_scope.dart';
|
||||
import 'package:doormile_cx/state/app_state.dart';
|
||||
import 'package:doormile_cx/ui/screens/account_screen.dart';
|
||||
import 'package:doormile_cx/ui/screens/settings/about_screen.dart';
|
||||
import 'package:doormile_cx/ui/screens/settings/notifications_screen.dart';
|
||||
import 'package:doormile_cx/ui/screens/settings/payment_screen.dart';
|
||||
import 'package:doormile_cx/ui/screens/settings/support_screen.dart';
|
||||
import 'package:doormile_cx/ui/widgets/inputs.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// EVERY ROW ON ACCOUNT OPENS SOMETHING
|
||||
///
|
||||
/// Two of these rows had no `onTap` at all and three answered with a toast.
|
||||
/// All five drew a chevron, which is the affordance for "this opens a page" —
|
||||
/// so the screen was making a promise in five places and keeping it in one.
|
||||
///
|
||||
/// A tap that silently does nothing is the worst version of this, because the
|
||||
/// customer cannot tell it from a page that is slow: they press it again.
|
||||
///
|
||||
/// The check is deliberately shallow and broad. It does not care what is on
|
||||
/// each page; it cares that the tap goes somewhere, for every row, which is
|
||||
/// the thing that was wrong and the thing that quietly comes back when a row
|
||||
/// is added in a hurry.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
late AppState app;
|
||||
|
||||
setUp(() async {
|
||||
DoormileApi.overrideInstance(DevDoormileApi());
|
||||
app = AppState()
|
||||
// Straight onto the state rather than through the sign-in screens: this
|
||||
// is about what Account's rows do, and walking the OTP flow to find out
|
||||
// would make every one of these tests fail for reasons in another file.
|
||||
..customer = const Customer(
|
||||
id: 'c1',
|
||||
name: 'Joe Oommen',
|
||||
phone: '+91 98765 43210',
|
||||
email: 'joe@example.com',
|
||||
);
|
||||
await app.refreshOrders();
|
||||
});
|
||||
|
||||
tearDown(() => DoormileApi.overrideInstance(null));
|
||||
|
||||
Future<void> pumpAccount(WidgetTester tester) async {
|
||||
// A tall surface so the whole list is laid out. The default 800x600 window
|
||||
// leaves the About group below the fold, and `tap` on an off-screen widget
|
||||
// warns and misses rather than failing — which reads as "the row does not
|
||||
// work" and is the same symptom this file exists to catch.
|
||||
tester.view.physicalSize = const Size(1230, 9000);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
AppScope(
|
||||
state: app,
|
||||
child: const MaterialApp(home: AccountScreen()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Taps a row by its label and lets whatever it opens settle.
|
||||
Future<void> tapRow(WidgetTester tester, String label) async {
|
||||
final row = find.ancestor(
|
||||
of: find.text(label),
|
||||
matching: find.byType(DmRow),
|
||||
);
|
||||
expect(row, findsOneWidget, reason: 'no row labelled "$label"');
|
||||
await tester.tap(row);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
group('the rows that led nowhere', () {
|
||||
testWidgets('Notifications opens its page', (tester) async {
|
||||
await pumpAccount(tester);
|
||||
await tapRow(tester, 'Notifications');
|
||||
expect(find.byType(NotificationsScreen), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Payment methods opens its page', (tester) async {
|
||||
await pumpAccount(tester);
|
||||
await tapRow(tester, 'Payment methods');
|
||||
expect(find.byType(PaymentScreen), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Help and support opens its page', (tester) async {
|
||||
await pumpAccount(tester);
|
||||
await tapRow(tester, 'Help and support');
|
||||
expect(find.byType(SupportScreen), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('both policy rows open About', (tester) async {
|
||||
await pumpAccount(tester);
|
||||
|
||||
await tapRow(tester, 'Terms and policies');
|
||||
expect(find.byType(AboutScreen), findsOneWidget);
|
||||
// Under its own name, not under the other row's.
|
||||
expect(find.text('Terms and policies'), findsWidgets);
|
||||
|
||||
// Not `pageBack()`: that looks for a Material or Cupertino back button
|
||||
// and this app's bar carries its own `DmBackButton`.
|
||||
tester.state<NavigatorState>(find.byType(Navigator).first).pop();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tapRow(tester, 'About Doormile');
|
||||
expect(find.byType(AboutScreen), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('no row draws a chevron it cannot honour', () {
|
||||
testWidgets('every chevron row is tappable', (tester) async {
|
||||
await pumpAccount(tester);
|
||||
|
||||
final rows = tester.widgetList<DmRow>(find.byType(DmRow)).toList();
|
||||
expect(rows, isNotEmpty);
|
||||
|
||||
final broken = [
|
||||
for (final row in rows)
|
||||
if (row.showChevron && row.onTap == null) row.label,
|
||||
];
|
||||
expect(
|
||||
broken,
|
||||
isEmpty,
|
||||
reason: 'these rows point at a page and swallow the tap: $broken',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('the pages say only what is true', () {
|
||||
testWidgets('Notifications does not promise push', (tester) async {
|
||||
await pumpAccount(tester);
|
||||
await tapRow(tester, 'Notifications');
|
||||
|
||||
// No push SDK is wired in — `AppState.registerPushToken` has nothing to
|
||||
// hand over — so the screen states that rather than offering a switch.
|
||||
expect(find.textContaining('not switched on yet'), findsOneWidget);
|
||||
expect(find.byType(Switch), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Payment offers nothing to add or save', (tester) async {
|
||||
await pumpAccount(tester);
|
||||
await tapRow(tester, 'Payment methods');
|
||||
|
||||
expect(find.textContaining('Nothing is charged'), findsOneWidget);
|
||||
expect(find.textContaining('Add card'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Support shows no contact until one is configured', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpAccount(tester);
|
||||
await tapRow(tester, 'Help and support');
|
||||
|
||||
// AppConfig.supportPhone/Email are empty unless passed at build time.
|
||||
// A support line that rings nowhere is worse than none, so the block is
|
||||
// absent rather than invented.
|
||||
expect(find.text('Talk to us'), findsNothing);
|
||||
expect(find.text('Copy details'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -54,6 +54,7 @@ class FakeApi extends DoormileApi {
|
||||
required String? slotId,
|
||||
FareEstimate? fare,
|
||||
String? contactPhone,
|
||||
String? contactName,
|
||||
String? idempotencyKey,
|
||||
}) async {
|
||||
createKeys.add(idempotencyKey);
|
||||
@@ -698,28 +699,41 @@ void main() {
|
||||
'packageCount': 3,
|
||||
});
|
||||
|
||||
// The contract carries the recipient flat on the destination, beside the
|
||||
// codes, and spells a dropped pin `latitude`/`longitude`.
|
||||
// ── Nested under `details`, with the pin as `{lat, lng}` ──
|
||||
//
|
||||
// This asserted the flat shape and `latitude`/`longitude`. Both are
|
||||
// dropped by the server without an error, which is why the whole
|
||||
// full-address path was reaching the Miler with nothing on it.
|
||||
group.details.update(recipientName: 'Meera S', building: '12/A');
|
||||
group.details.pin = const MapPin(13.085, 80.21);
|
||||
expect(group.toBookingJson(), {
|
||||
'stateCode': 'TN',
|
||||
'districtCode': 'TN-MAA',
|
||||
'packageCount': 3,
|
||||
'recipientName': 'Meera S',
|
||||
'building': '12/A',
|
||||
'latitude': 13.085,
|
||||
'longitude': 80.21,
|
||||
'details': {
|
||||
'recipientName': 'Meera S',
|
||||
'building': '12/A',
|
||||
'pin': {'lat': 13.085, 'lng': 80.21},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('a PATCH sends nulls, because null is how a field is cleared', () {
|
||||
test('a PATCH clears with an empty string, not with null', () {
|
||||
// ── The old shape could not clear anything ──
|
||||
//
|
||||
// It sent `null` for an unset field and a comment said that cleared it.
|
||||
// The server writes only non-nil values, so `null` means "leave this
|
||||
// alone" — a customer could add a landmark and never remove one. `""`
|
||||
// clears a text field and `pin {0,0}` clears the pin.
|
||||
final details = DeliveryDetails(street: '12th Main');
|
||||
final patch = details.toPatchJson();
|
||||
expect(patch['street'], '12th Main');
|
||||
expect(patch.containsKey('landmark'), isTrue);
|
||||
expect(patch['landmark'], isNull);
|
||||
expect(patch.containsKey('latitude'), isTrue);
|
||||
expect(patch['landmark'], '');
|
||||
expect(patch['instructions'], '');
|
||||
expect(patch['pin'], {'lat': 0, 'lng': 0});
|
||||
|
||||
final pinned = DeliveryDetails(street: 'x')..pin = const MapPin(13.0, 80.0);
|
||||
expect(pinned.toPatchJson()['pin'], {'lat': 13.0, 'lng': 80.0});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,20 +99,20 @@ Future<void> openSend(WidgetTester tester) async {
|
||||
Finder inSheet(Finder matching) =>
|
||||
find.descendant(of: find.byType(BottomSheet), matching: matching);
|
||||
|
||||
/// Answers the destination sheet, then the window sheet behind it.
|
||||
/// Answers both questions on the pickup sheet.
|
||||
///
|
||||
/// The destination is reached by searching rather than by opening its state:
|
||||
/// search cuts across every state from the first step, and it is the path a
|
||||
/// customer who already knows where they are sending actually takes.
|
||||
/// ── One sheet now, not two ──
|
||||
///
|
||||
/// [takeWindow] false dismisses the window sheet rather than answering it,
|
||||
/// which leaves the booking without a slot and Confirm disabled — the review
|
||||
/// screen then carries "Choose a window" as its outstanding row.
|
||||
Future<void> pickCity(
|
||||
WidgetTester tester,
|
||||
String name, {
|
||||
bool takeWindow = true,
|
||||
}) async {
|
||||
/// It used to be a destination sheet that closed so a window sheet could open
|
||||
/// behind it, and this helper drove both. `showPickupSheet` asks them on one
|
||||
/// surface, so the second half is a step rather than a new sheet and its
|
||||
/// button reads "Confirm pickup" rather than "Use 2:00 – 4:00 PM".
|
||||
///
|
||||
/// The `takeWindow: false` branch went with the change: dismissing now drops
|
||||
/// the destination too, because there is only one sheet to dismiss, so a
|
||||
/// booking that reaches Review without a slot is no longer reachable from
|
||||
/// Home. Nothing passed it.
|
||||
Future<void> pickCity(WidgetTester tester, String name) async {
|
||||
await tester.enterText(find.byType(TextField).first, name);
|
||||
await settle(tester, 250);
|
||||
// A `Text` inside the sheet, explicitly.
|
||||
@@ -136,13 +136,10 @@ Future<void> pickCity(
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
// The window sheet follows the destination on its own.
|
||||
final use = find.textContaining('Use ');
|
||||
if (takeWindow && use.evaluate().isNotEmpty) {
|
||||
await tester.tap(use.first);
|
||||
await settle(tester);
|
||||
} else if (!takeWindow) {
|
||||
await tester.tapAt(const Offset(200, 40)); // dismiss through the scrim
|
||||
// The same sheet, now asking when.
|
||||
final confirm = find.text('Confirm pickup');
|
||||
if (confirm.evaluate().isNotEmpty) {
|
||||
await tester.tap(confirm.first);
|
||||
await settle(tester);
|
||||
}
|
||||
}
|
||||
@@ -278,20 +275,13 @@ void main() {
|
||||
await tester.tap(find.text('DROP'));
|
||||
await settle(tester);
|
||||
|
||||
// Destination first, then the door — the address sheet comes on its own.
|
||||
await tester.enterText(find.byType(TextField).first, 'Chennai');
|
||||
await settle(tester, 250);
|
||||
await tester.tap(
|
||||
inSheet(
|
||||
find.byWidgetPredicate((w) => w is Text && w.data == 'Chennai'),
|
||||
).first,
|
||||
);
|
||||
await settle(tester);
|
||||
final send = find.textContaining('Send to ');
|
||||
if (send.evaluate().isNotEmpty) {
|
||||
await tester.tap(send.first);
|
||||
await settle(tester);
|
||||
}
|
||||
// ── Where and when first, the door afterwards ──
|
||||
//
|
||||
// The address sheet used to land between the destination and the window.
|
||||
// Both paths now answer where-and-when on one sheet, and the full form's
|
||||
// extra question — which door — comes after it, next to the review that
|
||||
// shows it.
|
||||
await pickCity(tester, 'Chennai');
|
||||
|
||||
expect(state.draftDetailed, isTrue);
|
||||
expect(find.text('Where in Chennai?'), findsOneWidget);
|
||||
@@ -302,9 +292,7 @@ void main() {
|
||||
await tester.tap(find.text('Save address'));
|
||||
await settle(tester);
|
||||
|
||||
// Then the window, then the review — which carries the address as a row.
|
||||
await tester.tap(find.textContaining('Use ').first);
|
||||
await settle(tester);
|
||||
// Then the review, which carries the address as a row.
|
||||
expect(find.text('DROP ADDRESS'), findsOneWidget);
|
||||
|
||||
final group = state.draftDestinations.first;
|
||||
@@ -564,16 +552,38 @@ void main() {
|
||||
await settle(tester);
|
||||
expect(state.draftContactPhone, isNull);
|
||||
|
||||
// Changed, it is the number the Miler will ring at the door.
|
||||
// ── Changed, it is a note — not the number the Miler's button dials ──
|
||||
//
|
||||
// That is what this test used to claim. The rider's number is derived by
|
||||
// the backend from the booking's **account**: `GET /miler/bookings` sends
|
||||
// one phone field and nothing the create request carries can change it.
|
||||
// The handover person travels in `remarks` instead, with a name, and both
|
||||
// this screen and Review say so rather than implying otherwise.
|
||||
await tester.tap(find.text('PICKUP'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('Someone else is handing it over?'));
|
||||
await settle(tester);
|
||||
await tester.enterText(find.byType(TextField).first, '9003144518');
|
||||
|
||||
// By value, not by position: the fold asks for a name first now, and
|
||||
// `.first` quietly typed the phone number into it.
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, '9876543210'),
|
||||
'9003144518',
|
||||
);
|
||||
await tester.enterText(find.byType(TextField).first, 'Meera S');
|
||||
await settle(tester, 200);
|
||||
await tester.tap(find.text('Confirm pickup point'));
|
||||
await settle(tester);
|
||||
expect(state.draftContactPhone, '+91 9003144518');
|
||||
expect(state.draftContactName, 'Meera S');
|
||||
|
||||
// Review shows the account's number as the one that will be called, and
|
||||
// the handover person beside it.
|
||||
expect(find.text('YOUR MILER WILL CALL'), findsOneWidget);
|
||||
// Whatever shape the account's number is in — the dev backend echoes the
|
||||
// typed identifier, production sends E.164 — it is the one on the card.
|
||||
expect(find.text('+91 9876543210'), findsWidgets);
|
||||
expect(find.textContaining('Meera S'), findsWidgets);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
@@ -629,36 +639,35 @@ void main() {
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('the destination sheet browses state then district',
|
||||
testWidgets('the destination step is one flat list, not two levels',
|
||||
(tester) async {
|
||||
await signIn(tester);
|
||||
await openSend(tester);
|
||||
|
||||
// Step one is the states, not sixty district names.
|
||||
expect(inSheet(find.text('Tamil Nadu')), findsOneWidget);
|
||||
expect(inSheet(find.text('Kerala')), findsOneWidget);
|
||||
expect(inSheet(find.text('Chennai')), findsNothing);
|
||||
|
||||
// Step two is that state's districts.
|
||||
await tester.tap(find.text('Tamil Nadu'));
|
||||
await settle(tester);
|
||||
// ── What this replaced ──
|
||||
//
|
||||
// The sheet used to open on states and hide every city until one was
|
||||
// tapped, so the first thing a customer saw was a question about
|
||||
// geography rather than an answer about service. The old test asserted
|
||||
// that Chennai was *absent* from step one.
|
||||
//
|
||||
// Now the state is a heading and the cities are all there. The list is
|
||||
// also the answer to "where do you deliver?", which is why Home's reach
|
||||
// line opens this and not a second screen of its own.
|
||||
expect(inSheet(find.text('TAMIL NADU')), findsOneWidget);
|
||||
expect(inSheet(find.text('KERALA')), findsOneWidget);
|
||||
expect(inSheet(find.text('Chennai')), findsOneWidget);
|
||||
expect(inSheet(find.text('Coimbatore')), findsWidgets);
|
||||
expect(inSheet(find.text('Ernakulam')), findsOneWidget);
|
||||
|
||||
// Districts that are not open are never offered, and neither is a state
|
||||
// whose every district is closed.
|
||||
expect(inSheet(find.text('Madurai')), findsNothing);
|
||||
expect(inSheet(find.text('Puducherry')), findsNothing);
|
||||
|
||||
// Back out, then let search cut across states: Ernakulam is in Kerala and
|
||||
// the customer should not have to know that to find it.
|
||||
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
|
||||
await settle(tester);
|
||||
expect(inSheet(find.text('Chennai')), findsNothing);
|
||||
|
||||
// And search still cuts across states: Ernakulam is in Kerala and the
|
||||
// customer should not have to know that to find it.
|
||||
await pickCity(tester, 'Ernakulam');
|
||||
expect(find.textContaining('Ernakulam'), findsWidgets);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('orders split into their own rows once collected', (tester) async {
|
||||
@@ -820,11 +829,12 @@ void main() {
|
||||
api.flags.networkError = false;
|
||||
await tester.tap(find.text('Retry'));
|
||||
await settle(tester);
|
||||
// It comes back on the step it failed on: the states, not a blank sheet.
|
||||
expect(find.text('Tamil Nadu'), findsOneWidget);
|
||||
await tester.tap(find.text('Tamil Nadu'));
|
||||
await settle(tester);
|
||||
// It comes back with the list it failed to load, not a blank sheet. The
|
||||
// state is a heading now — upper-cased and not a step to tap through — so
|
||||
// the cities are there without a second navigation.
|
||||
expect(find.text('TAMIL NADU'), findsOneWidget);
|
||||
expect(find.text('Coimbatore'), findsWidgets);
|
||||
expect(find.text('Chennai'), findsWidgets);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
|
||||
@@ -211,14 +211,15 @@ void main() {
|
||||
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
|
||||
await _settle(tester);
|
||||
|
||||
// Booking: BOOK asks where, then the review screen asks the rest.
|
||||
// ── Booking: one sheet, two questions, then the review ──
|
||||
//
|
||||
// It was two sheets, and the shots followed them: states, then the
|
||||
// districts inside a state, then a separate window sheet. There are no
|
||||
// states to open now — the list is flat and the state is a heading — and
|
||||
// the window is the same sheet's second step.
|
||||
await tester.tap(find.text('ONE TOUCH'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '07-destination-states');
|
||||
|
||||
await tester.tap(find.text('Tamil Nadu'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '07b-destination-districts');
|
||||
await _shot(tester, '07-pickup-where');
|
||||
|
||||
await tester.tap(
|
||||
find
|
||||
@@ -230,13 +231,13 @@ void main() {
|
||||
);
|
||||
await _settle(tester);
|
||||
// The dev backend allows several destinations, so the row ticked rather
|
||||
// than closing the sheet — the choice is confirmed.
|
||||
// than advancing — the choice is confirmed.
|
||||
await tester.tap(find.textContaining('Send to ').first);
|
||||
await _settle(tester);
|
||||
// The window sheet follows the destination on its own: the customer
|
||||
// chooses their slot, it is never chosen for them.
|
||||
await _shot(tester, '09-window-sheet');
|
||||
await tester.tap(find.textContaining('Use ').first);
|
||||
// Same surface, second question. The customer chooses their slot; it is
|
||||
// never chosen for them.
|
||||
await _shot(tester, '07b-pickup-when');
|
||||
await tester.tap(find.text('Confirm pickup'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '08-send');
|
||||
|
||||
|
||||
136
test/home_reach_test.dart
Normal file
@@ -0,0 +1,136 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:doormile_cx/data/dev_doormile_api.dart';
|
||||
import 'package:doormile_cx/data/doormile_api.dart';
|
||||
import 'package:doormile_cx/data/models.dart';
|
||||
import 'package:doormile_cx/state/app_scope.dart';
|
||||
import 'package:doormile_cx/state/app_state.dart';
|
||||
import 'package:doormile_cx/ui/screens/home_screen.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// HOME SAYS HOW FAR DOORMILE GOES
|
||||
///
|
||||
/// One line under the sphere, and it has now failed to appear twice for two
|
||||
/// different reasons — both silent, because an absent widget looks exactly
|
||||
/// like a widget that decided it had nothing to say:
|
||||
///
|
||||
/// 1. `cachedCities` walked every state in `statesCache`, including the ones
|
||||
/// with no open districts, and `loadCities` only ever fetches districts for
|
||||
/// the open ones. It found a hole and reported nothing cached. `loadCities`
|
||||
/// returned eleven cities and the getter returned null beside it.
|
||||
/// 2. `loadCities` fills two caches and notifies nobody, so even once the data
|
||||
/// was there Home never rebuilt to read it.
|
||||
///
|
||||
/// Neither would be caught by anything that checks the widget tree in
|
||||
/// isolation, which is why this drives the real state object.
|
||||
///
|
||||
/// ── Two ways this file can hang rather than fail ──
|
||||
///
|
||||
/// The sphere breathes and its pulses repeat forever, so Home never settles:
|
||||
/// `pumpAndSettle` does not fail here, it runs until the test times out ten
|
||||
/// minutes later, which is a slow way to learn nothing. Fixed pumps instead.
|
||||
///
|
||||
/// And the dev API answers on a real timer while the widget binding's clock is
|
||||
/// fake, so awaiting a load directly inside a `testWidgets` body waits on a
|
||||
/// delay that will never elapse. Those go through `tester.runAsync`.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
late AppState app;
|
||||
|
||||
setUp(() {
|
||||
DoormileApi.overrideInstance(DevDoormileApi());
|
||||
app = AppState()
|
||||
..customer = const Customer(
|
||||
id: 'c1',
|
||||
name: 'Joe Oommen',
|
||||
phone: '+91 98765 43210',
|
||||
email: 'joe@example.com',
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() => DoormileApi.overrideInstance(null));
|
||||
|
||||
Future<void> pumpHome(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(1170, 2532);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
// Inside a Scaffold, because `HomeScreen` does not carry one — it is a tab
|
||||
// root and `ShellScreen` owns the Scaffold for all three. Without it there
|
||||
// is no `Material` for the ink responses and no bounded width for the
|
||||
// greeting row, and the screen fails for two reasons that have nothing to
|
||||
// do with what is being tested.
|
||||
await tester.pumpWidget(
|
||||
AppScope(
|
||||
state: app,
|
||||
child: const MaterialApp(home: Scaffold(body: HomeScreen())),
|
||||
),
|
||||
);
|
||||
for (var i = 0; i < 6; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
}
|
||||
}
|
||||
|
||||
group('the serviceable set', () {
|
||||
test('cachedCities agrees with loadCities', () async {
|
||||
expect(app.cachedCities, isNull, reason: 'nothing fetched yet');
|
||||
|
||||
final loaded = await app.loadCities();
|
||||
expect(loaded, isNotEmpty);
|
||||
expect(
|
||||
app.cachedCities?.length,
|
||||
loaded.length,
|
||||
reason: 'the synchronous view must be the same set as the async one',
|
||||
);
|
||||
});
|
||||
|
||||
test('it offers no district the picker would not', () async {
|
||||
final loaded = await app.loadCities();
|
||||
final cached = app.cachedCities!;
|
||||
|
||||
final loadedCodes = {for (final c in loaded) c.district.code};
|
||||
final cachedCodes = {for (final c in cached) c.district.code};
|
||||
expect(cachedCodes, loadedCodes);
|
||||
});
|
||||
});
|
||||
|
||||
group('the reach line', () {
|
||||
testWidgets('is absent until the cities are known', (tester) async {
|
||||
await pumpHome(tester);
|
||||
expect(find.textContaining('Delivering to'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('states the reach once they are', (tester) async {
|
||||
// `runAsync`, not a bare await: the dev API answers on a real timer and
|
||||
// the widget binding's clock is fake, so awaiting it directly inside a
|
||||
// `testWidgets` body waits for a delay that will never elapse.
|
||||
await tester.runAsync(() => app.loadCities());
|
||||
await pumpHome(tester);
|
||||
|
||||
final line = find.textContaining('Delivering to');
|
||||
expect(line, findsOneWidget);
|
||||
|
||||
final text = tester.widget<Text>(line).data!;
|
||||
final cities = app.cachedCities!;
|
||||
final states = <String>{for (final c in cities) c.state.code}.length;
|
||||
expect(text, contains('${cities.length}'));
|
||||
expect(text, contains('$states states'));
|
||||
});
|
||||
|
||||
testWidgets('a rebuild after the warm-up is what makes it appear', (
|
||||
tester,
|
||||
) async {
|
||||
// The order that used to fail: Home is already on screen when the
|
||||
// cities land. Nothing re-reads the cache unless the state says so.
|
||||
await pumpHome(tester);
|
||||
expect(find.textContaining('Delivering to'), findsNothing);
|
||||
|
||||
await tester.runAsync(() => app.loadCities());
|
||||
app.notifyListeners();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.textContaining('Delivering to'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -121,7 +121,7 @@ void main() {
|
||||
expect(fare.routeKm, 348.5);
|
||||
});
|
||||
|
||||
test('a booking sends the contract shape, flat and with a contact', () async {
|
||||
test('a booking nests its destination details', () async {
|
||||
final t = _api({
|
||||
'reference': 'DM-482913',
|
||||
'stage': 'booked',
|
||||
@@ -186,20 +186,40 @@ void main() {
|
||||
final body = t.sent.body;
|
||||
final pickup = body['pickup'] as Map<String, dynamic>;
|
||||
expect(pickup['lat'], 13.0827);
|
||||
expect(pickup['contactName'], 'Alex Kumar');
|
||||
expect(pickup['contactPhone'], '+919876543210');
|
||||
|
||||
// ── The pickup carries no contact ──
|
||||
//
|
||||
// It used to send `contactName` and `contactPhone` here and this test
|
||||
// asserted them. The create contract's pickup is `{title, sub, lat, lng}`;
|
||||
// extra keys are dropped without an error, and the rider's number is
|
||||
// derived by the backend from the account. Two fields written on every
|
||||
// booking and read by nobody.
|
||||
expect(pickup.containsKey('contactName'), isFalse);
|
||||
expect(pickup.containsKey('contactPhone'), isFalse);
|
||||
|
||||
final destination =
|
||||
(body['destinations'] as List).single as Map<String, dynamic>;
|
||||
expect(destination['recipientName'], 'Priya S');
|
||||
expect(destination['building'], '12/A');
|
||||
// Flat, not nested under `details`.
|
||||
expect(destination.containsKey('details'), isFalse);
|
||||
// And the note is not repeated here — it is the visit's `remarks`.
|
||||
expect(destination.containsKey('instructions'), isFalse);
|
||||
|
||||
// Per-destination instructions become the visit's one `remarks` line.
|
||||
expect(body['remarks'], 'Handle with care');
|
||||
// ── Nested, not flat ──
|
||||
//
|
||||
// The previous shape spread these across the destination and this test
|
||||
// asserted `details` was absent. The contract nests them, and a
|
||||
// destination's extra keys are dropped silently — so the whole
|
||||
// full-address path was being accepted with a 201 and thrown away.
|
||||
//
|
||||
// NOT yet confirmed against the running server. The `lat`/`lng` fix was
|
||||
// proven with two identical requests one apart; this deserves the same
|
||||
// before it is trusted in production.
|
||||
final details = destination['details'] as Map<String, dynamic>;
|
||||
expect(details['recipientName'], 'Priya S');
|
||||
expect(details['building'], '12/A');
|
||||
expect(details['street'], 'MG Road');
|
||||
// The note has its own field per destination; it is not the visit's line.
|
||||
expect(details['instructions'], 'Handle with care');
|
||||
expect(destination.containsKey('recipientName'), isFalse);
|
||||
|
||||
// `remarks` is now only the handover person, and there is none here.
|
||||
expect(body.containsKey('remarks'), isFalse);
|
||||
|
||||
// And the response — which names the state and district but sends no
|
||||
// codes — must still render as a destination.
|
||||
|
||||
157
test/repeat_booking_test.dart
Normal file
@@ -0,0 +1,157 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:doormile_cx/data/dev_doormile_api.dart';
|
||||
import 'package:doormile_cx/data/doormile_api.dart';
|
||||
import 'package:doormile_cx/data/models.dart';
|
||||
import 'package:doormile_cx/state/app_scope.dart';
|
||||
import 'package:doormile_cx/state/app_state.dart';
|
||||
import 'package:doormile_cx/ui/screens/booking/send_screen.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// SENDING A SECOND PARCEL
|
||||
///
|
||||
/// One booking carries one destination while the server caps it there, so a
|
||||
/// customer with two parcels for two places books twice. What that used to
|
||||
/// cost them was the whole flow twice — confirm, walk back to Home, re-answer
|
||||
/// the door they had not moved from, the window, everything.
|
||||
///
|
||||
/// [AppState.startBookingFrom] is what makes the second one short. These pin
|
||||
/// what it carries and, more importantly, what it refuses to.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
late AppState app;
|
||||
late Booking previous;
|
||||
|
||||
setUp(() async {
|
||||
DoormileApi.overrideInstance(DevDoormileApi());
|
||||
app = AppState()
|
||||
..customer = const Customer(
|
||||
id: 'c1',
|
||||
name: 'Joe Oommen',
|
||||
phone: '+91 98765 43210',
|
||||
email: 'joe@example.com',
|
||||
);
|
||||
await app.refreshOrders();
|
||||
previous = app.orders.first;
|
||||
});
|
||||
|
||||
tearDown(() => DoormileApi.overrideInstance(null));
|
||||
|
||||
group('what a repeat carries', () {
|
||||
test('the door, always', () {
|
||||
app.startBookingFrom(previous);
|
||||
expect(app.draftPickup?.title, previous.pickup.title);
|
||||
expect(app.draftPickup?.lat, previous.pickup.lat);
|
||||
expect(app.draftPickup?.lng, previous.pickup.lng);
|
||||
});
|
||||
|
||||
test('the destination only when asked', () {
|
||||
// "Send another from here" — same door, new route.
|
||||
app.startBookingFrom(previous);
|
||||
expect(
|
||||
app.draftDestinations.first.destination.districtCode,
|
||||
isNull,
|
||||
reason: 'the customer is choosing somewhere new',
|
||||
);
|
||||
|
||||
// "Send another like this" — same door, same route.
|
||||
app.startBookingFrom(previous, keepDestinations: true);
|
||||
expect(
|
||||
app.draftDestinations.first.destination.districtCode,
|
||||
previous.destinations.first.destination.districtCode,
|
||||
);
|
||||
expect(
|
||||
app.draftDestinations.first.packageCount,
|
||||
previous.destinations.first.packageCount,
|
||||
);
|
||||
});
|
||||
|
||||
test('never the window', () {
|
||||
// ── The one field a repeat must not assume ──
|
||||
//
|
||||
// A slot fills up. Pinning the second booking to the first one's window
|
||||
// would be refused at confirm with nothing the customer could act on —
|
||||
// and it is a genuinely fresh decision anyway: the first parcel going at
|
||||
// 2pm says nothing about when they want the next visit.
|
||||
expect(previous.slotId, isNotEmpty);
|
||||
app.startBookingFrom(previous, keepDestinations: true);
|
||||
expect(app.draftSlotId, isNull);
|
||||
});
|
||||
|
||||
test('a copy, not the booking itself', () {
|
||||
app.startBookingFrom(previous, keepDestinations: true);
|
||||
app.draftDestinations.first.packageCount = 9;
|
||||
|
||||
expect(
|
||||
previous.destinations.first.packageCount,
|
||||
isNot(9),
|
||||
reason: 'editing the draft must not rewrite the order it came from',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('the cap is obeyed', () {
|
||||
test('a repeat never carries more destinations than the server allows', () {
|
||||
final many = Booking(
|
||||
reference: 'DM-999999',
|
||||
pickup: previous.pickup,
|
||||
destinations: [
|
||||
for (var i = 0; i < 5; i++)
|
||||
DestinationGroup(
|
||||
destination: Destination(
|
||||
stateCode: 'TN',
|
||||
districtCode: 'TN-$i',
|
||||
stateName: 'Tamil Nadu',
|
||||
districtName: 'District $i',
|
||||
),
|
||||
),
|
||||
],
|
||||
slotId: previous.slotId,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
app.startBookingFrom(many, keepDestinations: true);
|
||||
expect(
|
||||
app.draftDestinations.length,
|
||||
lessThanOrEqualTo(app.limits.maxDestinations),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
group('the one-destination cap explains itself', () {
|
||||
/// Review with a draft on it, at whatever cap the server is advertising.
|
||||
Future<void> pumpReview(WidgetTester tester, {required int cap}) async {
|
||||
tester.view.physicalSize = const Size(1230, 9000);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
app.limits = BookingLimits(maxPackages: 20, maxDestinations: cap);
|
||||
app.startBookingFrom(previous, keepDestinations: true);
|
||||
|
||||
await tester.pumpWidget(
|
||||
AppScope(state: app, child: const MaterialApp(home: SendScreen())),
|
||||
);
|
||||
for (var i = 0; i < 6; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('says so when the server allows one', (tester) async {
|
||||
// ── Why this needs a test at all ──
|
||||
//
|
||||
// The dev backend advertises five, so this line never renders in the
|
||||
// screenshots or anywhere else a person would look. Production
|
||||
// advertises one. A conditional nobody exercises is a conditional that
|
||||
// quietly never fires.
|
||||
await pumpReview(tester, cap: 1);
|
||||
expect(find.textContaining('One destination per pickup'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('stays quiet when it allows more', (tester) async {
|
||||
await pumpReview(tester, cap: 5);
|
||||
expect(find.textContaining('One destination per pickup'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
Before Width: | Height: | Size: 418 KiB After Width: | Height: | Size: 418 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 164 KiB |
BIN
test/snapshots/07-pickup-where.png
Normal file
|
After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 186 KiB |
BIN
test/snapshots/07b-pickup-when.png
Normal file
|
After Width: | Height: | Size: 252 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 259 KiB |
@@ -21,9 +21,8 @@ pairs = [
|
||||
('05-account.png', '08-account.png'),
|
||||
('06-tracking.png', '09-tracking.png'),
|
||||
('06b-cancel-sheet.png', '10-cancel-pickup.png'),
|
||||
('07-destination-states.png', '11-destination-states.png'),
|
||||
('07b-destination-districts.png', '12-destination-districts.png'),
|
||||
('09-window-sheet.png', '13-pickup-window.png'),
|
||||
('07-pickup-where.png', '11-pickup-where.png'),
|
||||
('07b-pickup-when.png', '12-pickup-when.png'),
|
||||
('08-send.png', '14-send-a-parcel.png'),
|
||||
('10-pickup-map.png', '15-pickup-map.png'),
|
||||
('11-booked.png', '16-pickup-booked.png'),
|
||||
|
||||
172
tool/verify_booking.sh
Executable file
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env bash
|
||||
# Answers Ask 1 of docs/BACKEND_CHANGES.md: does a destination's `details{}`
|
||||
# object actually survive `POST /customer/bookings`?
|
||||
#
|
||||
# It books one pickup with every detail field filled in, reads the booking back,
|
||||
# prints which fields came home, and cancels it again.
|
||||
#
|
||||
# tool/verify_booking.sh --phone 98XXXXXXXX --pin 1234 --name 'QA Test'
|
||||
#
|
||||
# ── The OTP path is gone ──
|
||||
#
|
||||
# This script used to offer OTP, which created nothing and was the safe option.
|
||||
# The backend has stopped OTP, so the only way in is set-pin/verify-pin — and
|
||||
# `set-pin` CREATES A PERMANENT ACCOUNT on any number that has none, with no
|
||||
# reset and no delete endpoint. Run this on staging, or on a number you own.
|
||||
# There is no third option until the backend answers §1 of
|
||||
# docs/BACKEND_CHANGES.md.
|
||||
#
|
||||
# ── This talks to production ──
|
||||
#
|
||||
# `AppConfig._stagingBase` is `_prodBase`; the backend has not named a staging
|
||||
# host. So the booking below is a real pickup in a real slot that a real Miler
|
||||
# can be dispatched to. It is cancelled at the end, but if the script dies in
|
||||
# between, cancel it by hand — the reference is printed as soon as it exists.
|
||||
set -euo pipefail
|
||||
|
||||
BASE="${DM_API_BASE:-https://api.doormile.com/api/v1}"
|
||||
PHONE=""; PIN=""; NAME="Doormile QA"; KEEP=0
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--phone) PHONE="$2"; shift 2 ;;
|
||||
--pin) PIN="$2"; shift 2 ;;
|
||||
--name) NAME="$2"; shift 2 ;;
|
||||
--base) BASE="$2"; shift 2 ;;
|
||||
--keep) KEEP=1; shift ;; # leave the booking standing
|
||||
*) echo "unknown option: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$PHONE" ] || { echo "need --phone" >&2; exit 2; }
|
||||
|
||||
C="$BASE/customer"
|
||||
say() { printf '\n\033[1m%s\033[0m\n' "$*"; }
|
||||
|
||||
# ---------------------------------------------------------------- 1. session
|
||||
|
||||
[ -n "$PIN" ] || { echo "need --pin (OTP is stopped; there is no other way in)" >&2; exit 2; }
|
||||
|
||||
# ── Try the existing account first ──
|
||||
#
|
||||
# `verify-pin` before `set-pin`, so a re-run on a number that already has a PIN
|
||||
# signs in rather than answering 409 — and so the account-creating call is only
|
||||
# ever reached when the account genuinely does not exist.
|
||||
say "1. Signing in"
|
||||
SESSION=$(curl -sS -X POST "$C/auth/verify-pin" -H 'Content-Type: application/json' \
|
||||
-d "$(printf '{"phone":"%s","pin":"%s"}' "$PHONE" "$PIN")")
|
||||
|
||||
if printf '%s' "$SESSION" | grep -qE 'pin_not_set|invalid_pin'; then
|
||||
REG=$(curl -sS -X POST "$C/auth/login" -H 'Content-Type: application/json' \
|
||||
-d "$(printf '{"phone":"%s"}' "$PHONE")")
|
||||
echo "$REG"
|
||||
printf '\n\033[1;33mThis will CREATE a permanent production account on %s.\033[0m\n' "$PHONE"
|
||||
printf 'There is no reset and no delete endpoint. Type yes to continue: '
|
||||
read -r CONFIRM
|
||||
[ "$CONFIRM" = "yes" ] || { echo "stopped."; exit 1; }
|
||||
SESSION=$(curl -sS -X POST "$C/auth/set-pin" -H 'Content-Type: application/json' \
|
||||
-d "$(printf '{"phone":"%s","new_pin":"%s","name":"%s"}' "$PHONE" "$PIN" "$NAME")")
|
||||
fi
|
||||
|
||||
TOKEN=$(printf '%s' "$SESSION" | python3 -c 'import json,sys; print((json.load(sys.stdin).get("data") or {}).get("accessToken",""))')
|
||||
[ -n "$TOKEN" ] || { echo "sign-in failed:"; printf '%s\n' "$SESSION"; exit 1; }
|
||||
printf '%s' "$SESSION" | python3 -c 'import json,sys; print("signed in as", (json.load(sys.stdin)["data"]["customer"]))'
|
||||
|
||||
AUTH=(-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json')
|
||||
|
||||
# ------------------------------------------------------------------ 2. slot
|
||||
|
||||
say "2. Picking the first available slot"
|
||||
SLOT=$(curl -sS "$C/pickup-slots?lat=11.0168&lng=76.9558" \
|
||||
| python3 -c 'import json,sys
|
||||
for s in json.load(sys.stdin)["data"]:
|
||||
if s.get("available"): print(s["id"], s["day"], s["window"]); break')
|
||||
SLOT_ID=$(printf '%s' "$SLOT" | cut -d' ' -f1)
|
||||
[ -n "$SLOT_ID" ] || { echo "no slot available"; exit 1; }
|
||||
echo "slot: $SLOT"
|
||||
|
||||
# --------------------------------------------------------------- 3. estimate
|
||||
|
||||
say "3. Fare estimate"
|
||||
curl -sS -X POST "$C/fare/estimate" "${AUTH[@]}" -d '{
|
||||
"pickup": {"lat": 11.0168, "lng": 76.9558},
|
||||
"destinations": [{"stateCode":"TN","districtCode":"TN-MAA","packageCount":1}]
|
||||
}' | python3 -m json.tool
|
||||
|
||||
# ------------------------------------------------- 4. book, with every field
|
||||
|
||||
say "4. Creating the booking — details NESTED, pin as {lat,lng}"
|
||||
BODY='{
|
||||
"slotId": "'"$SLOT_ID"'",
|
||||
"pickup": {
|
||||
"title": "12 Race Course Road",
|
||||
"sub": "Race Course, Coimbatore 641018",
|
||||
"lat": 11.0168, "lng": 76.9558
|
||||
},
|
||||
"destinations": [{
|
||||
"stateCode": "TN",
|
||||
"districtCode": "TN-MAA",
|
||||
"packageCount": 1,
|
||||
"details": {
|
||||
"recipientName": "VERIFY Recipient",
|
||||
"recipientPhone": "9003144518",
|
||||
"building": "VERIFY Building 12/A",
|
||||
"street": "VERIFY Street MG Road",
|
||||
"landmark": "VERIFY Landmark opp depot",
|
||||
"instructions": "VERIFY Instructions call first",
|
||||
"pin": {"lat": 13.0418, "lng": 80.2341}
|
||||
}
|
||||
}],
|
||||
"remarks": "VERIFY Remarks handover contact"
|
||||
}'
|
||||
CREATED=$(curl -sS -X POST "$C/bookings" "${AUTH[@]}" \
|
||||
-H "Idempotency-Key: verify-$(date +%s)-$$" -d "$BODY")
|
||||
REF=$(printf '%s' "$CREATED" | python3 -c 'import json,sys; print((json.load(sys.stdin).get("data") or {}).get("reference",""))')
|
||||
[ -n "$REF" ] || { echo "create failed:"; printf '%s' "$CREATED" | python3 -m json.tool; exit 1; }
|
||||
echo "booked: $REF <-- cancel this by hand if the script stops here"
|
||||
|
||||
# ------------------------------------------------------- 5. the actual answer
|
||||
|
||||
say "5. Reading it back — did the details survive?"
|
||||
curl -sS "$C/bookings/$REF" "${AUTH[@]}" | python3 -c '
|
||||
import json, sys
|
||||
b = json.load(sys.stdin)["data"]
|
||||
d = (b.get("destinations") or [{}])[0]
|
||||
det = d.get("details") or {}
|
||||
want = {
|
||||
"recipientName": "VERIFY Recipient",
|
||||
"recipientPhone": None,
|
||||
"building": "VERIFY Building 12/A",
|
||||
"street": "VERIFY Street MG Road",
|
||||
"landmark": "VERIFY Landmark opp depot",
|
||||
"instructions": "VERIFY Instructions call first",
|
||||
}
|
||||
print()
|
||||
ok = 0
|
||||
for k, expected in want.items():
|
||||
got = det.get(k)
|
||||
hit = got not in (None, "")
|
||||
ok += hit
|
||||
print((" PASS " if hit else " LOST ") + k.ljust(16) + repr(got))
|
||||
pin = det.get("pin")
|
||||
pin_ok = bool(pin) and pin.get("lat")
|
||||
ok += bool(pin_ok)
|
||||
print((" PASS " if pin_ok else " LOST ") + "pin".ljust(16) + repr(pin))
|
||||
print()
|
||||
print(" %d of 7 detail fields survived" % ok)
|
||||
print(" fare:", b.get("fare"))
|
||||
print(" stage:", b.get("stage"), " status:", b.get("status"))
|
||||
print()
|
||||
print("ASK 1 ANSWERED:", "nested details{} WORKS" if ok >= 5 else "nested details{} is NOT what the server reads")
|
||||
'
|
||||
|
||||
# ----------------------------------------------------------------- 6. tidy up
|
||||
|
||||
if [ "$KEEP" = "1" ]; then
|
||||
say "6. Leaving $REF standing (--keep). Cancel it yourself."
|
||||
else
|
||||
say "6. Cancelling $REF"
|
||||
curl -sS -X POST "$C/bookings/$REF/cancel" "${AUTH[@]}" \
|
||||
-d '{"reason":"payload verification"}' | python3 -m json.tool
|
||||
fi
|
||||
|
||||
say "Check the admin console for $REF before it disappears from the active tab."
|
||||