diff --git a/design/screens/04-home.png b/design/screens/04-home.png index 3dc8ec3..c9daeaf 100644 Binary files a/design/screens/04-home.png and b/design/screens/04-home.png differ diff --git a/design/screens/05-pickup-search.png b/design/screens/05-pickup-search.png index 4788564..5f48cfb 100644 Binary files a/design/screens/05-pickup-search.png and b/design/screens/05-pickup-search.png differ diff --git a/design/screens/06-orders-active.png b/design/screens/06-orders-active.png index d13e219..bf761c6 100644 Binary files a/design/screens/06-orders-active.png and b/design/screens/06-orders-active.png differ diff --git a/design/screens/07-orders-completed.png b/design/screens/07-orders-completed.png index 9be542b..63b8567 100644 Binary files a/design/screens/07-orders-completed.png and b/design/screens/07-orders-completed.png differ diff --git a/design/screens/08-account.png b/design/screens/08-account.png index 48d3deb..eda2c4c 100644 Binary files a/design/screens/08-account.png and b/design/screens/08-account.png differ diff --git a/design/screens/11-destination-states.png b/design/screens/11-destination-states.png deleted file mode 100644 index 77450b2..0000000 Binary files a/design/screens/11-destination-states.png and /dev/null differ diff --git a/design/screens/11-pickup-where.png b/design/screens/11-pickup-where.png new file mode 100644 index 0000000..8b6eeb8 Binary files /dev/null and b/design/screens/11-pickup-where.png differ diff --git a/design/screens/12-destination-districts.png b/design/screens/12-destination-districts.png deleted file mode 100644 index 9d8e72a..0000000 Binary files a/design/screens/12-destination-districts.png and /dev/null differ diff --git a/design/screens/12-pickup-when.png b/design/screens/12-pickup-when.png new file mode 100644 index 0000000..281e5d5 Binary files /dev/null and b/design/screens/12-pickup-when.png differ diff --git a/design/screens/13-pickup-window.png b/design/screens/13-pickup-window.png deleted file mode 100644 index 86d0768..0000000 Binary files a/design/screens/13-pickup-window.png and /dev/null differ diff --git a/design/screens/14-send-a-parcel.png b/design/screens/14-send-a-parcel.png index bf4e04f..a5d5900 100644 Binary files a/design/screens/14-send-a-parcel.png and b/design/screens/14-send-a-parcel.png differ diff --git a/design/screens/16-pickup-booked.png b/design/screens/16-pickup-booked.png index 1436df5..d50d1a8 100644 Binary files a/design/screens/16-pickup-booked.png and b/design/screens/16-pickup-booked.png differ diff --git a/design/screens/17-receipt.png b/design/screens/17-receipt.png index 746a7c9..a975e49 100644 Binary files a/design/screens/17-receipt.png and b/design/screens/17-receipt.png differ diff --git a/design/screens/README.md b/design/screens/README.md index 536a212..5bf6f31 100644 --- a/design/screens/README.md +++ b/design/screens/README.md @@ -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. | diff --git a/docs/BACKEND_CHANGES.md b/docs/BACKEND_CHANGES.md new file mode 100644 index 0000000..35a1e38 --- /dev/null +++ b/docs/BACKEND_CHANGES.md @@ -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. diff --git a/lib/data/app_config.dart b/lib/data/app_config.dart index f17d8f8..4344f57 100644 --- a/lib/data/app_config.dart +++ b/lib/data/app_config.dart @@ -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 { diff --git a/lib/data/dev_doormile_api.dart b/lib/data/dev_doormile_api.dart index 06617ee..1eed656 100644 --- a/lib/data/dev_doormile_api.dart +++ b/lib/data/dev_doormile_api.dart @@ -443,6 +443,7 @@ class DevDoormileApi extends DoormileApi { required String? slotId, FareEstimate? fare, String? contactPhone, + String? contactName, String? idempotencyKey, }) => _respond(() { if (destinations.isEmpty || diff --git a/lib/data/doormile_api.dart b/lib/data/doormile_api.dart index 1299af3..d41ae73 100644 --- a/lib/data/doormile_api.dart +++ b/lib/data/doormile_api.dart @@ -134,6 +134,7 @@ abstract class DoormileApi { required String? slotId, FareEstimate? fare, String? contactPhone, + String? contactName, String? idempotencyKey, }); diff --git a/lib/data/live_doormile_api.dart b/lib/data/live_doormile_api.dart index 7230887..7060864 100644 --- a/lib/data/live_doormile_api.dart +++ b/lib/data/live_doormile_api.dart @@ -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 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 diff --git a/lib/data/models.dart b/lib/data/models.dart index 9baae09..2d9c547 100644 --- a/lib/data/models.dart +++ b/lib/data/models.dart @@ -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 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 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 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 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 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 toBookingJson() => { - ...destination.toJson(), - 'packageCount': packageCount, - ...details.toJson(), - 'codAmount': ?codAmount, - }; + Map 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 diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 291eef1..3d585f2 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -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 _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 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 = []; - 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}) { diff --git a/lib/ui/screens/account_screen.dart b/lib/ui/screens/account_screen.dart index 08636d0..2cdd343 100644 --- a/lib/ui/screens/account_screen.dart +++ b/lib/ui/screens/account_screen.dart @@ -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()), ), ], ), diff --git a/lib/ui/screens/booking/confirmed_screen.dart b/lib/ui/screens/booking/confirmed_screen.dart index 4ce09e3..0f50d16 100644 --- a/lib/ui/screens/booking/confirmed_screen.dart +++ b/lib/ui/screens/booking/confirmed_screen.dart @@ -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 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 _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(BookingRoutes.send, (_) => const SendScreen()), + ); + } + @override Widget build(BuildContext context) { final app = AppScope.of(context); @@ -228,6 +254,24 @@ class _ConfirmedScreenState extends State bookingRoute(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, diff --git a/lib/ui/screens/booking/pickup_location_screen.dart b/lib/ui/screens/booking/pickup_location_screen.dart index d1c61de..4bc78ff 100644 --- a/lib/ui/screens/booking/pickup_location_screen.dart +++ b/lib/ui/screens/booking/pickup_location_screen.dart @@ -51,6 +51,11 @@ class _PickupLocationScreenState extends State { /// 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 { 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 { @override void dispose() { _contact.dispose(); + _contactName.dispose(); super.dispose(); } @@ -89,10 +96,13 @@ class _PickupLocationScreenState extends State { /// 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 _search(AppState app) async { @@ -185,6 +195,7 @@ class _PickupLocationScreenState extends State { resolving: resolving, denial: app.locationDenial, contact: _contact, + contactName: _contactName, contactOpen: _contactOpen, onToggleContact: () => setState(() => _contactOpen = !_contactOpen), @@ -214,7 +225,7 @@ class _PickupLocationScreenState extends State { 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), diff --git a/lib/ui/screens/booking/send_screen.dart b/lib/ui/screens/booking/send_screen.dart index 0a42958..b1efa23 100644 --- a/lib/ui/screens/booking/send_screen.dart +++ b/lib/ui/screens/booking/send_screen.dart @@ -168,6 +168,51 @@ class _SendScreenState extends State { ), ], ), + // ── 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 { 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( + 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, diff --git a/lib/ui/screens/home_screen.dart b/lib/ui/screens/home_screen.dart index 5b092da..43b5b43 100644 --- a/lib/ui/screens/home_screen.dart +++ b/lib/ui/screens/home_screen.dart @@ -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 { /// 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 _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 { } if (!mounted) return; - await showWindowSheet(context); - if (!mounted) return; - await navigator.push( bookingRoute(BookingRoutes.send, (_) => const SendScreen()), ); @@ -292,6 +297,25 @@ class _HomeScreenState extends State { 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 { // 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 { ); } - /// 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 { } } +/// "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 = {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 ── diff --git a/lib/ui/screens/order_details_screen.dart b/lib/ui/screens/order_details_screen.dart index 8454548..30a65b1 100644 --- a/lib/ui/screens/order_details_screen.dart +++ b/lib/ui/screens/order_details_screen.dart @@ -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 _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(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()), ), ], ), diff --git a/lib/ui/screens/settings/about_screen.dart b/lib/ui/screens/settings/about_screen.dart new file mode 100644 index 0000000..be0febb --- /dev/null +++ b/lib/ui/screens/settings/about_screen.dart @@ -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, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/ui/screens/settings/notifications_screen.dart b/lib/ui/screens/settings/notifications_screen.dart new file mode 100644 index 0000000..8a203b2 --- /dev/null +++ b/lib/ui/screens/settings/notifications_screen.dart @@ -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.', + ), + ], + ], + ), + ); + } +} diff --git a/lib/ui/screens/settings/payment_screen.dart b/lib/ui/screens/settings/payment_screen.dart new file mode 100644 index 0000000..57e8d4f --- /dev/null +++ b/lib/ui/screens/settings/payment_screen.dart @@ -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.', + ), + ], + ), + ); + } +} diff --git a/lib/ui/screens/settings/settings_kit.dart b/lib/ui/screens/settings/settings_kit.dart new file mode 100644 index 0000000..1a87bad --- /dev/null +++ b/lib/ui/screens/settings/settings_kit.dart @@ -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 pushSettings(BuildContext context, Widget page) { + return Navigator.of(context).push( + DmPageRoute(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 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 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; diff --git a/lib/ui/screens/settings/support_screen.dart b/lib/ui/screens/settings/support_screen.dart new file mode 100644 index 0000000..3fa6e43 --- /dev/null +++ b/lib/ui/screens/settings/support_screen.dart @@ -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', + ), + ), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/ui/screens/sheets/destination_sheet.dart b/lib/ui/screens/sheets/destination_sheet.dart index adc78af..0d7fad1 100644 --- a/lib/ui/screens/sheets/destination_sheet.dart +++ b/lib/ui/screens/sheets/destination_sheet.dart @@ -46,24 +46,43 @@ import '../../widgets/states.dart'; Future?> showDestinationSheet(BuildContext context) { return showDmSheet>( 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> onPicked; + + /// False when the host is drawing the title and the close itself. + final bool showHeader; @override - State<_DestinationSheet> createState() => _DestinationSheetState(); + State createState() => _DestinationPickerState(); } -class _DestinationSheetState extends State<_DestinationSheet> { +class _DestinationPickerState extends State { 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 = {}; @@ -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( - 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( + 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 _body( List 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 = []; + 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, diff --git a/lib/ui/screens/sheets/pickup_sheet.dart b/lib/ui/screens/sheets/pickup_sheet.dart new file mode 100644 index 0000000..c8aef4b --- /dev/null +++ b/lib/ui/screens/sheets/pickup_sheet.dart @@ -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 places; + final PickupSlot slot; +} + +/// Asks both questions and returns both answers, or null if dismissed. +Future showPickupSheet(BuildContext context) { + return showDmSheet( + context: context, + builder: (context) => const _PickupSheet(), + ); +} + +class _PickupSheet extends StatefulWidget { + const _PickupSheet(); + + @override + State<_PickupSheet> createState() => _PickupSheetState(); +} + +class _PickupSheetState extends State<_PickupSheet> { + List? _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 places) { + if (places.length == 1) return 'Going to ${places.first.label}'; + return 'Going to ${places.length} places'; + } +} diff --git a/lib/ui/screens/sheets/window_sheet.dart b/lib/ui/screens/sheets/window_sheet.dart index 6e432a7..0dcc7ac 100644 --- a/lib/ui/screens/sheets/window_sheet.dart +++ b/lib/ui/screens/sheets/window_sheet.dart @@ -28,21 +28,11 @@ Future 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( + // 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 onPicked; + + /// Overrides the button's text. Null uses "Use 2:00 – 4:00 PM". + final String? confirmLabel; + + @override + State createState() => _WindowPickerState(); +} + +class _WindowPickerState extends State { + String? _day; + PickupSlot? _picked; + + @override + Widget build(BuildContext context) { + final app = AppScope.of(context); + + return DmAsyncList( 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), + ], ); }, - ), - ), - ], ); } diff --git a/pubspec.lock b/pubspec.lock index 3b89e86..6991a3d 100644 --- a/pubspec.lock +++ b/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" diff --git a/pubspec.yaml b/pubspec.yaml index 678dcec..bf57eb7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: diff --git a/test/account_rows_test.dart b/test/account_rows_test.dart new file mode 100644 index 0000000..a87ae98 --- /dev/null +++ b/test/account_rows_test.dart @@ -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 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 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(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(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); + }); + }); +} diff --git a/test/api_integration_test.dart b/test/api_integration_test.dart index d9eacaf..251d554 100644 --- a/test/api_integration_test.dart +++ b/test/api_integration_test.dart @@ -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}); }); }); } diff --git a/test/booking_flow_test.dart b/test/booking_flow_test.dart index 4684512..b8fef02 100644 --- a/test/booking_flow_test.dart +++ b/test/booking_flow_test.dart @@ -99,20 +99,20 @@ Future 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 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 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 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); }); diff --git a/test/design_snapshot_test.dart b/test/design_snapshot_test.dart index 92a775a..90aeab8 100644 --- a/test/design_snapshot_test.dart +++ b/test/design_snapshot_test.dart @@ -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'); diff --git a/test/home_reach_test.dart b/test/home_reach_test.dart new file mode 100644 index 0000000..a480dcc --- /dev/null +++ b/test/home_reach_test.dart @@ -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 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(line).data!; + final cities = app.cachedCities!; + final states = {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); + }); + }); +} diff --git a/test/live_api_wire_test.dart b/test/live_api_wire_test.dart index 9d79e00..e60406f 100644 --- a/test/live_api_wire_test.dart +++ b/test/live_api_wire_test.dart @@ -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; 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; - 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; + 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. diff --git a/test/repeat_booking_test.dart b/test/repeat_booking_test.dart new file mode 100644 index 0000000..827ae02 --- /dev/null +++ b/test/repeat_booking_test.dart @@ -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 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); + }); + }); +} diff --git a/test/snapshots/03-home.png b/test/snapshots/03-home.png index 3dc8ec3..c9daeaf 100644 Binary files a/test/snapshots/03-home.png and b/test/snapshots/03-home.png differ diff --git a/test/snapshots/03b-pickup-search.png b/test/snapshots/03b-pickup-search.png index 4788564..5f48cfb 100644 Binary files a/test/snapshots/03b-pickup-search.png and b/test/snapshots/03b-pickup-search.png differ diff --git a/test/snapshots/04-orders.png b/test/snapshots/04-orders.png index d13e219..bf761c6 100644 Binary files a/test/snapshots/04-orders.png and b/test/snapshots/04-orders.png differ diff --git a/test/snapshots/04b-orders-completed.png b/test/snapshots/04b-orders-completed.png index 9be542b..63b8567 100644 Binary files a/test/snapshots/04b-orders-completed.png and b/test/snapshots/04b-orders-completed.png differ diff --git a/test/snapshots/05-account.png b/test/snapshots/05-account.png index 48d3deb..eda2c4c 100644 Binary files a/test/snapshots/05-account.png and b/test/snapshots/05-account.png differ diff --git a/test/snapshots/07-destination-states.png b/test/snapshots/07-destination-states.png deleted file mode 100644 index 77450b2..0000000 Binary files a/test/snapshots/07-destination-states.png and /dev/null differ diff --git a/test/snapshots/07-pickup-where.png b/test/snapshots/07-pickup-where.png new file mode 100644 index 0000000..8b6eeb8 Binary files /dev/null and b/test/snapshots/07-pickup-where.png differ diff --git a/test/snapshots/07b-destination-districts.png b/test/snapshots/07b-destination-districts.png deleted file mode 100644 index 9d8e72a..0000000 Binary files a/test/snapshots/07b-destination-districts.png and /dev/null differ diff --git a/test/snapshots/07b-pickup-when.png b/test/snapshots/07b-pickup-when.png new file mode 100644 index 0000000..281e5d5 Binary files /dev/null and b/test/snapshots/07b-pickup-when.png differ diff --git a/test/snapshots/08-send.png b/test/snapshots/08-send.png index bf4e04f..a5d5900 100644 Binary files a/test/snapshots/08-send.png and b/test/snapshots/08-send.png differ diff --git a/test/snapshots/09-window-sheet.png b/test/snapshots/09-window-sheet.png deleted file mode 100644 index 86d0768..0000000 Binary files a/test/snapshots/09-window-sheet.png and /dev/null differ diff --git a/test/snapshots/11-booked.png b/test/snapshots/11-booked.png index 1436df5..d50d1a8 100644 Binary files a/test/snapshots/11-booked.png and b/test/snapshots/11-booked.png differ diff --git a/test/snapshots/12-receipt.png b/test/snapshots/12-receipt.png index 746a7c9..a975e49 100644 Binary files a/test/snapshots/12-receipt.png and b/test/snapshots/12-receipt.png differ diff --git a/tool/screens.sh b/tool/screens.sh index ea57ba6..632ab57 100755 --- a/tool/screens.sh +++ b/tool/screens.sh @@ -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'), diff --git a/tool/verify_booking.sh b/tool/verify_booking.sh new file mode 100755 index 0000000..c52c393 --- /dev/null +++ b/tool/verify_booking.sh @@ -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."