Miler rider app: surface system, visible design language, backend lifecycle
Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6
.gitignore
vendored
@@ -43,3 +43,9 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
|
# Golden-comparison debris. `flutter test` writes four PNGs per failing shot
|
||||||
|
# (isolatedDiff / maskedDiff / masterImage / testImage) and regenerates them on
|
||||||
|
# every failing run — 128 of them were sitting untracked. The references
|
||||||
|
# themselves live in test/shots and ARE committed; these are the diff output.
|
||||||
|
test/failures/
|
||||||
|
|||||||
1732
ABOUT_MILER.md
400
API_SPEC.md
@@ -1,400 +0,0 @@
|
|||||||
# Miler (Rider App) — Backend API Specification
|
|
||||||
|
|
||||||
**Audience:** Doormile backend team
|
|
||||||
**Purpose:** This is the contract the **Miler rider app** needs the backend to implement so we can connect everything and go live. It documents (1) what the app already sends and expects today, and (2) the changes required to ship the mixed pickup + delivery route feature.
|
|
||||||
|
|
||||||
**How to use this doc:** Design/confirm each endpoint below, then send back the finalized doc (exact URLs, request/response JSON, and any field renames). We then wire the app to your final contract and go live.
|
|
||||||
|
|
||||||
> Status legend for each endpoint:
|
|
||||||
> **[LIVE]** already called by the current app — keep the contract stable.
|
|
||||||
> **[CHANGE]** needs a change/addition before go-live.
|
|
||||||
> **[NEW]** not built yet.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Global conventions
|
|
||||||
|
|
||||||
### 1.1 Base URLs / environments
|
|
||||||
The app switches between **dev** and **live** by an environment flag. Please expose the same path on both:
|
|
||||||
|
|
||||||
```
|
|
||||||
DEV base: https://jupiter.doormile.app/dev/api
|
|
||||||
LIVE base: https://jupiter.doormile.app/live/api
|
|
||||||
```
|
|
||||||
|
|
||||||
> **⚠️ Must fix before go-live:** Today a few write endpoints (update pickup, create rider log, break logs) point at a **second host** `https://queue.workolik.com/live/api/...`, and the app currently has to **bypass TLS cert validation and hard-code the server IP** (`66.116.225.226`) because carrier DNS returns broken CDN nodes for that host and the certificate doesn't validate. **Please serve everything from one host (`jupiter.doormile.app`) with a valid TLS certificate and correct DNS** so we can remove the SSL-bypass hack. This is a security and reliability blocker.
|
|
||||||
|
|
||||||
### 1.2 Auth
|
|
||||||
- The app does **not** currently send a bearer token — requests are keyed by `userid`. **Please tell us the intended auth model.** Recommended: return a JWT/session token from login and require `Authorization: Bearer <token>` on every other call. If you keep `userid`-only, confirm that explicitly.
|
|
||||||
|
|
||||||
### 1.3 Request/response format
|
|
||||||
- Content type: `application/json` (both directions).
|
|
||||||
- **Standard response envelope** (already used by read endpoints — please use it everywhere):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 200,
|
|
||||||
"status": true,
|
|
||||||
"message": "Success",
|
|
||||||
"details": [ ... ] // object OR array — the actual payload
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- The app reads the payload from `details` first, then falls back to `data`, then the root. **Please standardize on `details`.**
|
|
||||||
- On error return `status: false`, a non-2xx HTTP code, and a human-readable `message`.
|
|
||||||
|
|
||||||
### 1.4 Formats
|
|
||||||
- **Dates (query params):** `YYYY-MM-DD` (e.g. `2026-07-18`).
|
|
||||||
- **Timestamps (bodies):** full date-time string, currently `YYYY-MM-DD HH:mm:ss`. Confirm timezone — please use **IST** consistently and state it.
|
|
||||||
- **Lat/Long:** strings, decimal degrees (e.g. `"12.9716"`). Do not truncate precision.
|
|
||||||
- **Money:** number (₹). Confirm 2-decimal.
|
|
||||||
- **Booleans / flags:** confirm whether you use `true/false` or `1/0` — the app currently tolerates both for `status` but please pick one.
|
|
||||||
|
|
||||||
### 1.5 Cache-busting
|
|
||||||
Read endpoints receive a `t=<epoch-millis>` query param — ignore it server-side; it exists to defeat caching.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Authentication
|
|
||||||
|
|
||||||
### 2.1 Rider Login **[LIVE]**
|
|
||||||
`POST /v2/users/rider/login`
|
|
||||||
|
|
||||||
Request:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"contactno": "9876543210",
|
|
||||||
"devicetype": "android", // "android" | "ios"
|
|
||||||
"configid": 123,
|
|
||||||
"deviceid": "<device-uuid>",
|
|
||||||
"userfcmtoken": "<fcm-token>",
|
|
||||||
"pin": 1234 // optional; sent on PIN login
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Response `details` (object) — **every field below is consumed by the app**, so keep them:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"userid": 1001,
|
|
||||||
"riderid": 55,
|
|
||||||
"partnerid": 12,
|
|
||||||
"configid": 123,
|
|
||||||
"shiftid": 7,
|
|
||||||
"logid": 0,
|
|
||||||
"logseconds": 0,
|
|
||||||
"tenantid": 3,
|
|
||||||
"locationid": 9,
|
|
||||||
"applocationid": 9,
|
|
||||||
"roleid": 2,
|
|
||||||
"authmode": 1,
|
|
||||||
"authname": "…",
|
|
||||||
"firstname": "Suriya",
|
|
||||||
"lastname": "K",
|
|
||||||
"username": "suriya",
|
|
||||||
"email": "…",
|
|
||||||
"onduty": 0, // 0 = off duty, 1 = on duty
|
|
||||||
"starttime": "09:00", // shift window (display)
|
|
||||||
"endtime": "18:00",
|
|
||||||
"pickupradius": 100, // meters — geofence radius for arrived/pickup
|
|
||||||
"fuelcharge": 5.0, // ₹ per km (rider payout)
|
|
||||||
"firstmilecharge": 0.0, // per-km first-mile charge (alias: firstmilecharges)
|
|
||||||
"userfcmtoken": "<echoed>"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Notes:
|
|
||||||
- `authmode` decides the flow (e.g. whether a PIN step is required). **Please document the possible values.**
|
|
||||||
- If a rider needs to set a PIN on first login, tell us how that state is signaled.
|
|
||||||
|
|
||||||
### 2.2 Update PIN **[LIVE]**
|
|
||||||
`PUT /v2/users/update`
|
|
||||||
```json
|
|
||||||
{ "userid": 1001, "pin": 1234 }
|
|
||||||
```
|
|
||||||
Response: standard envelope, `status: true` on success.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Rider duty log (On/Off Duty, breaks)
|
|
||||||
|
|
||||||
The rider goes **On Duty** → works stops → **Off Duty**. These calls power the duty timer and location tracking.
|
|
||||||
|
|
||||||
### 3.1 Create Rider Log (go On Duty) **[LIVE]**
|
|
||||||
`POST /v2/partners/createriderlog`
|
|
||||||
|
|
||||||
Request:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"logid": 0, // 0 → server assigns new logid; returned in response
|
|
||||||
"userid": 1001,
|
|
||||||
"partnerid": 12,
|
|
||||||
"shiftid": 7,
|
|
||||||
"logdate": "2026-07-18T09:00:00",
|
|
||||||
"login": "2026-07-18 09:00:00", // on-duty timestamp
|
|
||||||
"onduty": 1,
|
|
||||||
"status": "online",
|
|
||||||
"latitude": "12.9716",
|
|
||||||
"longitude": "77.5946",
|
|
||||||
"raw_latitude": "12.9716",
|
|
||||||
"raw_longitude": "77.5946",
|
|
||||||
"velocity_lat": "0",
|
|
||||||
"velocity_lng": "0",
|
|
||||||
"speed": "0",
|
|
||||||
"heading": "0",
|
|
||||||
"contactno": "9876543210",
|
|
||||||
"tenantid": 3,
|
|
||||||
"locationid": 9,
|
|
||||||
"applocationid": 9,
|
|
||||||
"userfcmtoken": "<fcm-token>",
|
|
||||||
"orderid": "" // optional; current stop context if any
|
|
||||||
}
|
|
||||||
```
|
|
||||||
**Response must return the new `logid`** (the app stores it and uses it for updates). Return it in `details`.
|
|
||||||
|
|
||||||
### 3.2 Update Rider Log (heartbeat / go Off Duty) **[LIVE]**
|
|
||||||
`PUT /v1/partners/updateriderlog`
|
|
||||||
|
|
||||||
Sent periodically as a location heartbeat and once when going Off Duty:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"logid": 4567,
|
|
||||||
"userid": 1001,
|
|
||||||
"logdate": "2026-07-18T13:00:00",
|
|
||||||
"latitude": "12.9722",
|
|
||||||
"longitude": "77.5950",
|
|
||||||
"speed": "0",
|
|
||||||
"heading": "0",
|
|
||||||
"status": "online", // "offline" when going off duty
|
|
||||||
"orderid": ""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Confirm the exact field(s) used to mark **Off Duty** (e.g. `status: "offline"` and/or `logout` timestamp + `onduty: 0`). Please state it explicitly.
|
|
||||||
|
|
||||||
### 3.3 Get Rider Log **[LIVE]**
|
|
||||||
`GET /v1/partners/getriderlog?userid=1001` → current log record (used to restore duty state on app restart). Return `logid`, `onduty`, `login` time, accumulated seconds, etc.
|
|
||||||
|
|
||||||
### 3.4 Get Rider Count **[LIVE]**
|
|
||||||
`GET /v1/partners/getridercount?userid=1001` → counts for the dashboard (e.g. completed stops today). **Please document exact fields.**
|
|
||||||
|
|
||||||
### 3.5 Break logs **[LIVE]**
|
|
||||||
- `POST /v2/partners/createbreaklog` — start break.
|
|
||||||
- `PUT /v2/partners/updatebreaklog` — end break.
|
|
||||||
|
|
||||||
Please document the exact request bodies (rider id, logid, start/end timestamps, break type).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Route & Stops (the core flow)
|
|
||||||
|
|
||||||
**Domain recap for the backend:** For a booked time slot, the hub/admin assigns a rider an **ordered route** of stops. The rider starts at a **hub**, works stops **in fixed sequence** (cannot reorder), can **skip and resume** a stop, and after the last stop **returns to the same hub**. Each stop is either a **PICKUP** or a **DELIVERY** (see §4.5 — this is the key new requirement).
|
|
||||||
|
|
||||||
### 4.1 Get Pickup Queue (assigned/pending stops) **[LIVE]**
|
|
||||||
`GET /v2/pickups/getpickupqueues?userid=1001&fromdate=2026-07-18&todate=2026-07-18&orderstatus=<optional>&t=<epoch>`
|
|
||||||
|
|
||||||
Returns `details` = **array of stop objects**. Fields the app reads today (please keep these names, lowercase):
|
|
||||||
|
|
||||||
| Field | Type | Meaning |
|
|
||||||
|---|---|---|
|
|
||||||
| `orderid` | string/int | Order identifier shown to rider |
|
|
||||||
| `pickupid` | int | **Stop id — primary key for all status updates** |
|
|
||||||
| `orderheaderid` | int | Order header id (sent back on updates) |
|
|
||||||
| `pickuplocationid` | int | Location id of the stop |
|
|
||||||
| `orderstatus` | string | Current status (see §4.6 lifecycle) |
|
|
||||||
| `step` | int | **Sequence position in the route (1..N) — defines fixed order** |
|
|
||||||
| `pickupcustomer` | string | Customer / store name |
|
|
||||||
| `pickupcontactno` | string | Customer phone (Call button) |
|
|
||||||
| `pickupaddress` | string | Stop address |
|
|
||||||
| `pickuplat` / `pickuplong` | string | Stop coordinates (geofence + navigation) |
|
|
||||||
| `dropaddress` | string | Drop address (delivery stops) |
|
|
||||||
| `droplat` / `droplon` | string | Drop coordinates (delivery stops) |
|
|
||||||
| `collectionamt` | number | Amount to collect at this stop (0 = none) |
|
|
||||||
| `pickupamt` | number | Pickup charge |
|
|
||||||
| `eta` / `expected_pickup_time` | string | ETA / expected time (display) |
|
|
||||||
| `tenantid` / `tenantname` | int/string | Tenant |
|
|
||||||
| `starttime` | string | Slot / assignment start |
|
|
||||||
|
|
||||||
> **⚠️ Casing:** the app has seen **both** `pickuplat`/`PickupLat` and `orderid`/`OrderId` variants in responses. **Please return one consistent casing (lowercase preferred) across all endpoints** and never mix within a payload.
|
|
||||||
|
|
||||||
### 4.2 Get Current Pickups **[LIVE]**
|
|
||||||
`GET /v1/pickups/getpickups?userid=1001&fromdate=&todate=&t=` — the rider's active/in-progress stops. Same object shape as §4.1.
|
|
||||||
|
|
||||||
### 4.3 Get Pickups V3 (date-bounded / picked history) **[LIVE]**
|
|
||||||
`GET /v3/pickups/getpickups?userid=1001&fromdate=&todate=&t=` — used for completed/"picked" history. Same object shape.
|
|
||||||
|
|
||||||
> Please clarify the intended difference between v1/v2/v3 of `getpickups` so we can consolidate. Ideally **one** endpoint filtered by `orderstatus` and date range.
|
|
||||||
|
|
||||||
### 4.4 Update Stop status **[LIVE — needs delivery extension, see §4.5]**
|
|
||||||
`PUT /v1/pickups/updatepickup`
|
|
||||||
|
|
||||||
This one endpoint is called at **every** state transition; `orderstatus` selects the transition. Common fields on all transitions:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"pickupid": 8890,
|
|
||||||
"orderheaderid": 4501,
|
|
||||||
"orderstatus": "<state>",
|
|
||||||
"riderslat": "12.9716", // rider GPS at the moment
|
|
||||||
"riderslon": "77.5946",
|
|
||||||
"raw_latitude": "12.9716", "raw_longitude": "77.5946",
|
|
||||||
"velocity_lat": "0", "velocity_lng": "0", "speed": "0", "heading": "0",
|
|
||||||
"notes": ""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Per-transition additional fields:
|
|
||||||
|
|
||||||
**`accepted`** — rider starts the assigned route/stop.
|
|
||||||
|
|
||||||
**`active`** — rider en route to the stop.
|
|
||||||
|
|
||||||
**`arrived`** — rider reached the stop (passes geofence check):
|
|
||||||
```json
|
|
||||||
{ "orderstatus": "arrived", "arrivaltime": "2026-07-18 10:05:00", "pickuplat": "", "pickuplong": "", "actualkms": "", "pickupamt": 0.0 }
|
|
||||||
```
|
|
||||||
|
|
||||||
**`Picked up`** (pickup complete) — the big one:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"orderstatus": "Picked up",
|
|
||||||
"pickupedtime": "2026-07-18 10:07:00",
|
|
||||||
"pickuptime": "2026-07-18 10:07:00",
|
|
||||||
"pickuplocationid": 9,
|
|
||||||
"pickuplat": "12.9716", "pickuplong": "77.5946",
|
|
||||||
"riderkms": "0.4200", // distance rider travelled to this stop
|
|
||||||
"ridercharges": 2.10, // payout for this leg
|
|
||||||
"ridertime": 12, // minutes
|
|
||||||
"pickupamt": 0.0,
|
|
||||||
"collectionamt": 100.0, // amount due
|
|
||||||
"collectedamt": 100.0, // amount actually collected
|
|
||||||
"collectionstatus": "collected",
|
|
||||||
"smspickup": 0,
|
|
||||||
"wasskipped": false, // true if this stop had been skipped earlier
|
|
||||||
"bonuspts": 5,
|
|
||||||
"dropimage": "<base64-or-url>" // photo proof of pickup
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**`skipped`** — rider skips this stop, will resume later (first-class; sequence preserved).
|
|
||||||
|
|
||||||
**`cancelled`** — pickup could not be completed (reason in `notes`).
|
|
||||||
|
|
||||||
**`rejected`** — rider rejects the assigned stop.
|
|
||||||
|
|
||||||
**`picked`** — internal "picked" marker (via `updatepickup` v1). Please clarify vs `Picked up`.
|
|
||||||
|
|
||||||
> **⚠️ Please normalize `orderstatus` values.** Today they are inconsistent (`"Picked up"` with a space & capital, vs `"arrived"`, `"active"`, `"skipped"` lowercase). **Give us one canonical set of machine values** (e.g. all lowercase snake: `assigned`, `active`, `arrived`, `picked_up`, `delivered`, `skipped`, `cancelled`, `rejected`) and we'll map the UI labels ourselves.
|
|
||||||
|
|
||||||
Response for all updates: standard envelope with `status: true`.
|
|
||||||
|
|
||||||
### 4.5 ⭐ REQUIRED CHANGE — Stop `type` (Pickup vs Delivery) **[CHANGE]**
|
|
||||||
|
|
||||||
**This is the single most important change for go-live.** Each stop on a route can be a **pickup** or a **delivery**, and the app UI must branch on it. Today the API returns no such field, so the app treats **every stop as a pickup**. Please add:
|
|
||||||
|
|
||||||
1. **On every stop object** (§4.1–4.3) add a stop type field:
|
|
||||||
```json
|
|
||||||
"type": "pickup" // "pickup" | "delivery"
|
|
||||||
```
|
|
||||||
(Name it `type` or `stoptype` — the app already looks for `type`/`stoptype`/`stopType`; **pick one and tell us**.)
|
|
||||||
|
|
||||||
2. **For `delivery` stops**, the stop object must carry delivery details:
|
|
||||||
| Field | Type | Meaning |
|
|
||||||
|---|---|---|
|
|
||||||
| `dropaddress` | string | Where to deliver |
|
|
||||||
| `droplat` / `droplon` | string | Delivery coordinates (geofence + nav) |
|
|
||||||
| `otp` | string/int | Delivery OTP the customer gives (proof of delivery) |
|
|
||||||
| `collectionamt` | number | COD to collect on delivery (0 = prepaid) |
|
|
||||||
| customer name/phone | string | Recipient contact (reuse `pickupcustomer`/`pickupcontactno` or give delivery-specific fields — **tell us which**) |
|
|
||||||
|
|
||||||
3. **Delivery completion** goes through the same `PUT /v1/pickups/updatepickup` with:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"pickupid": 8891,
|
|
||||||
"orderstatus": "delivered", // canonical value TBD (see §4.4 note)
|
|
||||||
"deliveredtime": "2026-07-18 10:20:00",
|
|
||||||
"otp": "4821", // OTP the rider entered — verify server-side
|
|
||||||
"dropimage": "<base64-or-url>", // photo proof of delivery
|
|
||||||
"collectedamt": 0.0, "collectionstatus": "prepaid",
|
|
||||||
"riderslat": "…", "riderslon": "…", "riderkms": "…", "bonuspts": 5
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Please confirm: (a) whether OTP is **verified server-side** (recommended) or just recorded, (b) the canonical `orderstatus` for a completed delivery, (c) delivery-specific proof fields (signature? photo? OTP-only?).
|
|
||||||
|
|
||||||
### 4.6 Stop status lifecycle (state machine)
|
|
||||||
|
|
||||||
```
|
|
||||||
assigned ──► active ──► arrived ──► ┌─ (pickup) picked_up ─┐
|
|
||||||
│ │ │ └─ (delivery) delivered ─┤──► [next stop]
|
|
||||||
│ │ └────────────► skipped ──► (resume later) ─► active
|
|
||||||
└────────────┴──────────────────────► cancelled / rejected
|
|
||||||
```
|
|
||||||
Rules the backend must enforce/allow:
|
|
||||||
- Rider **cannot reorder**; `step` is authoritative.
|
|
||||||
- Rider **can skip** any stop and resume it later — `skipped` is not terminal.
|
|
||||||
- After the **last** stop the rider returns to the **origin hub**. Please tell us whether hub-return is its own record/status or implicit.
|
|
||||||
|
|
||||||
### 4.7 Create Pickup Log **[LIVE]**
|
|
||||||
`POST /v2/pickups/createpickuplog` — audit/event log entries for a stop. Body is wrapped in an **array**: `[ { ...event } ]`. Please document the event schema (event type, timestamp, pickupid, lat/long).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Earnings / Summary
|
|
||||||
|
|
||||||
### 5.1 Partner summary **[LIVE]**
|
|
||||||
`GET /v2/partners/...` (base `https://jupiter.doormile.app/live/api/v2/partners`)
|
|
||||||
Powers the Earnings screen (daily/weekly/monthly totals, stop counts, payout). **Please document the exact path + params (userid, period, date range) and the response fields** (totals, per-day breakdown).
|
|
||||||
|
|
||||||
### 5.2 Rider weekly KMs **[LIVE]**
|
|
||||||
`GET /v1/partners/...` — weekly distance for payout. Document exact path + response.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Supporting endpoints
|
|
||||||
|
|
||||||
These are used by the app; **please document each** (request + response):
|
|
||||||
|
|
||||||
- **Notifications** — list rider notifications (used on the notifications tab). Need: list + mark-read.
|
|
||||||
- **Rewards / bonus points** — the app shows `bonuspts`/`bonusPoints`; document how points are earned and fetched.
|
|
||||||
- **Support tickets** — create ticket, list tickets (models exist: subject, description, status, timestamps).
|
|
||||||
- **FCM push** — confirm the payload schema for push notifications (new stop assigned, route updated, etc.) so we can handle taps/deep-links.
|
|
||||||
- **App version / force-update** — the app tracks `CurrentVersion`; if you gate minimum version, document the endpoint.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Field glossary (canonical names)
|
|
||||||
|
|
||||||
| Field | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| `userid` | Rider's user id (primary key the app sends everywhere) |
|
|
||||||
| `riderid` / `partnerid` | Rider/partner identifiers |
|
|
||||||
| `shiftid` / `logid` | Shift and current duty-log ids |
|
|
||||||
| `tenantid` / `locationid` / `applocationid` | Org / hub / app-location scoping |
|
|
||||||
| `pickupid` | **Stop id** — the PK for a single stop, used on all status updates |
|
|
||||||
| `orderid` / `orderheaderid` | Order + order-header identifiers |
|
|
||||||
| `step` | Stop's fixed position in the route (1..N) |
|
|
||||||
| `type` / `stoptype` | **NEW:** `pickup` \| `delivery` |
|
|
||||||
| `orderstatus` | Stop state (see §4.6) |
|
|
||||||
| `collectionamt` / `collectedamt` / `collectionstatus` | COD due / collected / status |
|
|
||||||
| `pickuplat`,`pickuplong` / `droplat`,`droplon` | Stop / drop coordinates |
|
|
||||||
| `riderslat`,`riderslon` | Rider GPS at time of action |
|
|
||||||
| `riderkms` / `ridercharges` / `ridertime` | Distance / payout / minutes for a leg |
|
|
||||||
| `otp` | Delivery OTP (proof of delivery) |
|
|
||||||
| `dropimage` | Photo proof (pickup or delivery) |
|
|
||||||
| `bonuspts` | Bonus points for completing a stop |
|
|
||||||
| `pickupradius` | Geofence radius (m) for arrived/complete |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Open questions for the backend team (please answer in your returned doc)
|
|
||||||
|
|
||||||
1. **Auth model** — token-based or `userid`-only? (§1.2)
|
|
||||||
2. **Single host + valid TLS + working DNS** — can we drop the `queue.workolik.com` host and the SSL/IP hack? (§1.1)
|
|
||||||
3. **Canonical `orderstatus` values** — give us the final machine strings. (§4.4)
|
|
||||||
4. **Stop `type` field** — final field name (`type` vs `stoptype`) and the delivery fields. (§4.5)
|
|
||||||
5. **Delivery proof** — OTP verified server-side? photo/signature required? canonical `delivered` status? (§4.5)
|
|
||||||
6. **getpickups v1/v2/v3** — can we consolidate to one endpoint? (§4.3)
|
|
||||||
7. **Casing** — confirm all-lowercase field names across every endpoint. (§4.1)
|
|
||||||
8. **Timezone** — confirm IST for all timestamps. (§1.4)
|
|
||||||
9. **Hub return** — is returning to hub its own status/record? (§4.6)
|
|
||||||
10. **Summary, rewards, notifications, support, push** — full request/response schemas. (§5–6)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Generated from the current Miler app's live API integration. Every field marked [LIVE] is already sent/consumed by the app in production code — please preserve those names or tell us the new ones so we can migrate.*
|
|
||||||
330
BOTTOM_SHEETS.md
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
# Miler — Bottom Sheets
|
||||||
|
|
||||||
|
Every modal surface that rises from the bottom of the Miler rider app: what it
|
||||||
|
is, when it appears, what it looks like, and the rules it must obey.
|
||||||
|
|
||||||
|
**Scope.** `lib/` only. `lib/xpress` is the ported Xpress-rider lane and keeps
|
||||||
|
its own surfaces; it is not covered here and must not be used as a reference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The one presenter
|
||||||
|
|
||||||
|
Every sheet in this app is opened through **`showMilerSheet()`**
|
||||||
|
(`lib/views/helpers/widgets/miler_sheet_kit.dart`). No screen calls
|
||||||
|
`showModalBottomSheet` and configures a route itself.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
showMilerSheet<T>(
|
||||||
|
context,
|
||||||
|
builder: (ctx) => MilerSheetScaffold(child: …),
|
||||||
|
large: false, // slower entrance for sheets covering most of the screen
|
||||||
|
isDismissible: true,
|
||||||
|
enableDrag: true,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
It fixes four things so no caller can get them wrong:
|
||||||
|
|
||||||
|
| Setting | Value | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| `backgroundColor` | `transparent` | The scaffold paints the glass. An opaque route background shows as a square white corner behind the rounded one. |
|
||||||
|
| `isScrollControlled` | `true`, always | A sheet that cannot grow past half height is a sheet whose text field the keyboard covers. |
|
||||||
|
| `sheetAnimationStyle` | `kMilerSheetStyle` / `kMilerLargeSheetStyle` | One entrance curve, two durations. |
|
||||||
|
| Shape | Owned by `milerGlassSheet` | No caller re-declares a radius. |
|
||||||
|
|
||||||
|
### What this replaced
|
||||||
|
|
||||||
|
An audit found **six** presentation styles for one kind of surface: frosted
|
||||||
|
glass at radius 28; opaque `pureSurface` at 24; default white at 16 with **no**
|
||||||
|
`isScrollControlled` (so the keyboard covered the reason field); an ad-hoc
|
||||||
|
`Container` with its own shadow and a 50pt top margin; `Get.bottomSheet` flat at
|
||||||
|
16; and a floating card with margins. Five of them drew their own drag handle —
|
||||||
|
four at 40×4 in `borderSubtle`, which on frosted glass is close to invisible —
|
||||||
|
and each applied `SafeArea` its own way.
|
||||||
|
|
||||||
|
A rider crossing one shift met most of these. That is what makes an app feel
|
||||||
|
assembled rather than designed: the same gesture arriving on different-looking
|
||||||
|
surfaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The structure inside
|
||||||
|
|
||||||
|
**`MilerSheetScaffold`** owns the fabric, the handle, the content padding and
|
||||||
|
the bottom inset. A sheet author writes content and nothing else.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────────────────┐
|
||||||
|
│ ▬▬▬▬ │ MilerSheetHandle — 40×4, borderStrong
|
||||||
|
│ │ 10 top / 14 bottom margin
|
||||||
|
│ ◐ Title │ MilerSheetHeader — 18sp/w700
|
||||||
|
│ One supporting line. │ subtitle 13.5sp/w600, badge tinted 21sp
|
||||||
|
│ │
|
||||||
|
│ ───────────────────────────── │ content — the sheet's own
|
||||||
|
│ │
|
||||||
|
│ [ Primary action ] │
|
||||||
|
└───────────────────────────────────┘
|
||||||
|
20dp gutter, matching the page beneath
|
||||||
|
```
|
||||||
|
|
||||||
|
### The inset rule — one line, and it has bitten this app before
|
||||||
|
|
||||||
|
```dart
|
||||||
|
bottomInset = max(viewInsets.bottom, viewPadding.bottom)
|
||||||
|
```
|
||||||
|
|
||||||
|
The keyboard **replaces** the gesture bar; whichever is up pays the inset.
|
||||||
|
**Summing them is the double-SafeArea bug** — it floats the CTA a gesture bar's
|
||||||
|
height above the keyboard. Never add a `SafeArea` inside a sheet.
|
||||||
|
|
||||||
|
The padding is animated (`DesignConstants.motionState`, `easeOutCubic`) because
|
||||||
|
a sheet that jumps its full keyboard height in one frame reads as a glitch
|
||||||
|
rather than a response.
|
||||||
|
|
||||||
|
### The handle
|
||||||
|
|
||||||
|
`borderStrong`, **not** `borderSubtle`. At 7% glass over a dimmed page, subtle
|
||||||
|
grey measures under 1.15:1 against its own ground — so the sheets most worth
|
||||||
|
flicking away looked least like they could be.
|
||||||
|
|
||||||
|
Hide it (`handle: false`) only for a sheet that must not be casually dismissed.
|
||||||
|
|
||||||
|
### The header badge
|
||||||
|
|
||||||
|
Optional, **tinted, never filled** — a filled disc at this size outweighs the
|
||||||
|
title it introduces. Pass the accent the subject already wears elsewhere (duty
|
||||||
|
green, brand red, a stop-kind accent) so the sheet is visibly about the thing
|
||||||
|
that opened it.
|
||||||
|
|
||||||
|
### `MilerSheetChoiceRow`
|
||||||
|
|
||||||
|
The standard row for a list of answers: label, optional icon, accent, selected
|
||||||
|
state, optional rule beneath. Label 14.5sp. Used wherever a sheet asks the rider
|
||||||
|
to pick one of several things.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The sheets
|
||||||
|
|
||||||
|
### Home
|
||||||
|
|
||||||
|
#### Duty — `duty_sheet.dart`
|
||||||
|
**Opens:** tapping the duty control in the app bar.
|
||||||
|
**Asks:** `Go on duty?` / `Go off duty?`
|
||||||
|
**Body:** one sentence of consequence — on duty, the hub can add you to the next
|
||||||
|
slot and live tracking runs; off duty, the hub stops assigning and tracking
|
||||||
|
pauses, and any booking you are holding has to be finished first.
|
||||||
|
**Commits with:** a button.
|
||||||
|
|
||||||
|
> **Why not a slide.** This had accumulated a handle, an icon disc, a title, a
|
||||||
|
> subtitle, a *drawn diagram of the switch moving*, three icon-and-text
|
||||||
|
> consequence rows and a slide-to-confirm — eight blocks for a question with two
|
||||||
|
> answers. The slide was borrowed from the payment flows, and this app's rule is
|
||||||
|
> that **slides are reserved for money and for custody**. Ending a shift is
|
||||||
|
> neither.
|
||||||
|
|
||||||
|
#### Stop detail — `stop_detail_sheet.dart`
|
||||||
|
**Opens:** tapping a stop on the trip card, or a queue row on Deliveries.
|
||||||
|
**Type:** `DraggableScrollableSheet` — `0.78` initial, `0.5` min, `0.95` max.
|
||||||
|
**Answers:** *where actually is this* (a small non-interactive map with the hub,
|
||||||
|
the stop and the leg between them) and *what exactly is here* (full address,
|
||||||
|
contact, parcel counts, cash, notes, OTP).
|
||||||
|
|
||||||
|
**Layout:** number chip + customer + status → map → `620 m away · 1 min ride` →
|
||||||
|
`DELIVER TO` + address → `WHAT TO DO HERE` + the task and its requirement →
|
||||||
|
a two-up **fact grid** (From / Bag / Order / Contact) on one white surface cut
|
||||||
|
by hairlines → Call and Navigate.
|
||||||
|
|
||||||
|
> The fact grid was a `label ── value` table with a fixed 110pt label column. The
|
||||||
|
> values — the half a rider needs — sat in a ragged right column and a long one
|
||||||
|
> wrapped under a wide empty label, putting a hole in the middle of the block.
|
||||||
|
> Stacked two to a row, the whole set is scanned in a couple of saccades.
|
||||||
|
|
||||||
|
This sheet is **why the card above it can afford to truncate**. The list is for
|
||||||
|
scanning; everything it drops lives here, one tap away.
|
||||||
|
|
||||||
|
#### Pickup preview — `pickup_preview_sheet.dart`
|
||||||
|
**Opens:** the `i` / details control on a kitchen, and the kitchen card's own tap.
|
||||||
|
**Answers:** *where am I going, and what am I collecting there* — the leg drawn
|
||||||
|
from where the rider is standing, plus the load waiting at the counter.
|
||||||
|
**Primary action:** `Start navigation` — and **only then** does Miler hand off to
|
||||||
|
Google Maps.
|
||||||
|
|
||||||
|
> Handing off means the rider leaves Miler: his route, bag counts and manifest
|
||||||
|
> all go behind another app. Committing to that on the strength of a kitchen's
|
||||||
|
> name and a straight-line distance is one tap too few.
|
||||||
|
|
||||||
|
#### Rung sheet — `stop_action_sheet.dart`
|
||||||
|
**Opens:** advancing a stop from Home.
|
||||||
|
**Asks:** `Arrived at Vidhya Kitchen` / the collection equivalent, with the count
|
||||||
|
of orders at that counter and the **named manifest**.
|
||||||
|
**Commits with:** `Slide to confirm arrival` / `Slide to confirm pickup`.
|
||||||
|
|
||||||
|
> Arriving and collecting are claims about the physical world — *I am at this
|
||||||
|
> counter*, *this bag is in my box*. They are written to the hub, they move food
|
||||||
|
> and money, and a stray thumb on a moving bike must not be able to make them.
|
||||||
|
|
||||||
|
#### Reject reasons — `homepage.dart`
|
||||||
|
**Opens:** rejecting one or more assigned stops.
|
||||||
|
**Asks:** `Why are you rejecting this stop?` (or `…these N stops?`)
|
||||||
|
**Subtitle:** *The hub needs a reason to re-route it to someone else.*
|
||||||
|
**Body:** a `MilerSheetChoiceRow` list — too far from my route; shop/customer
|
||||||
|
closed today; parcel too large for my box; not enough time left in my shift;
|
||||||
|
address looks wrong.
|
||||||
|
|
||||||
|
#### Turn on location — `homepage.dart`
|
||||||
|
**Opens:** when a permission or a fix is missing.
|
||||||
|
**Asks:** `Turn on location`
|
||||||
|
**Subtitle:** *Bookings and live tracking need your position. Nothing is assigned
|
||||||
|
to a rider the hub cannot see.*
|
||||||
|
|
||||||
|
#### Product details — `homepage.dart`
|
||||||
|
**Opens:** from an order row.
|
||||||
|
**Title:** `Product details`, with an inventory badge.
|
||||||
|
**Body:** a plain ruled list of whatever the payload actually carries. An empty
|
||||||
|
payload is **one quiet sentence**, not a padded red box.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Deliveries and the live map
|
||||||
|
|
||||||
|
#### Live stop sheet — `map.dart`
|
||||||
|
**Type:** `DraggableScrollableSheet`, and the **only** persistent (non-modal)
|
||||||
|
sheet in the app. Three snap points, no more:
|
||||||
|
|
||||||
|
| Name | Extent | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| Glance | `0.35` | Map-dominant; ETA still readable |
|
||||||
|
| **Working** | **`0.45`** | Default. ETA, identity, address and the action, without scrolling |
|
||||||
|
| Detail | `0.75` | Everything, map still legible |
|
||||||
|
|
||||||
|
**Hierarchy — how long → where → who → what action:**
|
||||||
|
|
||||||
|
```
|
||||||
|
▬▬▬▬
|
||||||
|
6 min ← the dominant operational figure
|
||||||
|
1.8 km away · arrive ~10:42
|
||||||
|
───────────────────────── ← one hairline, the sheet's only rule
|
||||||
|
Joe Mathew ☎ ← identity + Call
|
||||||
|
12 SNS Colony, Peelamedu
|
||||||
|
Bag 8
|
||||||
|
[ Navigate ] [ I've arrived ] pickup leg
|
||||||
|
⟶ Slide to finish stop delivery leg
|
||||||
|
```
|
||||||
|
|
||||||
|
One continuous surface. **No cards inside it** — the hierarchy is typography,
|
||||||
|
whitespace and that single hairline.
|
||||||
|
|
||||||
|
> The camera's bottom padding is derived from `_sheetWorking` and the real safe
|
||||||
|
> areas, not from a remembered pixel value. A hardcoded `300.h` agreed with 45%
|
||||||
|
> only on the device it was measured on: on a 320×568 phone it framed the route
|
||||||
|
> into a sliver, and at large text the stop pin ended up under the sheet.
|
||||||
|
|
||||||
|
**Call** uses `StopContact.forLeg()` — the kitchen's number on a pickup leg, the
|
||||||
|
customer's on a delivery leg, and a label that says which. It is omitted
|
||||||
|
entirely when there is no number rather than offered dead.
|
||||||
|
|
||||||
|
#### How did this stop end? — `map.dart`
|
||||||
|
**Opens:** *only* after the rider completes the slide on the live sheet. **The
|
||||||
|
slide itself commits nothing.**
|
||||||
|
**Title:** `How did this stop end?`
|
||||||
|
**Subtitle:** *This is recorded with the office straight away.*
|
||||||
|
**Choices — deliberately unequal:**
|
||||||
|
|
||||||
|
| Choice | Weight |
|
||||||
|
|---|---|
|
||||||
|
| **Delivered** | Filled primary — the forward/success path |
|
||||||
|
| Skipped | Warning/amber — an exception |
|
||||||
|
| Cancelled | Destructive |
|
||||||
|
|
||||||
|
The slider carries `Semantics(button, label: 'Finish this stop', hint: 'Slide,
|
||||||
|
or double tap, to choose how the stop ended', onTap: …)` — a drag-only control
|
||||||
|
is unusable with a screen reader, and with gloves on a cold morning. The
|
||||||
|
accessible path opens the **same** question, so deliberateness is preserved.
|
||||||
|
|
||||||
|
#### Skip — `skip_sheet.dart`
|
||||||
|
**Opens:** choosing Skip.
|
||||||
|
**Body:** the reason list — customer unreachable; customer not at the location;
|
||||||
|
wrong address; access problem; customer refused; delivery paused; quantity
|
||||||
|
issue; other. Renders through `MilerSheetChoiceRow`.
|
||||||
|
**Guard:** at the limit it shows `Skip Limit Reached` — *You have exceeded the
|
||||||
|
limit of 2 skips within 3 hours.*
|
||||||
|
|
||||||
|
#### Stop brief — `sheet.dart` (`_StopBrief`)
|
||||||
|
Not a sheet itself: the shared **identity block** used by the map sheet, Update
|
||||||
|
Status and Skip, so all three name the stop the same way. Before it, the skip
|
||||||
|
sheet asked "Skip this pickup" without ever saying *which* pickup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Bookings / orders
|
||||||
|
|
||||||
|
#### Reject booking — `orderstaus_button.dart`
|
||||||
|
**Title:** `Reject booking` · **Subtitle:** *The hub re-routes it once it knows why.*
|
||||||
|
|
||||||
|
#### Cancel this booking? — `orderstaus_button.dart`
|
||||||
|
**Title:** `Cancel this booking?` · **Subtitle:** *It returns to pending and the
|
||||||
|
hub reassigns it.*
|
||||||
|
|
||||||
|
#### Payment confirm — `collect_payment.dart`
|
||||||
|
A confirm sheet before money is taken. **Slide**, per the money rule.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Rules for a new sheet
|
||||||
|
|
||||||
|
1. **Open it with `showMilerSheet`.** Never configure `showModalBottomSheet`
|
||||||
|
yourself.
|
||||||
|
2. **Wrap the content in `MilerSheetScaffold`.** It is the handle, the gutter and
|
||||||
|
the inset.
|
||||||
|
3. **No `SafeArea` inside a sheet.** The scaffold already paid the inset. Adding
|
||||||
|
one is the double-inset bug.
|
||||||
|
4. **No card inside a sheet.** The sheet *is* the surface. A bordered white panel
|
||||||
|
on frosted white is a line the rider reads past to reach what he came for.
|
||||||
|
5. **One header idiom** — `MilerSheetHeader`, badge tinted not filled.
|
||||||
|
6. **No close button.** The handle and the barrier dismiss. An `X` duplicates
|
||||||
|
both, and this app had exactly one sheet with one.
|
||||||
|
7. **Slide is reserved** for custody and money — arrival, collection, delivery
|
||||||
|
outcome, payment. Everything else commits with a button.
|
||||||
|
8. **A drag-only control needs `Semantics` with an `onTap` alternative** that
|
||||||
|
reaches the same confirmation.
|
||||||
|
9. **Unequal choices look unequal.** Forward path filled, exception amber,
|
||||||
|
destructive distinct. Never three identical buttons.
|
||||||
|
10. **Absent, not empty.** A row with no value is not drawn. A group with nothing
|
||||||
|
in it is not offered.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Known gaps
|
||||||
|
|
||||||
|
**Every modal sheet in `lib/` now goes through `showMilerSheet` +
|
||||||
|
`MilerSheetScaffold`.** The four direct callers listed in the first version of
|
||||||
|
this document have been migrated:
|
||||||
|
|
||||||
|
| File | Was | Now |
|
||||||
|
|---|---|---|
|
||||||
|
| `skip_sheet.dart` | own `SafeArea` **wrapping** a hand-rolled glass + gutter + handle, plus a ✕ | kit presenter + scaffold + `MilerSheetHeader` |
|
||||||
|
| `collect_payment.dart` | right route flags written by hand | kit presenter (content already used the scaffold) |
|
||||||
|
| `multi_map.dart` | own radius 20, **no glass**, `SafeArea` **plus** `viewInsets` padding, 24 gutter, 22sp red title, ✕ | kit presenter + scaffold + header |
|
||||||
|
| `map.dart` `_PickupBottomSheet` | a `shape: …circular(24)` that clipped nothing behind a transparent route | kit presenter |
|
||||||
|
|
||||||
|
### What remains
|
||||||
|
|
||||||
|
- **`auth.dart` uses `Get.bottomSheet`** for its eight error notices. It is a
|
||||||
|
controller with no `BuildContext`, which is why it was left; migrating it
|
||||||
|
means giving the controller a navigator key or moving the notices to the
|
||||||
|
screens. Until then, auth errors are the one surface that does not speak the
|
||||||
|
system's language.
|
||||||
|
- **The empty-state artwork cannot be verified in a test.** `Image.asset`
|
||||||
|
resolves against a bundle the test binding does not carry, so no finder sees
|
||||||
|
it. The Activity empty state is asserted by its remaining control instead;
|
||||||
|
the picture itself needs a device check.
|
||||||
|
- **The live map sheet has not been visually QA'd** across the device and
|
||||||
|
text-scale matrix. `_PickupMapScreen` is private and needs Get,
|
||||||
|
SharedPreferences, Geolocator and a live map to pump; that harness does not
|
||||||
|
exist yet.
|
||||||
|
- **`milerGlassSheet`'s blur has not been profiled** on a mid-range Android with
|
||||||
|
the route animating. Readability and GPU cost outrank glassmorphism on an
|
||||||
|
outdoor screen; if it measures badly, a high-opacity surface is the answer.
|
||||||
|
**No profiling was run, so no blur change was made.**
|
||||||
202
DESIGN_SYSTEM.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
# Miler — Surface & Elevation System
|
||||||
|
|
||||||
|
Why the app looked flat, what replaced it, and the rules that keep it fixed.
|
||||||
|
|
||||||
|
**Scope.** `lib/` only. `lib/xpress` is the ported Xpress-rider lane and is not
|
||||||
|
part of this system.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The diagnosis, with numbers
|
||||||
|
|
||||||
|
The app did not look flat because it lacked decoration. It looked flat because
|
||||||
|
**the ground did nothing**, and every screen compensated privately.
|
||||||
|
|
||||||
|
Measured against the page ground:
|
||||||
|
|
||||||
|
| Token | Hex | Contrast vs canvas | Verdict |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `neutralLight` | `#F2F4F8` | **1.021 : 1** | indistinguishable |
|
||||||
|
| `cardSurface` | `#EDEFF4` | **1.023 : 1** | indistinguishable |
|
||||||
|
| `surface` | `#FCF9F8` | **1.073 : 1** | invisible, and *warm* unlike everything else |
|
||||||
|
| `pureSurface` | `#FFFFFF` | **1.124 : 1** | the one that mattered — and too close |
|
||||||
|
|
||||||
|
A tonal difference does not begin to read as a *different surface* until roughly
|
||||||
|
**1.25 : 1**, and on the budget LCD this app ships to it needs the top of that
|
||||||
|
band. So a white card on a white page was not a card; it was a rectangle you had
|
||||||
|
to be told about. Every screen that wanted its content to separate therefore grew
|
||||||
|
its own border or its own shadow — and once one screen does that, they all do,
|
||||||
|
each in its own way. That is the mechanism that turns one product into a
|
||||||
|
collection of independently-built Flutter screens.
|
||||||
|
|
||||||
|
Five near-identical light tokens were also five names for about two decisions.
|
||||||
|
|
||||||
|
### The second half of the diagnosis
|
||||||
|
|
||||||
|
`MilerSheet` — the shared body under Home, Deliveries and Activity — painted
|
||||||
|
**white**. The `daylightSurface` canvas token existed but only two pages used it.
|
||||||
|
So the app's three main screens were a white page carrying white surfaces: a
|
||||||
|
1.0 : 1 step, which is not a step at all.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The fix: four layers
|
||||||
|
|
||||||
|
```
|
||||||
|
0 canvas the page itself. Never white.
|
||||||
|
1 working a region the rider reads or works in. White, no border.
|
||||||
|
2 raised something that acts. Same fill, plus lift.
|
||||||
|
3 floating over everything, with a scrim: sheets, dialogs.
|
||||||
|
```
|
||||||
|
|
||||||
|
Declared once in `lib/views/helpers/constants/miler_surface.dart` as
|
||||||
|
`MilerSurface`. Read that ladder when choosing a ground — not the raw colour
|
||||||
|
constants, which is what let five of them drift into meaning the same thing.
|
||||||
|
|
||||||
|
### Canvas — `#EEF2F7` → `#DEE3EA`
|
||||||
|
|
||||||
|
White now separates at **1.290 : 1** instead of 1.124. Surfaces separate on
|
||||||
|
their own, so the borders and shadows screens grew to compensate can come off
|
||||||
|
rather than being piled higher.
|
||||||
|
|
||||||
|
Nothing was traded for it: body text still clears **13.8 : 1**, and every
|
||||||
|
operational ink clears AA body on both grounds (see §4).
|
||||||
|
|
||||||
|
### Which screen stands on which rung
|
||||||
|
|
||||||
|
The rung is chosen by **what the screen actually draws**, not by preference:
|
||||||
|
|
||||||
|
| Screen | Ground | Because |
|
||||||
|
|---|---|---|
|
||||||
|
| Home | working (white) | rows on a spine — **no cards by design** |
|
||||||
|
| Activity | working (white) | same — a ruled record, not a stack of objects |
|
||||||
|
| Deliveries | **canvas** | its content genuinely *is* raised objects: the NOW card is a layer-2 command surface and needs a ground to sit on |
|
||||||
|
| Record / details | canvas | one white sheet plus folded groups |
|
||||||
|
| All modal sheets | floating | scrim + glass |
|
||||||
|
|
||||||
|
> **The trap, recorded because it was walked into.** Painting Home with the
|
||||||
|
> canvas produced a uniformly grey screen — it has nothing white on it to
|
||||||
|
> separate *from*, so the same flatness came back in a different colour. A
|
||||||
|
> darker ground only helps where something white actually stands on it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Elevation
|
||||||
|
|
||||||
|
| Layer | Fill | Lift |
|
||||||
|
|---|---|---|
|
||||||
|
| working | `pureSurface` | none — the canvas step does the work |
|
||||||
|
| raised | `pureSurface` | `MilerSurface.raisedShadow` (`shadowSm`) |
|
||||||
|
| floating | `glassCard` | `MilerSurface.floatingShadow` + **scrim** |
|
||||||
|
|
||||||
|
`MilerSurface.scrim` (`0x66000000`) replaced Flutter's default `black54` on
|
||||||
|
every sheet. **The scrim is the separation; the shadow is not.** A sheet that
|
||||||
|
needs a heavier shadow to be legible is a sheet whose scrim is too weak — the
|
||||||
|
scrim is the knob.
|
||||||
|
|
||||||
|
### The one nesting the system allows
|
||||||
|
|
||||||
|
`MilerSurface.inset` — a quiet tonal block *inside* layer 1, for a fact grid or
|
||||||
|
a folded group. Only when the block is a different **kind** of thing from what
|
||||||
|
surrounds it. **Never for grouping**: grouping is whitespace, type and the
|
||||||
|
timeline, which is why a route reads as a route without a box around each stop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Colour corrections that fell out of the canvas move
|
||||||
|
|
||||||
|
Two state inks landed in "AA large only" on the deeper ground and were darkened
|
||||||
|
by ~2% — the hue is indistinguishable at this distance, the arithmetic is not:
|
||||||
|
|
||||||
|
| Token | Was | Now | On canvas | On white |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `acceptGreen` | `#047857` | `#047354` | 4.25 → **4.54** | 5.86 |
|
||||||
|
| `warning` | `#AE5008` | `#A44B08` | 4.12 → **4.54** | 5.85 |
|
||||||
|
|
||||||
|
Every operational ink now clears **AA body (4.5:1)** on both grounds. Pinned in
|
||||||
|
`test/surface_system_test.dart` — a contrast rule that lives only in a comment
|
||||||
|
is a rule the next hex nudge undoes.
|
||||||
|
|
||||||
|
### What the colours mean
|
||||||
|
|
||||||
|
| Colour | Means |
|
||||||
|
|---|---|
|
||||||
|
| Brand red `#960019` | identity, navigation, primary action — **not** error |
|
||||||
|
| Green | confirmed success, completion |
|
||||||
|
| Amber | genuine attention/exception only — **never** normal pending |
|
||||||
|
| Error red | failure, and semantically distinct from brand red |
|
||||||
|
| Neutral secondary | pending, waiting, metadata |
|
||||||
|
|
||||||
|
> Pending was amber once. On a fresh morning every kitchen is pending, so the
|
||||||
|
> screen opened in warning and left nothing to say with when a stop actually
|
||||||
|
> went wrong.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Rules
|
||||||
|
|
||||||
|
1. **Choose a ground from the ladder**, not from the colour constants.
|
||||||
|
2. **A container is justified when it is a layer** — a modal sheet, a floating
|
||||||
|
command bar, a grouped operational workspace. Not because the content inside
|
||||||
|
it is a group.
|
||||||
|
3. **No card inside a card**, and no white card on a white surface.
|
||||||
|
4. **If a surface needs a border to be seen, the ground is wrong** — fix the
|
||||||
|
ground, not the surface.
|
||||||
|
5. **Never introduce a one-off colour, radius, shadow or spacing number in a
|
||||||
|
page.** Five near-duplicate tokens is how this started.
|
||||||
|
6. **Measure.** Any new surface pair needs ≥1.25 : 1 to read as two surfaces,
|
||||||
|
and any ink needs ≥4.5 : 1 on the darkest ground it can land on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. The second pass: the ladder made visible
|
||||||
|
|
||||||
|
The foundation pass fixed the ground; a device screenshot showed the result
|
||||||
|
still *read* flat — the silhouette of every page was unchanged. The second
|
||||||
|
pass gave the system its visible vocabulary, applied identically on Home,
|
||||||
|
Deliveries, Activity, Account and the sheets:
|
||||||
|
|
||||||
|
- **Hero heads.** A screen's key figure is a numeral (28sp w800, tabular),
|
||||||
|
captioned small — never a sentence in body type. Home: `15 · stops left`
|
||||||
|
with a 6pt determinate progress bar (canvas track, brand fill, green when
|
||||||
|
done) and `9 of 24 done · ≈4h · 38.9 km` under it. Activity: `4 · delivered
|
||||||
|
today`. Figures clamp at 1.3× text scale; captions ellipsise, numerals never.
|
||||||
|
- **Wells.** Anything that opens — Home's kitchens, Activity's trips — opens
|
||||||
|
into a canvas-toned inset (`radiusLg`, small padding). The well is the
|
||||||
|
ladder's one permitted nesting, and it is the *canvas* tone because that is
|
||||||
|
the only grey that stands on white (1.290:1).
|
||||||
|
- **Tags.** A label a rider matches against the physical world (`Bag 3`) is a
|
||||||
|
small filled tag: white on a well or tinted panel, canvas on white, brand
|
||||||
|
10% when selected, error 8% for `Missing`. Same shape everywhere — Home
|
||||||
|
rows, the pickup sheet, the confirm sheet, the Deliveries queue.
|
||||||
|
- **Placeholders recede.** An unassigned trip tab drops to `disabledFill` and
|
||||||
|
w600 and says `None yet`; empty slots never compete with real work.
|
||||||
|
- **Account joined the ladder**: canvas ground, the identity + day-figures as
|
||||||
|
one panel, each settings group a panel, labels on the canvas between them —
|
||||||
|
scoped to the Account tab so the eight other `settings_ui` screens are
|
||||||
|
untouched.
|
||||||
|
- **`neutralLight` is not a fill on white.** It measures 1.02:1 there; every
|
||||||
|
such use found on these pages now takes the canvas tone. The token keeps
|
||||||
|
its other jobs.
|
||||||
|
|
||||||
|
Pinned by: `home_gutter_test` (hero and route share an edge),
|
||||||
|
`stop_row_alignment_test` (the well's indent is bounded),
|
||||||
|
`home_structure_test` (one well, tags under 120pt, no borders or shadows in a
|
||||||
|
group), `activity_list_test` (a sheet is `working` or `canvas`, never the
|
||||||
|
warm off-white).
|
||||||
|
|
||||||
|
## 7. Known gaps
|
||||||
|
|
||||||
|
This was a foundation pass. It is not a completed product-wide redesign.
|
||||||
|
|
||||||
|
- **`GlassCard` still carries three separation mechanisms** — fill, border and
|
||||||
|
shadow. Its rim is documented as a fix for shadows crushing on a budget LCD,
|
||||||
|
reported twice from a device, so it was left alone rather than changed on a
|
||||||
|
guess. It should be revisited *on a device* now that the ground works.
|
||||||
|
- **No typography or spacing consolidation.** Arbitrary `.sp` values still exist
|
||||||
|
across pages; `MilerType` was not rationalised into a smaller scale.
|
||||||
|
- **Icon families are still mixed.** Lucide was adopted for Activity and the
|
||||||
|
status vocabulary; Material rounded remains widespread elsewhere.
|
||||||
|
- **Account/Profile was not audited or migrated.**
|
||||||
|
- **No device or text-scale QA** of the new ground, and **no GPU profiling** of
|
||||||
|
`milerGlassSheet`'s blur — so no blur change was made.
|
||||||
789
MILER_API_REQUIREMENTS.md
Normal file
@@ -0,0 +1,789 @@
|
|||||||
|
# Miler App — API requirements for the backend team
|
||||||
|
|
||||||
|
**From:** Miler rider-app engineering
|
||||||
|
**Date:** 21 Aug 2026
|
||||||
|
**Against:** *Doormile Miler App — API reference* (38 `/miler/*` routes)
|
||||||
|
**Base URL:** `https://api.doormile.com/api/v1`
|
||||||
|
|
||||||
|
Everything below was traced in the shipped app and, where it says *verified*,
|
||||||
|
confirmed with live calls against production using rider **Rajan A**
|
||||||
|
(`userid 38`, `tenantid 13`, configid 1001) on 20–21 Aug 2026.
|
||||||
|
|
||||||
|
Requests are ordered by **what is blocking riders today**. Each one states the
|
||||||
|
symptom, the evidence, and the smallest change that fixes it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Status — updated 21 Aug 2026
|
||||||
|
|
||||||
|
The backend team shipped requests **1, 2, 3, 5, 6** and cross-cutting items
|
||||||
|
**10.1–10.4** the same day. The app has been updated against the new contract
|
||||||
|
and the whole suite is green; each section below carries its own status line.
|
||||||
|
|
||||||
|
**What the app now does with it**
|
||||||
|
|
||||||
|
| Shipped | What the app changed |
|
||||||
|
|---|---|
|
||||||
|
| `Collected_By_Miler` | A real rung in `ConsignmentState`, excluded from the hub guard — a rider is never told the hub is holding a parcel that is in his own box. |
|
||||||
|
| `POST /consignments/:id/start-delivery` | **Start round** is a server write again. The local released-set is written **only** for what the server released; a stop it refused keeps its PICKED word rather than showing a rung the hub does not agree with. |
|
||||||
|
| `GET /consignments/:consignmentid` | The delivery gate reads state in one call instead of pulling and sorting the whole history. The logs walk stays as a fallback so a rider on this build meeting an older deployment is not stranded. |
|
||||||
|
| `consignmentid` + `consignmentstatus` on lists | The 17/29 blocker is closed, and the row itself now says where the delivery half has got to — so a delivered stop drops off the Deliveries tab without a per-stop round trip. |
|
||||||
|
| `Arrived_At_Pickup` | Parsed and mapped to the *arrived* rung; **I've arrived** now shows in the console. The undocumented `At_Customer` spelling still parses. |
|
||||||
|
| Skip from `Collected_By_Miler` | A failed attempt is reportable the moment the parcel is collected, not only once the round has started. |
|
||||||
|
| Error codes on 4xx | Every branch reads `ApiResult.code` — `INVALID_STATE`, `IDEMPOTENCY_IN_PROGRESS`. No delivery decision is made from message prose any more. |
|
||||||
|
| `Idempotency-Key` | Sent on `pickup-complete`, `deliver`, `skip`, `payment` and `start-delivery` as a stable `verb:resource:day` key. `IDEMPOTENCY_IN_PROGRESS` is waited out and re-asked twice rather than surfaced — a rider is no longer told his delivery failed while it is in the act of succeeding. |
|
||||||
|
|
||||||
|
**The flag split — confirmed and handled.** `Collected_By_Miler` +
|
||||||
|
`start-delivery` ship behind `MILER_COLLECTED_STATE_ENABLED`, **default off**,
|
||||||
|
so hyperlocal pickup still goes straight to `Out_for_Delivery` today. This
|
||||||
|
build is correct in both worlds and does not need the flag flipped:
|
||||||
|
|
||||||
|
- **Flag off** — the row's `consignmentstatus` already reads `Out_for_Delivery`
|
||||||
|
when the load reaches the Deliveries tab, so **Start round** spends no
|
||||||
|
request at all and behaves exactly as it does now. The app never fires a
|
||||||
|
`start-delivery` that can only be refused.
|
||||||
|
- **Flag on** — the row reads `Collected_By_Miler`, the press calls
|
||||||
|
`start-delivery`, and the local released-set is written only for what the
|
||||||
|
server released.
|
||||||
|
|
||||||
|
Nothing here needs coordinating: flip the flag whenever this build is out.
|
||||||
|
|
||||||
|
**Also shipped, unannounced and now consumed:** `step`, `stoptype`,
|
||||||
|
`etaminutes`, `cumulativekms` and `cumulativeeta` on the booking row. The
|
||||||
|
adapter builds a fixed map, so it had been dropping all five; the app now reads
|
||||||
|
them. `step` in particular is what lets the delivery leg follow the hub's route
|
||||||
|
instead of re-sorting nearest-first — **but see request 13: it is `0` on every
|
||||||
|
row.**
|
||||||
|
|
||||||
|
**Still open:** request 4 (upload route for proof of delivery — the photo
|
||||||
|
cannot leave the phone until this exists), **13** (populate the sequence),
|
||||||
|
**14** (an outcome for a failed attempt), 7, 8, 9.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0b. Summary table
|
||||||
|
|
||||||
|
| # | Request | Priority | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | A consignment state that means *collected, not yet riding* | **P0** | ✅ Shipped 21 Aug — `Collected_By_Miler` + `start-delivery` |
|
||||||
|
| 2 | `GET /miler/consignments/:id` (current state, one call) | **P0** | ✅ Shipped 21 Aug |
|
||||||
|
| 3 | Return `consignmentid` on `GET /miler/bookings` | **P0** | ✅ Shipped 21 Aug — with `consignmentstatus` |
|
||||||
|
| 4 | A file-upload route for proof of delivery | **P1** | ⏳ **Open** — POD cannot leave the phone |
|
||||||
|
| 5 | A booking state for *arrived at pickup* | **P1** | ✅ Shipped 21 Aug — `Arrived_At_Pickup` |
|
||||||
|
| 6 | Allow cancel/skip after pickup, or document the refusal | **P1** | ✅ Shipped 21 Aug — skip from collected |
|
||||||
|
| 7 | Real route distance + duration on the assignment | **P2** | ⏳ Open — figures are estimates |
|
||||||
|
| 8 | Persist `bonuspoints`, and a per-stop payout figure | **P2** | ⏳ Open — Earnings shows placeholders |
|
||||||
|
| 9 | Notification read-state table | **P3** | ⏳ Open — known stub |
|
||||||
|
| 13 | Route sequence — field shipped, **never populated** | **P0** | ⚠️ **Half** — `step` is on both endpoints but is `0` everywhere, `sequencedat` null |
|
||||||
|
| 14 | An outcome for a failed delivery attempt | **P1** | ⏳ **Open** — skip leaves the consignment open with no way to say why |
|
||||||
|
| 15 | `reached` must actually persist `Arrived_At_Pickup` | **P0** | 🔴 **BLOCKER** — reproduced: returns 200, writes nothing |
|
||||||
|
| 16 | Admin mapping for `Arrived_At_Pickup` + `Collected_By_Miler` | **P0** | 🔴 **Console change** — both absent; flag must not be flipped first |
|
||||||
|
| 17 | What `/admin/bookings` returns in `status` | **P1** | ❓ Unproven — one console network call answers it |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. P0 — There is no consignment state meaning "collected, not yet riding"
|
||||||
|
|
||||||
|
> **✅ Shipped 21 Aug 2026 — Option A.** `pickup-complete` now stops at
|
||||||
|
> `Collected_By_Miler`; `POST /miler/consignments/:id/start-delivery` makes the
|
||||||
|
> release. The app's **Start round** is a server write again.
|
||||||
|
|
||||||
|
### Symptom (reported)
|
||||||
|
> *"If I try to update as Picked it is directly updating as **active** in the
|
||||||
|
> console."*
|
||||||
|
|
||||||
|
### Evidence — verified
|
||||||
|
`GET /miler/consignments/logs/34` returns the consignment's real history:
|
||||||
|
|
||||||
|
```
|
||||||
|
Out_for_Delivery "Package collected by miler and converted to consignment"
|
||||||
|
Delivered "Delivered to SEQTEST Ukkadam at (11.00506, 76.95087)"
|
||||||
|
```
|
||||||
|
|
||||||
|
The first row is written by **`pickup-complete`** — it carries that handler's
|
||||||
|
own remark. So on the hyperlocal path, `pickup-complete` moves the consignment
|
||||||
|
straight to `Out_for_Delivery`.
|
||||||
|
|
||||||
|
The console maps (`src/utils/bookingStatus.js`):
|
||||||
|
|
||||||
|
```js
|
||||||
|
picked_up: 'picked',
|
||||||
|
converted_to_consignment: 'picked',
|
||||||
|
out_for_delivery: 'active', // ← what the operator sees
|
||||||
|
```
|
||||||
|
|
||||||
|
So the moment the rider taps **Picked** at the kitchen counter, the consignment
|
||||||
|
is `Out_for_Delivery` and the operator's board says **active** — while the food
|
||||||
|
is still on the counter and the rider has not moved.
|
||||||
|
|
||||||
|
### Why this is a backend request, not a UI one
|
||||||
|
The app already models the distinction locally (it knows the rider has not set
|
||||||
|
off), but local state cannot reach the hub, and the operator's board is fed
|
||||||
|
from server state. There is no server state that expresses *collected but not
|
||||||
|
yet out for delivery*, so the information does not exist to display.
|
||||||
|
|
||||||
|
### Requested change — pick **one**
|
||||||
|
|
||||||
|
**Option A (preferred).** Add a consignment status between conversion and the
|
||||||
|
road:
|
||||||
|
|
||||||
|
```
|
||||||
|
Created → Inwarded_at_Hub → … → Out_for_Delivery → Delivered
|
||||||
|
▲
|
||||||
|
Collected_By_Miler ← NEW
|
||||||
|
```
|
||||||
|
|
||||||
|
`pickup-complete` writes `Collected_By_Miler` for hyperlocal work.
|
||||||
|
A new call moves it on:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /miler/consignments/:id/start-delivery
|
||||||
|
→ 200 { success, data: { consignmentid, status: "Out_for_Delivery" } }
|
||||||
|
400 if the consignment is not Collected_By_Miler
|
||||||
|
```
|
||||||
|
|
||||||
|
`deliver` then accepts `Out_for_Delivery` exactly as it does now.
|
||||||
|
|
||||||
|
**Option B (smaller).** Leave the consignment alone and keep the **booking** at
|
||||||
|
`Picked_Up` until the rider starts the round, moving it to
|
||||||
|
`Converted_To_Consignment` at that point. Cheaper, but it leaves the two
|
||||||
|
vocabularies coupled, which is the root of several problems in this document.
|
||||||
|
|
||||||
|
> **Note on `POST /miler/deliveries/start`:** the app used to call this. It is
|
||||||
|
> **not** in the 38-route contract and returns `404 Cannot POST` — verified. We
|
||||||
|
> have removed the call. Request 1 Option A is the properly-specced replacement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. P0 — No way to read a consignment's current state in one call
|
||||||
|
|
||||||
|
> **✅ Shipped 21 Aug 2026.** `GET /miler/consignments/:consignmentid` returns
|
||||||
|
> the status plus `collected` / `out_for_delivery` / `delivered` /
|
||||||
|
> `can_start_delivery` / `can_deliver` / `can_skip`. The gate reads it; the
|
||||||
|
> logs walk remains only as a fallback for older deployments.
|
||||||
|
|
||||||
|
### Symptom (reported)
|
||||||
|
> *"I can't update it as delivered/skipped/cancelled — it's showing a lot of
|
||||||
|
> errors."*
|
||||||
|
|
||||||
|
### Evidence — verified
|
||||||
|
`POST /miler/consignments/34/deliver` →
|
||||||
|
|
||||||
|
```json
|
||||||
|
400 { "success": false, "message": "consignment is not out for delivery" }
|
||||||
|
```
|
||||||
|
|
||||||
|
That message is returned for **two opposite situations**:
|
||||||
|
|
||||||
|
| Real state | Meaning | What the rider should be told |
|
||||||
|
|---|---|---|
|
||||||
|
| `Inwarded_at_Hub` | not released yet | "the hub still has it" |
|
||||||
|
| `Delivered` | **already delivered** | "already done — nothing to do" |
|
||||||
|
|
||||||
|
In the reported case it was the second: consignment 34 had been delivered at
|
||||||
|
10:59 the previous day. The rider was shown an error for work he had completed.
|
||||||
|
|
||||||
|
### Why the app cannot fix this alone
|
||||||
|
`GET /miler/bookings` reports only the **booking** status, which is terminal at
|
||||||
|
`Converted_To_Consignment` and never learns about the delivery half. The only
|
||||||
|
route that exposes consignment state is
|
||||||
|
`GET /miler/consignments/logs/:consignmentid`, which returns the whole event
|
||||||
|
history — the app now sorts it by `historyid` and takes the last row. That
|
||||||
|
works, but it is a log-walk standing in for a state read, on the rider's mobile
|
||||||
|
data, before every delivery.
|
||||||
|
|
||||||
|
### Requested change
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /miler/consignments/:consignmentid
|
||||||
|
→ 200 {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
consignmentid: 34,
|
||||||
|
trackingno: "DM-TRK-…",
|
||||||
|
status: "Out_for_Delivery",
|
||||||
|
bookingid: 60,
|
||||||
|
attemptcount: 0,
|
||||||
|
updatedat: "2026-08-20T10:59:35Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
404 if it is not this rider's consignment
|
||||||
|
```
|
||||||
|
|
||||||
|
**And** make the two refusals distinguishable — either distinct messages or,
|
||||||
|
better, a stable code:
|
||||||
|
|
||||||
|
```json
|
||||||
|
400 { "success": false, "code": "CONSIGNMENT_ALREADY_DELIVERED",
|
||||||
|
"message": "consignment already delivered" }
|
||||||
|
400 { "success": false, "code": "CONSIGNMENT_NOT_RELEASED",
|
||||||
|
"message": "consignment is not out for delivery" }
|
||||||
|
```
|
||||||
|
|
||||||
|
A machine-readable `code` on every 4xx across `/miler/*` would let the app stop
|
||||||
|
string-matching messages, which is fragile in exactly the way this bug proves.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. P0 — `GET /miler/bookings` does not return `consignmentid`
|
||||||
|
|
||||||
|
> **✅ Shipped 21 Aug 2026.** Both `consignmentid` and `consignmentstatus` are
|
||||||
|
> on the list rows. The 17-of-29 gap is closed, and the consignment status now
|
||||||
|
> outranks the booking status the app draws the card from.
|
||||||
|
|
||||||
|
### Evidence — verified
|
||||||
|
24 booking rows returned for rider 38; **zero** contain any `consignment*` key.
|
||||||
|
`GET /miler/assignments` (list) does not carry it either. It appears **only** in
|
||||||
|
`GET /miler/assignments/:id`, nested inside `booking`:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// GET /miler/assignments/71
|
||||||
|
{ "data": { "assignment": {…}, "booking": { "bookingid": 75, …,
|
||||||
|
"consignmentid": 46 } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### This is not a performance concern — it blocks deliveries outright
|
||||||
|
|
||||||
|
**Verified 21 Aug 2026, rider 38:**
|
||||||
|
|
||||||
|
| Endpoint | Rows | Booking ids |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /miler/bookings` | **29** | 26, 28, 41, 51, **54–78**, 82 |
|
||||||
|
| `GET /miler/assignments` | **12** | 26, 28, 41, 51, 72–78, 82 |
|
||||||
|
|
||||||
|
**17 of 29 bookings have no assignment row at all.** `?status=Completed`,
|
||||||
|
`?status=all`, `?pagesize=200` and `?bookingid=59` all return the same 12 rows —
|
||||||
|
the endpoint ignores query parameters.
|
||||||
|
|
||||||
|
So for booking **59** (`DM-BK-501CB551-45130`, status
|
||||||
|
`Converted_To_Consignment`, sitting on the rider's Deliveries tab) every route
|
||||||
|
to its consignment id is a dead end:
|
||||||
|
|
||||||
|
1. the booking row — carries no consignment key *(none of the 29 do)*
|
||||||
|
2. the app's local record from `pickup-complete` — absent after a reinstall,
|
||||||
|
and absent entirely if that response did not carry the id
|
||||||
|
3. `GET /miler/assignments/:id` — **booking 59 is not in the assignments list**,
|
||||||
|
so there is no assignment id to fetch
|
||||||
|
4. `GET /miler/consignments/userlogs/:userid` — telemetry only (1 row, for an
|
||||||
|
unrelated consignment); carries no booking linkage
|
||||||
|
|
||||||
|
**There is no fifth route.** The rider is holding the parcel, the consignment
|
||||||
|
demonstrably exists (the booking is converted), and the app cannot name it — so
|
||||||
|
`deliver` can never be called and the stop can never be completed from the app.
|
||||||
|
|
||||||
|
This is the single highest-impact item in this document.
|
||||||
|
|
||||||
|
### Requested change
|
||||||
|
Add `consignmentid` (nullable) and `consignmentstatus` to every row of:
|
||||||
|
|
||||||
|
- `GET /miler/bookings`
|
||||||
|
- `GET /miler/assignments`
|
||||||
|
|
||||||
|
…and separately, **`GET /miler/assignments` should return every assignment the
|
||||||
|
rider still has work for**, not a 12-row subset. If that list is intentionally
|
||||||
|
scoped to active assignments, say so and we will stop treating it as a lookup
|
||||||
|
table — but then requirement (a) below becomes mandatory rather than merely
|
||||||
|
strongly preferred.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{ "bookingid": 75, "status": "Converted_To_Consignment",
|
||||||
|
"consignmentid": 46, "consignmentstatus": "Out_for_Delivery" }
|
||||||
|
```
|
||||||
|
|
||||||
|
This single change removes an N+1 call pattern **and** makes Request 2's read
|
||||||
|
unnecessary for the list case.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. P1 — No upload route: proof of delivery cannot leave the phone
|
||||||
|
|
||||||
|
> **⏳ STILL OPEN.** The photo is captured, previewed and stored on the device,
|
||||||
|
> and it is shown on the Activity record. It cannot reach the hub until this
|
||||||
|
> route exists. This is now the highest-priority outstanding item.
|
||||||
|
|
||||||
|
### What the app now does
|
||||||
|
The rider taps **Delivered** → a proof screen opens the camera → he sees the
|
||||||
|
photo → **Mark as delivered** on the same screen. The photo is stored on the
|
||||||
|
handset and shown on the delivery record in Activity.
|
||||||
|
|
||||||
|
### The blocker
|
||||||
|
`POST /miler/consignments/:id/deliver` takes `photourl` — a **string**. Nothing
|
||||||
|
in the 38-route contract accepts a file: no multipart route, no signed-URL
|
||||||
|
endpoint, no attachment on any other call.
|
||||||
|
|
||||||
|
We deliberately **do not** send the device file path in `photourl`. A path from
|
||||||
|
somebody's phone is not a URL; writing one into the hub's record would store a
|
||||||
|
string that looks like evidence and resolves to nothing.
|
||||||
|
|
||||||
|
**So proof of delivery currently exists only on the rider's phone**, and the
|
||||||
|
hub cannot see it. For a delivery business this is the difference between
|
||||||
|
having evidence and believing you have it.
|
||||||
|
|
||||||
|
### Requested change — either shape works
|
||||||
|
|
||||||
|
**Option A — direct upload (simplest for us).**
|
||||||
|
```http
|
||||||
|
POST /miler/uploads
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
file: <binary> (jpeg, ≤ 5 MB)
|
||||||
|
purpose: "delivery_proof" ("pickup_proof" | "signature" | …)
|
||||||
|
consignmentid: 46 (optional, for association)
|
||||||
|
→ 201 { success, data: { url: "https://cdn.doormile.com/proof/46-abc.jpg" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B — signed URL (cheaper server-side).**
|
||||||
|
```http
|
||||||
|
POST /miler/uploads/sign
|
||||||
|
{ "purpose": "delivery_proof", "contentType": "image/jpeg" }
|
||||||
|
→ 200 { success, data: { uploadUrl: "https://…?X-Amz-Signature=…",
|
||||||
|
url: "https://cdn.doormile.com/proof/…" } }
|
||||||
|
```
|
||||||
|
The app PUTs the bytes to `uploadUrl`, then sends `url` as `photourl`.
|
||||||
|
|
||||||
|
**Constraints from our side:** photos are JPEG, quality 70, max width 1280 —
|
||||||
|
typically 80–250 KB. Riders are on mobile data and often on 3G, so the upload
|
||||||
|
must be retryable and must **not** block the delivery: we will complete the
|
||||||
|
stop and upload in the background, then attach.
|
||||||
|
|
||||||
|
Please also confirm whether `receiversignatureurl` is expected to be fed from
|
||||||
|
the same mechanism.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. P1 — "Arrived" does not persist: there is no booking state for it
|
||||||
|
|
||||||
|
> **✅ Shipped 21 Aug 2026.** `reached` persists `Arrived_At_Pickup`. The app
|
||||||
|
> parses it (and the older `At_Customer` spelling) to the *arrived* rung.
|
||||||
|
|
||||||
|
### Symptom (reported)
|
||||||
|
> *"If I click Arrived on the home page it is not updating as arrived."*
|
||||||
|
|
||||||
|
### Evidence
|
||||||
|
The app calls `POST /miler/bookings/:bookingid/reached` with the booking id —
|
||||||
|
correct per contract. But the booking enum is:
|
||||||
|
|
||||||
|
```
|
||||||
|
Pending_Pickup, Created, Miler_Assigned, Pickup_Scheduled,
|
||||||
|
Picked_Up, Converted_To_Consignment, Cancelled
|
||||||
|
```
|
||||||
|
|
||||||
|
**There is no `Arrived`.** Whatever `reached` writes, the operator's board maps
|
||||||
|
`pickup_scheduled → 'accepted'`, so an arrived rider is indistinguishable from
|
||||||
|
one who merely accepted the job and is still 20 km away.
|
||||||
|
|
||||||
|
### Requested change
|
||||||
|
Add `Arrived_At_Pickup` to the booking enum, written by `reached`, and return
|
||||||
|
the new status in the response so the app can confirm rather than assume:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /miler/bookings/:bookingid/reached
|
||||||
|
→ 200 { success, data: { bookingid, status: "Arrived_At_Pickup",
|
||||||
|
reachedat: "2026-08-21T09:14:02Z" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Every state-changing route should return the resulting entity state.** Today
|
||||||
|
most return `{success: true}` with no body, so the app cannot verify the
|
||||||
|
transition landed and must re-poll the list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. P1 — A rider cannot report a failed delivery
|
||||||
|
|
||||||
|
> **✅ Shipped 21 Aug 2026.** `skip` is accepted from `Collected_By_Miler` as
|
||||||
|
> well as `Out_for_Delivery`.
|
||||||
|
|
||||||
|
### Symptom (reported)
|
||||||
|
> *"I can't update it as … skipped/cancelled."*
|
||||||
|
|
||||||
|
### Evidence
|
||||||
|
- `POST /miler/consignments/:id/skip` requires `Out_for_Delivery` — so a
|
||||||
|
consignment in any other state cannot be skipped, including one the rider is
|
||||||
|
genuinely holding.
|
||||||
|
- `POST /miler/bookings/:id/cancel` is **"refused once picked up"** per the
|
||||||
|
contract. After `pickup-complete` there is therefore *no* route by which a
|
||||||
|
rider can say "this cannot be completed" — he is holding a parcel with no way
|
||||||
|
to report the failure.
|
||||||
|
|
||||||
|
### Requested change
|
||||||
|
1. Allow `skip` from `Collected_By_Miler` **and** `Out_for_Delivery` (see
|
||||||
|
Request 1).
|
||||||
|
2. Add a consignment-level failure route:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /miler/consignments/:id/fail
|
||||||
|
{ "reason": "Customer refused", "lat": 11.0, "lon": 76.9 }
|
||||||
|
→ 200 { success, data: { status: "RTO_Initiated", attemptcount: 2 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Document the maximum `attemptcount` and what the hub does when it is hit —
|
||||||
|
the app should stop offering "skip" at that point rather than letting the
|
||||||
|
rider discover the ceiling by being refused.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. P2 — The Home figures are estimates, not real data
|
||||||
|
|
||||||
|
**Direct answer to the question asked.** The four figures on Home
|
||||||
|
(`DURATION · DISTANCE · PARCELS · PAYMENT`) are:
|
||||||
|
|
||||||
|
| Figure | Source | Real? |
|
||||||
|
|---|---|---|
|
||||||
|
| **PARCELS** | sum of parcel counts on the booking rows | ✅ **Real** (backend data) |
|
||||||
|
| **PAYMENT** | sum of `collectionamt` on the booking rows | ✅ **Real** (backend data) |
|
||||||
|
| **DISTANCE** | *client-side* haversine: hub → each stop in order → hub, straight lines. Falls back to summing each row's `kms` when coordinates are missing | ⚠️ **Estimate** |
|
||||||
|
| **DURATION** | *client-side*: straight-line distance ÷ an assumed 20 km/h, **plus** a hardcoded service time per stop — 2m30 delivery / 4m00 pickup / 5m30 combined, +90s if cash, +25s per extra parcel | ⚠️ **Estimate** |
|
||||||
|
|
||||||
|
So two of the four are real and two are the app's own arithmetic. Straight-line
|
||||||
|
distance under-reads real road distance by roughly 20–40% in Coimbatore, and
|
||||||
|
the service times are guesses that have never been measured against actual
|
||||||
|
stop durations.
|
||||||
|
|
||||||
|
### Requested change
|
||||||
|
Return the routed figures on the assignment/trip, computed once server-side
|
||||||
|
(you already have the coordinates, and a routing engine gives real road
|
||||||
|
distance):
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// GET /miler/assignments — per row, or a trip-level summary
|
||||||
|
{
|
||||||
|
"routedistancemeters": 21800, // real road distance for the leg
|
||||||
|
"routedurationseconds": 1620, // real drive time
|
||||||
|
"cumulativedistancemeters": 48200,
|
||||||
|
"cumulativedurationseconds": 5400
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The assignment model already has `riderkms`, `previouskms`, `cumulativekms`,
|
||||||
|
`etaminutes` and `cumulativeeta` — **all of them return 0** today (verified on
|
||||||
|
all 24 rows). Populating those existing fields would be enough; no new schema
|
||||||
|
needed.
|
||||||
|
|
||||||
|
**Until then the app will keep showing estimates, because inventing precision
|
||||||
|
we do not have is worse than an honest approximation.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. P2 — Earnings has no real per-stop money
|
||||||
|
|
||||||
|
- `bonuspoints` is never written (acknowledged in your own doc).
|
||||||
|
- There is no per-stop payout anywhere in the API. `deliver` computes
|
||||||
|
`riderkms` and `ridercharges` server-side and returns **neither**;
|
||||||
|
`GET /miler/earnings` answers for a *period* only.
|
||||||
|
|
||||||
|
The app therefore shows `Stop payout · See Account` on the delivery record
|
||||||
|
rather than a number, because a figure derived from a rate the app does not
|
||||||
|
hold is the one invention a rider would act on and be wrong about.
|
||||||
|
|
||||||
|
### Requested change
|
||||||
|
Return the earnings the server already computed, on the response to `deliver`
|
||||||
|
and on the booking/consignment rows:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{ "riderkms": 4.6, "ridercharges": 38.50, "bonuspoints": 5 }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Battery percentage — direct answer
|
||||||
|
|
||||||
|
**Yes, it can reach the console today, and the plumbing already exists on both
|
||||||
|
sides.**
|
||||||
|
|
||||||
|
- The app reads the real battery level (`battery_plus`) in
|
||||||
|
`live_tracking_service.dart` and `foreground_service.dart`.
|
||||||
|
- It is sent on `POST /miler/logs` and `POST /miler/consignments/logs` as the
|
||||||
|
documented **string** field `battery`.
|
||||||
|
- The console already renders it — `src/pages/nearle/riders/riders.js`:
|
||||||
|
`riderLogsdata?.battery ? \`${battery}%\` : 'N/A'`.
|
||||||
|
|
||||||
|
**What to check on your side:** the console reads it from *rider logs*, so it
|
||||||
|
only appears while the app is posting telemetry (on duty, service running). If
|
||||||
|
operators see `N/A`, the likely causes in order are: (a) the rider is off duty
|
||||||
|
so nothing is being posted; (b) `GET /miler/logs` is returning the latest row
|
||||||
|
without `battery` populated; (c) the value is being stored but the console's
|
||||||
|
rider-detail query is not selecting it.
|
||||||
|
|
||||||
|
**One request:** expose the most recent telemetry row per rider in one call, so
|
||||||
|
the console does not have to page logs to find a current battery/GPS reading:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /miler/riders/:userid/last-seen (or /admin equivalent)
|
||||||
|
→ { success, data: { latitude, longitude, battery, speed, status,
|
||||||
|
logdate, connection } }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Cross-cutting requests
|
||||||
|
|
||||||
|
> **✅ 10.1–10.4 shipped 21 Aug 2026.** Mutations return the resulting state,
|
||||||
|
> `pickup-complete` returns `consignment_id` and `next_action`, 4xx bodies
|
||||||
|
> carry a machine-readable `code`, and `Idempotency-Key` is honoured. 10.5
|
||||||
|
> (`requiredeliveryotp` on the tenant payload) is still open.
|
||||||
|
|
||||||
|
These are small and would remove whole classes of defect:
|
||||||
|
|
||||||
|
1. **Return the resulting state from every mutation.** `accept`, `reject`,
|
||||||
|
`reached`, `parcel`, `payment`, `pickup-complete`, `deliver`, `skip`,
|
||||||
|
`cancel` mostly return `{success: true}`. The app cannot confirm a
|
||||||
|
transition and must re-poll.
|
||||||
|
|
||||||
|
2. **`pickup-complete` must return the `consignmentid` it minted.** It is the
|
||||||
|
one moment the id is guaranteed to exist. The app records it locally from
|
||||||
|
the response today; when the response omits it, the delivery leg has to go
|
||||||
|
hunting (Request 3).
|
||||||
|
|
||||||
|
3. **Machine-readable error codes on every 4xx** (see Request 2). String
|
||||||
|
matching on `"consignment is not out for delivery"` is how the app ended up
|
||||||
|
telling riders the wrong thing.
|
||||||
|
|
||||||
|
4. **Idempotency.** `deliver`, `pickup-complete` and `accept` are not
|
||||||
|
idempotent. A dropped response on a bad connection means the rider retries
|
||||||
|
and gets a 400 for work that succeeded. Accepting an
|
||||||
|
`Idempotency-Key` header and replaying the original result would remove
|
||||||
|
this entire failure mode.
|
||||||
|
|
||||||
|
5. **Confirm `requiredeliveryotp` per tenant** is exposed to the app. We
|
||||||
|
currently send `otp` only when we have one and never fabricate a
|
||||||
|
placeholder; if the flag were on the profile/tenant payload we could show
|
||||||
|
or hide the OTP field correctly instead of inferring.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. What the app already does correctly (no change needed)
|
||||||
|
|
||||||
|
Recorded so the backend team does not chase these:
|
||||||
|
|
||||||
|
- `deliver`/`skip` key on the **consignment id**, never the booking, assignment
|
||||||
|
or pickup id.
|
||||||
|
- `accept`/`reject` key on `bookingassignmentid`.
|
||||||
|
- `reached`/`parcel`/`payment`/`pickup-complete`/`cancel` key on `bookingid`.
|
||||||
|
- Telemetry sends lat/long/speed/heading/battery as **strings**, and never
|
||||||
|
sends `userid` in the body.
|
||||||
|
- Booking statuses and consignment statuses are handled as two separate
|
||||||
|
vocabularies; the app no longer infers a delivery state from a booking
|
||||||
|
status.
|
||||||
|
- Duplicate taps are collapsed client-side (one in-flight mutation per
|
||||||
|
resource+verb), but see Request 10.4 — that is not a substitute for
|
||||||
|
server-side idempotency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11b. Request 13 — P0: the route sequence is exposed but never populated
|
||||||
|
|
||||||
|
**Re-verified live 21 Aug 2026** against rider `userid 23 / tenantid 1` —
|
||||||
|
`GET /miler/bookings` (29 rows) and `GET /miler/assignments` (11 rows).
|
||||||
|
|
||||||
|
### The transport is there. Thank you.
|
||||||
|
|
||||||
|
`step` is on **both** endpoints, alongside `sequencedat`, `etaminutes`,
|
||||||
|
`cumulativekms`, `cumulativeeta` and `stoptype`. That closes the client half of
|
||||||
|
this entirely: the app now reads `step` off the booking row, orders both legs
|
||||||
|
by it, and never re-sorts an assigned route.
|
||||||
|
|
||||||
|
### The data is empty
|
||||||
|
|
||||||
|
```
|
||||||
|
/miler/bookings step = 0 on all 29 rows
|
||||||
|
/miler/assignments step = 0 on all 11 rows
|
||||||
|
sequencedat = null on all 11 rows
|
||||||
|
etaminutes = 0, riderkms = 0, cumulativekms = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
`sequencedat: null` on every assignment means **the route optimizer has never
|
||||||
|
run for this rider**. So there is no admin route to follow — not one the app is
|
||||||
|
dropping, one that does not exist.
|
||||||
|
|
||||||
|
### What the app does about it
|
||||||
|
|
||||||
|
It does not pretend. With no sequence anywhere in the set it falls back —
|
||||||
|
booked time first, then nearest-first — and **labels the queue on screen** with
|
||||||
|
which rule produced the order (`Hub route` vs `Nearest first`), so a rider is
|
||||||
|
never told the app's guess is his route. It is also logged per fetch:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ROUTE][deliveries] 7 stop(s) with no assigned sequence — ordering is the
|
||||||
|
app's own fallback, not the hub's route
|
||||||
|
```
|
||||||
|
|
||||||
|
### Requested change
|
||||||
|
|
||||||
|
Populate it. Whatever writes `sequencedat` — batch assign, the console's
|
||||||
|
sequence action, a scheduled optimizer pass — is either not running or not
|
||||||
|
running for this tenant. Once `step` comes down non-zero, the app follows it on
|
||||||
|
both legs with no further change and no release needed.
|
||||||
|
|
||||||
|
**Please confirm:** is sequencing expected to be automatic on assignment, or is
|
||||||
|
it a deliberate action a hub manager has to take in the console? The answer
|
||||||
|
changes whether "no route assigned" is a normal state the rider should see or a
|
||||||
|
fault worth alerting on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11c. Request 14 — P1: a failed delivery attempt has no outcome
|
||||||
|
|
||||||
|
`POST /miler/consignments/:id/skip` returns 200 and, as far as the app can
|
||||||
|
tell, leaves the consignment `Out_for_Delivery`. There is no state, field or
|
||||||
|
route that says *this stop was attempted and failed*.
|
||||||
|
|
||||||
|
That leaves the two systems unable to agree on whether the stop is still
|
||||||
|
actionable:
|
||||||
|
|
||||||
|
- The **rider** has been to the door. It is done with, for him, today.
|
||||||
|
- The **hub** still has an open `Out_for_Delivery` consignment.
|
||||||
|
|
||||||
|
The app refuses to resolve that by inventing a terminal state locally — it
|
||||||
|
reads the consignment after every skip and only files the stop as finished if
|
||||||
|
the server agrees it is closed. When the server keeps it open, the stop is
|
||||||
|
**parked**: still visible under SKIPPED with the rider's reason, still holding
|
||||||
|
its consignment mapping, resumable by him. Honest, but it is a workaround for a
|
||||||
|
missing contract.
|
||||||
|
|
||||||
|
### Requested change — any one of these
|
||||||
|
|
||||||
|
1. A consignment state for it — `Delivery_Failed` / `Attempt_Failed` — that
|
||||||
|
`skip` moves it to, with whatever reassignment or RTO the hub decides
|
||||||
|
happening from there.
|
||||||
|
2. An `attemptcount` + `lastattemptat` on the consignment, so the app can at
|
||||||
|
least show *"attempt 2 of 3"* and the hub can act on the count.
|
||||||
|
3. Documented confirmation that a skip is expected to leave the consignment
|
||||||
|
open and that the rider is meant to retry it in the same shift — in which
|
||||||
|
case the app will surface it as retryable rather than as a closed record.
|
||||||
|
|
||||||
|
Whichever you pick, the app needs to know **who owns the stop after a skip**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11d. Request 15 — P0 BLOCKER: `reached` is a deployed no-op
|
||||||
|
|
||||||
|
**Reproduced against production 21 Aug 2026.** Rider `userid 23 / tenantid 1`,
|
||||||
|
booking 78, three calls in sequence:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /miler/bookings → booking 78 status = Miler_Assigned
|
||||||
|
|
||||||
|
POST /miler/bookings/78/reached
|
||||||
|
{"lat":11.0168,"lon":76.9558}
|
||||||
|
→ 200 {"success":true,"data":{"bookingid":78,"status":"Miler_Assigned"}}
|
||||||
|
|
||||||
|
GET /miler/bookings → booking 78 status = Miler_Assigned
|
||||||
|
```
|
||||||
|
|
||||||
|
The endpoint returns `success: true`, echoes the booking's **unchanged**
|
||||||
|
status, and writes nothing. `Arrived_At_Pickup` is not written and does not
|
||||||
|
appear on any of the 31 bookings in this tenant.
|
||||||
|
|
||||||
|
**This is the whole reason Arrived never reaches Admin.** It is not a client
|
||||||
|
bug: the app calls the documented endpoint with the documented body and gets a
|
||||||
|
200. It had been treating that 200 as proof of a transition, which it no longer
|
||||||
|
does — the app now reads the returned status and, when it is not
|
||||||
|
`Arrived_At_Pickup`, tells the rider his hub has not recorded the arrival
|
||||||
|
rather than drawing a tick.
|
||||||
|
|
||||||
|
### Requested change
|
||||||
|
|
||||||
|
`POST /miler/bookings/:bookingid/reached` must persist `Arrived_At_Pickup` on
|
||||||
|
the booking and return it:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /miler/bookings/:bookingid/reached
|
||||||
|
{ "lat": 11.0168, "lon": 76.9558 }
|
||||||
|
|
||||||
|
200 { "success": true,
|
||||||
|
"data": { "bookingid": 78, "status": "Arrived_At_Pickup" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Please also confirm which is true**, because they need different fixes:
|
||||||
|
1. the handler was never wired to write, or
|
||||||
|
2. it writes only from `Pickup_Scheduled` and silently no-ops from
|
||||||
|
`Miler_Assigned` (booking 78 was assigned but not yet accepted).
|
||||||
|
|
||||||
|
If (2), say so and the app will stop offering **I've arrived** before the
|
||||||
|
accept lands. If it is meant to be callable from `Miler_Assigned`, it must
|
||||||
|
write from there too.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11e. Request 16 — P0: Admin has no mapping for the two new statuses
|
||||||
|
|
||||||
|
**Not a backend change — a console change.** Traced in
|
||||||
|
`Doormilexpress_console/src/utils/bookingStatus.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const BOOKING_STATUS_TO_DELIVERY_STATUS = {
|
||||||
|
created: 'pending', pending_pickup: 'pending',
|
||||||
|
miler_assigned: 'pending', pickup_scheduled: 'accepted',
|
||||||
|
picked_up: 'picked', converted_to_consignment: 'picked',
|
||||||
|
out_for_delivery: 'active',
|
||||||
|
delivered: 'delivered', cancelled: 'cancelled'
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Neither `arrived_at_pickup` nor `collected_by_miler` is in it. Unmapped
|
||||||
|
statuses "pass through lowercased", which the Deliveries page renders as an
|
||||||
|
unknown badge — and the file's own comment says the Arrived tab "will show a 0
|
||||||
|
count until the real enum is confirmed".
|
||||||
|
|
||||||
|
Two consequences the console team must act on:
|
||||||
|
|
||||||
|
1. **Even after request 15 ships, Admin still will not show Arrived.** Add
|
||||||
|
`arrived_at_pickup: 'arrived'`.
|
||||||
|
2. **Enabling `MILER_COLLECTED_STATE_ENABLED` today would make Admin worse,
|
||||||
|
not better.** With no `collected_by_miler` key, every freshly collected
|
||||||
|
parcel would render as an unknown badge instead of Active. Add
|
||||||
|
`collected_by_miler: 'picked'` **before** the flag is flipped.
|
||||||
|
|
||||||
|
### Ordering — this matters
|
||||||
|
|
||||||
|
```
|
||||||
|
1. console adds arrived_at_pickup + collected_by_miler mappings
|
||||||
|
2. backend fixes `reached` to persist Arrived_At_Pickup (request 15)
|
||||||
|
3. this app build ships — it already supports both lifecycles
|
||||||
|
4. only then flip MILER_COLLECTED_STATE_ENABLED = true
|
||||||
|
```
|
||||||
|
|
||||||
|
Flipping the flag before step 1 or step 3 breaks the rider's Picked rung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11f. Request 17 — the one thing still unproven: what `/admin/bookings` puts in `status`
|
||||||
|
|
||||||
|
`GET /miler/bookings` reports the **booking** status: booking 77 is
|
||||||
|
`Converted_To_Consignment` with `consignmentstatus: Out_for_Delivery`.
|
||||||
|
|
||||||
|
The console reads `b.status` from `/admin/bookings` and maps it
|
||||||
|
(`api.js:611` and `api.js:664`). If that field held `Converted_To_Consignment`
|
||||||
|
too, the console would render **Picked** — which is not what we see; it shows
|
||||||
|
**Active**.
|
||||||
|
|
||||||
|
So exactly one of these is true, and we cannot tell which from the rider token:
|
||||||
|
|
||||||
|
| # | Possibility | How to confirm | Owner |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A | `/admin/bookings` projects the consignment status onto `status` | one network call in the console's dev tools | backend |
|
||||||
|
| B | the backend also writes `Out_for_Delivery` onto the booking row for hyperlocal, and `/miler/bookings` reports something different | compare both endpoints for the same bookingid | backend |
|
||||||
|
| C | the deployed console is older than this source (the repo comment says `converted_to_consignment` "used to map to `accepted`") | check the deployed bundle | console |
|
||||||
|
|
||||||
|
**Please open the console's Deliveries page, look at the `/admin/bookings`
|
||||||
|
response for one collected booking, and tell us the value of `status`.** That
|
||||||
|
single value decides which of the three it is, and none of them is fixed in the
|
||||||
|
rider app.
|
||||||
|
|
||||||
|
Whichever it is, note that in compatibility mode **Active is not wrong** — the
|
||||||
|
consignment genuinely is `Out_for_Delivery`. The app now agrees with the
|
||||||
|
console instead of showing `Picked` over it. Picked/Collected becomes a real,
|
||||||
|
separate rung only once the flag is on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Open questions for the backend team
|
||||||
|
|
||||||
|
~~1. Is `Collected_By_Miler` (Request 1 Option A) acceptable, or do you prefer
|
||||||
|
holding the booking at `Picked_Up` (Option B)?~~ **Answered: Option A, shipped.**
|
||||||
|
|
||||||
|
2. Which upload shape do you want to support — direct multipart or signed URL?
|
||||||
|
3. Is there an existing object store / CDN we should target for proof photos,
|
||||||
|
and what retention applies to them?
|
||||||
|
4. Can `riderkms` / `etaminutes` / `cumulativekms` be populated from the
|
||||||
|
routing engine, or is there no routing engine in the stack today?
|
||||||
|
5. What is the intended `attemptcount` ceiling, and what happens at it?
|
||||||
16
README.md
@@ -1,16 +0,0 @@
|
|||||||
# Doormile
|
|
||||||
|
|
||||||
A new Flutter project.
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
This project is a starting point for a Flutter application.
|
|
||||||
|
|
||||||
A few resources to get you started if this is your first Flutter project:
|
|
||||||
|
|
||||||
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
|
||||||
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
|
|
||||||
|
|
||||||
For help getting started with Flutter development, view the
|
|
||||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
|
||||||
samples, guidance on mobile development, and a full API reference.
|
|
||||||
@@ -19,6 +19,59 @@ subprojects {
|
|||||||
project.evaluationDependsOn(":app")
|
project.evaluationDependsOn(":app")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Compiling maplibre_gl against the JDK we actually have ──
|
||||||
|
//
|
||||||
|
// `maplibre_gl` is the map engine behind the delivery line's screens (the
|
||||||
|
// ported Xpress-rider flow under lib/xpress). Version 0.26.2 declares
|
||||||
|
// `sourceCompatibility = 21`, and javac refuses a source release newer than the
|
||||||
|
// JDK running the build:
|
||||||
|
//
|
||||||
|
// Execution failed for task ':maplibre_gl:compileDebugJavaWithJavac'
|
||||||
|
// > error: invalid source release: 21
|
||||||
|
//
|
||||||
|
// Flutter here is configured against JDK 17, so every build fails at that task
|
||||||
|
// — including builds of the parcel line, which never loads a MapLibre map.
|
||||||
|
//
|
||||||
|
// The plugin's own sources are Java 8-compatible; 21 is the level it asks for,
|
||||||
|
// not one it needs. So the level is lowered to 17 for this one subproject.
|
||||||
|
//
|
||||||
|
// Deliberately scoped by name rather than applied to every subproject: a blanket
|
||||||
|
// override would silently change the bytecode target of ~20 unrelated plugins
|
||||||
|
// that build fine today, to fix one that does not.
|
||||||
|
//
|
||||||
|
// The real fix is a JDK 21 toolchain (`flutter config --jdk-dir=...`). When this
|
||||||
|
// machine and CI both have one, delete this block and let the plugin have the
|
||||||
|
// level it asked for.
|
||||||
|
// Both halves must move together: Kotlin and Java have to agree on a JVM target
|
||||||
|
// or the Kotlin plugin fails the build itself ("Inconsistent JVM-target
|
||||||
|
// compatibility"). Setting only the JavaCompile tasks is not enough either —
|
||||||
|
// AGP writes `compileOptions` from the library extension after this file is
|
||||||
|
// evaluated, so the override has to happen in `afterEvaluate` to land last.
|
||||||
|
subprojects {
|
||||||
|
if (name == "maplibre_gl") {
|
||||||
|
afterEvaluate {
|
||||||
|
extensions.findByName("android")?.let { ext ->
|
||||||
|
ext.withGroovyBuilder {
|
||||||
|
"compileOptions" {
|
||||||
|
setProperty("sourceCompatibility", JavaVersion.VERSION_17)
|
||||||
|
setProperty("targetCompatibility", JavaVersion.VERSION_17)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tasks.withType<JavaCompile>().configureEach {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17.toString()
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17.toString()
|
||||||
|
}
|
||||||
|
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>()
|
||||||
|
.configureEach {
|
||||||
|
compilerOptions.jvmTarget.set(
|
||||||
|
org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tasks.register<Delete>("clean") {
|
tasks.register<Delete>("clean") {
|
||||||
delete(rootProject.layout.buildDirectory)
|
delete(rootProject.layout.buildDirectory)
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 MiB |
BIN
assets/images/Doormile Bike.png
Normal file
|
After Width: | Height: | Size: 480 KiB |
BIN
assets/images/bookings_taken.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
assets/images/caught_up.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
assets/images/homeicon.png
Normal file
|
After Width: | Height: | Size: 965 B |
BIN
assets/images/in.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
assets/images/information-point.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
assets/images/map.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
assets/images/no_activity_yet.png
Normal file
|
After Width: | Height: | Size: 820 KiB |
BIN
assets/images/no_bookings_available.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
assets/images/no_bookings_available1.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
assets/images/nothing_finished_yet.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
BIN
assets/images/onboarding_img_2.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
assets/images/onlinebottom.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
assets/images/onlineoffline.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
assets/images/orderssample.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
assets/images/pending.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
assets/images/phone-call .png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
assets/images/profileicon.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
assets/images/selcart.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
assets/images/selecteddelivery.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/images/selectedprofile.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/images/selectedsummary.png
Normal file
|
After Width: | Height: | Size: 836 B |
BIN
assets/images/selecthome.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
assets/images/shoppingbag.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
assets/images/summary.png
Normal file
|
After Width: | Height: | Size: 794 B |
BIN
assets/images/today.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/images/total.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
assets/images/trip_not_assigned.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
@@ -108,6 +108,15 @@
|
|||||||
"version" : "1.22.5"
|
"version" : "1.22.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "maplibre-gl-native-distribution",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "84a79bc375a301169390ac110c868f06c857b83f",
|
||||||
|
"version" : "6.27.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "nanopb",
|
"identity" : "nanopb",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
|
||||||
|
import 'package:miler/data/service_profile.dart';
|
||||||
|
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||||
|
|
||||||
/// Canonical lifecycle status for a booking/stop, parsed from the backend's
|
/// Canonical lifecycle status for a booking/stop, parsed from the backend's
|
||||||
/// free-form `orderstatus` string.
|
/// free-form `orderstatus` string.
|
||||||
///
|
///
|
||||||
@@ -16,6 +22,32 @@ enum StopStatus {
|
|||||||
active,
|
active,
|
||||||
arrived,
|
arrived,
|
||||||
picked,
|
picked,
|
||||||
|
|
||||||
|
/// ── The milk run's second half ──
|
||||||
|
///
|
||||||
|
/// [picked] is where a logistics stop ends: the parcel is collected and it
|
||||||
|
/// becomes the hub's problem. A milk run does not end there — collection is
|
||||||
|
/// the middle of the day, and the rider still has to carry the load to the
|
||||||
|
/// customers.
|
||||||
|
///
|
||||||
|
/// So these three rungs exist for the line that has them, and only that line
|
||||||
|
/// puts a stop into them. Keeping them in the same enum is what lets one
|
||||||
|
/// `stopStatusOf` test serve both halves of the day; keeping them *distinct
|
||||||
|
/// from* [picked] and [arrived] is what stops a collected crate being
|
||||||
|
/// reported as fifteen delivered lunches ninety minutes early.
|
||||||
|
///
|
||||||
|
/// The load is released and the rider is driving his round.
|
||||||
|
outForDelivery,
|
||||||
|
|
||||||
|
/// At a customer's door — not at a source. The distinction matters: the
|
||||||
|
/// pickup [arrived] and this one are different places, different actions and
|
||||||
|
/// different next steps.
|
||||||
|
deliveryArrived,
|
||||||
|
|
||||||
|
/// Handed over. Terminal for a milk-run stop, the way [picked] is terminal
|
||||||
|
/// for a logistics one.
|
||||||
|
delivered,
|
||||||
|
|
||||||
skipped,
|
skipped,
|
||||||
cancelled,
|
cancelled,
|
||||||
rejected,
|
rejected,
|
||||||
@@ -44,6 +76,21 @@ StopStatus stopStatusFromRaw(dynamic raw) {
|
|||||||
case 'pickuped':
|
case 'pickuped':
|
||||||
case 'pickedup':
|
case 'pickedup':
|
||||||
return StopStatus.picked;
|
return StopStatus.picked;
|
||||||
|
// The milk run's delivery rungs. `out_for_delivery` is also the backend's
|
||||||
|
// own consignment status, spelled its way, so a stop stamped from either
|
||||||
|
// side folds to the same state.
|
||||||
|
case 'outfordelivery':
|
||||||
|
case 'out_for_delivery':
|
||||||
|
case 'out for delivery':
|
||||||
|
case 'delivering':
|
||||||
|
return StopStatus.outForDelivery;
|
||||||
|
case 'deliveryarrived':
|
||||||
|
case 'delivery_arrived':
|
||||||
|
case 'at customer':
|
||||||
|
case 'at_customer':
|
||||||
|
return StopStatus.deliveryArrived;
|
||||||
|
case 'delivered':
|
||||||
|
return StopStatus.delivered;
|
||||||
case 'skipped':
|
case 'skipped':
|
||||||
return StopStatus.skipped;
|
return StopStatus.skipped;
|
||||||
case 'cancelled':
|
case 'cancelled':
|
||||||
@@ -67,11 +114,53 @@ extension StopStatusX on StopStatus {
|
|||||||
bool get isActive => this == StopStatus.active;
|
bool get isActive => this == StopStatus.active;
|
||||||
bool get isRejected => this == StopStatus.rejected;
|
bool get isRejected => this == StopStatus.rejected;
|
||||||
|
|
||||||
/// A completed pickup — picked up or cancelled. (Matches the long-standing
|
/// On the customer round: released, at a door, or handed over.
|
||||||
/// `!= 'picked' && != 'picked up' && != 'cancelled'` filter used to build the
|
bool get isDeliveryLeg =>
|
||||||
/// "next stops" and remaining-pickups lists.)
|
this == StopStatus.outForDelivery ||
|
||||||
bool get isFinishedPickup =>
|
this == StopStatus.deliveryArrived ||
|
||||||
this == StopStatus.picked || this == StopStatus.cancelled;
|
this == StopStatus.delivered;
|
||||||
|
|
||||||
|
/// Handed to the customer. Terminal on a milk run.
|
||||||
|
bool get isDelivered => this == StopStatus.delivered;
|
||||||
|
|
||||||
|
/// ── "Is this stop finished?" is a question about the LINE ──
|
||||||
|
///
|
||||||
|
/// This is the distinction that broke the milk run, so it is worth being
|
||||||
|
/// exact about.
|
||||||
|
///
|
||||||
|
/// On **logistics**, collecting the parcel *is* the job: [picked] is the end,
|
||||||
|
/// the booking becomes a consignment, and the hub takes it from there.
|
||||||
|
///
|
||||||
|
/// On a **milk run**, [picked] is the *middle of the morning*. The rider is
|
||||||
|
/// holding fifteen lunches and has not delivered one of them. His day ends at
|
||||||
|
/// [delivered], one customer at a time.
|
||||||
|
///
|
||||||
|
/// One boolean answered both, and it answered "picked = finished". So a
|
||||||
|
/// milk-run order collected at a kitchen was dropped from the deliveries list
|
||||||
|
/// as completed work and filed on Activity as history — before the rider had
|
||||||
|
/// left the counter. He collected five lunches and watched them disappear
|
||||||
|
/// into his own history.
|
||||||
|
///
|
||||||
|
/// Read this, not [isTerminal], anywhere the question is "should this stop
|
||||||
|
/// still be worked today?".
|
||||||
|
bool get isWorkComplete => ServiceProfile.active.deliversToCustomer
|
||||||
|
? (this == StopStatus.delivered || this == StopStatus.cancelled)
|
||||||
|
: (this == StopStatus.picked ||
|
||||||
|
this == StopStatus.delivered ||
|
||||||
|
this == StopStatus.cancelled);
|
||||||
|
|
||||||
|
/// Every state that ends a stop on *some* line, without asking which.
|
||||||
|
///
|
||||||
|
/// Only for code that must not depend on the active profile — a pure store
|
||||||
|
/// helper, say. Screens want [isWorkComplete].
|
||||||
|
bool get isTerminal =>
|
||||||
|
this == StopStatus.picked ||
|
||||||
|
this == StopStatus.delivered ||
|
||||||
|
this == StopStatus.cancelled;
|
||||||
|
|
||||||
|
/// The old name for [isWorkComplete], kept because ~8 call sites read it and
|
||||||
|
/// they all want the line-aware answer.
|
||||||
|
bool get isFinishedPickup => isWorkComplete;
|
||||||
|
|
||||||
/// Still awaiting the rider's acceptance — belongs on Home, not Bookings.
|
/// Still awaiting the rider's acceptance — belongs on Home, not Bookings.
|
||||||
bool get isPending =>
|
bool get isPending =>
|
||||||
@@ -94,12 +183,68 @@ extension StopStatusX on StopStatus {
|
|||||||
StopStatus.accepted => 'Accepted',
|
StopStatus.accepted => 'Accepted',
|
||||||
StopStatus.active => 'In progress',
|
StopStatus.active => 'In progress',
|
||||||
StopStatus.arrived => 'At the stop',
|
StopStatus.arrived => 'At the stop',
|
||||||
StopStatus.picked => 'Completed',
|
StopStatus.picked => 'Picked up',
|
||||||
|
StopStatus.outForDelivery => 'Out for delivery',
|
||||||
|
StopStatus.deliveryArrived => 'At the customer',
|
||||||
|
StopStatus.delivered => 'Delivered',
|
||||||
StopStatus.skipped => 'Skipped',
|
StopStatus.skipped => 'Skipped',
|
||||||
StopStatus.cancelled => 'Cancelled',
|
StopStatus.cancelled => 'Cancelled',
|
||||||
StopStatus.rejected => 'Rejected',
|
StopStatus.rejected => 'Rejected',
|
||||||
StopStatus.unknown => 'Active',
|
StopStatus.unknown => 'Active',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// The colour that name is drawn in.
|
||||||
|
///
|
||||||
|
/// Next to [label] for the same reason the labels are here: a status cannot
|
||||||
|
/// be added without somebody deciding both what it is called and how loud it
|
||||||
|
/// is. Semantic, not decorative — green means the rider is done with it, red
|
||||||
|
/// means it needs him now, grey means it is waiting on somebody else.
|
||||||
|
Color get color => switch (this) {
|
||||||
|
StopStatus.newStop || StopStatus.assigned => ColorConstants.secondaryText,
|
||||||
|
StopStatus.accepted => ColorConstants.acceptGreen,
|
||||||
|
StopStatus.active || StopStatus.arrived => ColorConstants.pickupAccent,
|
||||||
|
StopStatus.picked => ColorConstants.acceptGreen,
|
||||||
|
// ── Loud, but in the leg's own colour ──
|
||||||
|
//
|
||||||
|
// These wore the brand red, and on the stop sheet that word sat between a
|
||||||
|
// blue leg disc and a blue "what to do here" chip, one line above a red
|
||||||
|
// Navigate button — a status dressed as an action, on a screen that codes
|
||||||
|
// its delivery leg blue everywhere else. The round is still drawn loudly;
|
||||||
|
// it is drawn in the ink that already means *delivery leg*, which is the
|
||||||
|
// same rule that keeps the pickup-leg states on the pickup accent above.
|
||||||
|
StopStatus.outForDelivery ||
|
||||||
|
StopStatus.deliveryArrived => ColorConstants.deliveryAccent,
|
||||||
|
StopStatus.delivered => ColorConstants.acceptGreen,
|
||||||
|
StopStatus.skipped => ColorConstants.warning,
|
||||||
|
StopStatus.cancelled || StopStatus.rejected => ColorConstants.errorRed,
|
||||||
|
StopStatus.unknown => ColorConstants.secondaryText,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A glyph for the same state, so the tag never depends on colour alone —
|
||||||
|
/// these are read in sunlight, through a scratched screen, at a gate.
|
||||||
|
IconData get icon => switch (this) {
|
||||||
|
StopStatus.newStop || StopStatus.assigned => LucideIcons.clock,
|
||||||
|
StopStatus.accepted => LucideIcons.circleCheck,
|
||||||
|
StopStatus.active => LucideIcons.bike,
|
||||||
|
StopStatus.arrived => LucideIcons.mapPin,
|
||||||
|
StopStatus.picked => LucideIcons.package,
|
||||||
|
StopStatus.outForDelivery => LucideIcons.truck,
|
||||||
|
StopStatus.deliveryArrived => LucideIcons.mapPin,
|
||||||
|
// ── Delivered is settled, not decorated ──
|
||||||
|
//
|
||||||
|
// `verified_rounded` is a starburst badge — the shape Material reserves
|
||||||
|
// for *verified account*, and the loudest glyph in the set. Down a column
|
||||||
|
// of finished stops it made every completed delivery look like an award.
|
||||||
|
// A package with a tick on it says the same thing about the same object,
|
||||||
|
// quietly, and it is the mark every delivery app in the world uses.
|
||||||
|
StopStatus.delivered => LucideIcons.packageCheck,
|
||||||
|
// Skipped must not read as a variant of delivered. A circle with a stroke
|
||||||
|
// through it is the "attempted, did not happen" mark; `replay` promised a
|
||||||
|
// retry the rider may not actually be able to make.
|
||||||
|
StopStatus.skipped => LucideIcons.circleSlash,
|
||||||
|
StopStatus.cancelled || StopStatus.rejected => LucideIcons.ban,
|
||||||
|
StopStatus.unknown => LucideIcons.circle,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Canonical protocol verbs sent to the status-change API (the *write* side).
|
/// Canonical protocol verbs sent to the status-change API (the *write* side).
|
||||||
@@ -110,4 +255,17 @@ class OrderAction {
|
|||||||
static const String picked = 'PICKED';
|
static const String picked = 'PICKED';
|
||||||
static const String rejected = 'REJECTED';
|
static const String rejected = 'REJECTED';
|
||||||
static const String cancelled = 'CANCELLED';
|
static const String cancelled = 'CANCELLED';
|
||||||
|
|
||||||
|
/// The milk run's second half. These are *client* verbs — they name what the
|
||||||
|
/// rider did, and each one maps to a real backend call rather than to a
|
||||||
|
/// status string the API would not recognise:
|
||||||
|
///
|
||||||
|
/// startDelivery → POST /miler/deliveries/start
|
||||||
|
/// deliveryArrived → (local; the round has no per-stop arrival route)
|
||||||
|
/// delivered → POST /miler/consignments/:id/deliver
|
||||||
|
///
|
||||||
|
/// See the mapping table in `MilkRun`.
|
||||||
|
static const String startDelivery = 'START_DELIVERY';
|
||||||
|
static const String deliveryArrived = 'DELIVERY_ARRIVED';
|
||||||
|
static const String delivered = 'DELIVERED';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
|
||||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
|
||||||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:miler/providers/auth/auth_provider.dart';
|
import 'package:miler/providers/auth/auth_provider.dart';
|
||||||
@@ -10,6 +10,7 @@ import 'package:miler/utils/device.dart';
|
|||||||
import 'package:miler/controllers/profile_controller.dart';
|
import 'package:miler/controllers/profile_controller.dart';
|
||||||
import 'package:miler/Models/login/login.dart';
|
import 'package:miler/Models/login/login.dart';
|
||||||
import 'package:miler/data/api_config.dart';
|
import 'package:miler/data/api_config.dart';
|
||||||
|
import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
|
||||||
|
|
||||||
enum AuthNext { verifyPin, otp, notRegistered, error }
|
enum AuthNext { verifyPin, otp, notRegistered, error }
|
||||||
|
|
||||||
@@ -20,6 +21,20 @@ class AuthController extends GetxController {
|
|||||||
AuthNext? lastDecision;
|
AuthNext? lastDecision;
|
||||||
// Optional callback used by MPIN screen to clear and refocus fields when user taps "Retry"
|
// Optional callback used by MPIN screen to clear and refocus fields when user taps "Retry"
|
||||||
VoidCallback? onPinRetry;
|
VoidCallback? onPinRetry;
|
||||||
|
|
||||||
|
/// Why the last [verifyPinWithServer] failed, in the rider's words.
|
||||||
|
///
|
||||||
|
/// ── "Incorrect MPIN" was the answer to every question ──
|
||||||
|
///
|
||||||
|
/// The MPIN screen painted that one line whenever the controller reported a
|
||||||
|
/// failure — a wrong PIN, a dead network, a 500, and (for a long time) a
|
||||||
|
/// device-id lookup that threw before the request was sent. So the one
|
||||||
|
/// symptom a rider could report was the one cause that was often not true,
|
||||||
|
/// and there was no way to tell a mistyped PIN from an app that was never
|
||||||
|
/// going to reach the server.
|
||||||
|
///
|
||||||
|
/// Set on every failure path, cleared on success. Read by `Mpin.dart`.
|
||||||
|
String? lastPinFailure;
|
||||||
static const String _prefsUserIdKey = 'userid';
|
static const String _prefsUserIdKey = 'userid';
|
||||||
static const String _prefsPendingPinUserIdKey = 'pending_pin_userid';
|
static const String _prefsPendingPinUserIdKey = 'pending_pin_userid';
|
||||||
static const String _prefsUserNameKey = 'user_name';
|
static const String _prefsUserNameKey = 'user_name';
|
||||||
@@ -56,44 +71,22 @@ class AuthController extends GetxController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showBottomSheet({required String title, required String message}) {
|
void _showBottomSheet({required String title, required String message}) {
|
||||||
|
// `Get.bottomSheet` stays (this controller has no BuildContext for the
|
||||||
|
// kit's presenter), but the surface inside it is the kit's — the same
|
||||||
|
// glass, handle and insets as every sheet after sign-in, so the first
|
||||||
|
// sheet a rider ever meets is not the one drawn differently.
|
||||||
Get.bottomSheet(
|
Get.bottomSheet(
|
||||||
Builder(
|
MilerSheetScaffold(
|
||||||
builder: (context) => Container(
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
left: 16,
|
|
||||||
right: 16,
|
|
||||||
top: 16,
|
|
||||||
bottom: 16 + MediaQuery.of(context).viewPadding.bottom,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: ColorConstants.pureSurface,
|
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
|
||||||
),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.info_outline, color: ColorConstants.primary, size: 40),
|
MilerSheetHeader(
|
||||||
const SizedBox(height: 12),
|
title: title,
|
||||||
Text(
|
subtitle: message,
|
||||||
title,
|
icon: LucideIcons.info,
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
fontFamily: FontConstants.fontFamily,
|
|
||||||
fontSize: 20,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 18),
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
message,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontFamily: FontConstants.fontFamily,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
MilerButton(
|
MilerButton(
|
||||||
label: 'Retry',
|
label: 'Retry',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -105,7 +98,6 @@ class AuthController extends GetxController {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
);
|
);
|
||||||
@@ -130,13 +122,19 @@ class AuthController extends GetxController {
|
|||||||
// Create-MPIN, which would overwrite the PIN the account was issued.
|
// Create-MPIN, which would overwrite the PIN the account was issued.
|
||||||
// Seeded development accounts take exactly this branch — enter the phone,
|
// Seeded development accounts take exactly this branch — enter the phone,
|
||||||
// enter the seeded MPIN, done.
|
// enter the seeded MPIN, done.
|
||||||
|
// Null means the directory could not be reached — see
|
||||||
|
// [AuthProvider.milerAccountExists]. Treat it as "he has an account",
|
||||||
|
// because that is true of every rider who gets this far and because the
|
||||||
|
// MPIN screen is the only one that can tell him what went wrong. The OTP
|
||||||
|
// branch is the dead end: it ends at Create-MPIN, which cannot write a
|
||||||
|
// PIN, so guessing wrong in that direction locks a rider out.
|
||||||
final exists = await _api.milerAccountExists(normalized);
|
final exists = await _api.milerAccountExists(normalized);
|
||||||
if (exists) {
|
if (exists ?? true) {
|
||||||
lastDecision = AuthNext.verifyPin;
|
lastDecision = AuthNext.verifyPin;
|
||||||
return lastDecision!;
|
return lastDecision!;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unknown number (or legacy backend) — fall through to the OTP step.
|
// The directory answered, and said there is no such account.
|
||||||
lastDecision = AuthNext.otp;
|
lastDecision = AuthNext.otp;
|
||||||
return lastDecision!;
|
return lastDecision!;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -160,8 +158,24 @@ class AuthController extends GetxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ── There is no OTP route on the backend ──
|
||||||
|
///
|
||||||
|
/// This returned true with the comment "automatically succeed for mocked
|
||||||
|
/// login", which read as leftover demo scaffolding. It is not: `MilerApi`
|
||||||
|
/// carries the whole auth surface and it is three routes — `login`,
|
||||||
|
/// `verify-pin`, `device-token`. Nothing verifies a code, so there is nothing
|
||||||
|
/// for this to call.
|
||||||
|
///
|
||||||
|
/// It stays a pass-through for the same reason [AuthProvider.updatePin] does:
|
||||||
|
/// failing instead would strand a new rider on a screen with no way forward,
|
||||||
|
/// which is worse and no more honest. What changes is that the gap is now
|
||||||
|
/// recorded rather than described as a mock, so it shows up in the same place
|
||||||
|
/// as every other missing route.
|
||||||
Future<bool> verifyOtp(String code) async {
|
Future<bool> verifyOtp(String code) async {
|
||||||
// Automatically succeed for mocked login
|
ApiConfig.logGap(
|
||||||
|
'verifyOtp',
|
||||||
|
'No OTP verification route exists; the code entered is not checked.',
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,15 +199,6 @@ class AuthController extends GetxController {
|
|||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Demo mode: the mocked login flow stores a fake rider id (9999) that
|
|
||||||
// the live server rejects, so updatePin fails. Save the PIN locally and
|
|
||||||
// report success so the demo Create-MPIN flow works without a real
|
|
||||||
// account or server call.
|
|
||||||
if (userId == 9999) {
|
|
||||||
await prefs.setString('dbPin', newPin);
|
|
||||||
await prefs.remove(_prefsPendingPinUserIdKey);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
final int pinNum = int.parse(newPin);
|
final int pinNum = int.parse(newPin);
|
||||||
final res = await _api.updatePin(userId: userId, pin: pinNum);
|
final res = await _api.updatePin(userId: userId, pin: pinNum);
|
||||||
@@ -202,13 +207,17 @@ class AuthController extends GetxController {
|
|||||||
await prefs.remove(_prefsPendingPinUserIdKey);
|
await prefs.remove(_prefsPendingPinUserIdKey);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
final bodyPreview = res.body.length > 200
|
// The server's own sentence, not a slice of its JSON. A rider reading
|
||||||
? '${res.body.substring(0, 200)}...'
|
// `{"status":false,"code":403,...}` learns nothing he can act on.
|
||||||
: res.body;
|
String reason = '';
|
||||||
_showBottomSheet(
|
try {
|
||||||
title: 'Failed (${res.statusCode})',
|
final decoded = json.decode(res.body);
|
||||||
message: 'Unable to set PIN. Server said: $bodyPreview',
|
if (decoded is Map) reason = (decoded['message'] ?? '').toString();
|
||||||
);
|
} catch (_) {}
|
||||||
|
if (reason.trim().isEmpty) {
|
||||||
|
reason = 'Could not set your MPIN. Please contact your hub manager.';
|
||||||
|
}
|
||||||
|
_showBottomSheet(title: 'MPIN not changed', message: reason);
|
||||||
return false;
|
return false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('setPin error: $e');
|
debugPrint('setPin error: $e');
|
||||||
@@ -281,15 +290,52 @@ class AuthController extends GetxController {
|
|||||||
currentPhone ?? prefs.getString(_prefsContactNoKey) ?? '';
|
currentPhone ?? prefs.getString(_prefsContactNoKey) ?? '';
|
||||||
final int? pinNum = int.tryParse(inputPin);
|
final int? pinNum = int.tryParse(inputPin);
|
||||||
if (phone.isEmpty || pinNum == null || inputPin.length != 4) {
|
if (phone.isEmpty || pinNum == null || inputPin.length != 4) {
|
||||||
_showBottomSheet(
|
// Two different faults wearing one message. A missing phone is not the
|
||||||
title: 'Invalid PIN',
|
// rider mistyping — it means he reached this screen without the number
|
||||||
message: 'Please enter your 4-digit PIN and try again.',
|
// step, and telling him to re-enter his PIN sends him round a loop that
|
||||||
);
|
// cannot end.
|
||||||
|
lastPinFailure = phone.isEmpty
|
||||||
|
? 'We lost your phone number. Go back and enter it again.'
|
||||||
|
: 'Enter all 4 digits of your MPIN.';
|
||||||
|
_showBottomSheet(title: 'Invalid PIN', message: lastPinFailure!);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final deviceId = await DeviceUtils.ensureDeviceId(prefs);
|
// ── Nothing gathered here may stop the sign-in ──
|
||||||
final fcmToken = await DeviceUtils.ensureFcmToken(prefs);
|
//
|
||||||
|
// These two lines used to sit bare inside this `try`, and
|
||||||
|
// `ensureDeviceId` threw on iOS and on any Android that handed back an
|
||||||
|
// empty id. The throw landed in the catch below, so the rider was told
|
||||||
|
// "Could not reach the server" — before a single byte had been sent —
|
||||||
|
// and the MPIN screen then called it an incorrect PIN. Every number,
|
||||||
|
// every attempt.
|
||||||
|
//
|
||||||
|
// `ensureDeviceId` is total now (see [DeviceUtils]), and this second
|
||||||
|
// guard says why it must stay that way: `deviceId` is not even part of
|
||||||
|
// the verify-pin body, and a push token the rider declined is not a
|
||||||
|
// reason to refuse him his shift. Best effort, then post regardless.
|
||||||
|
String deviceId = '';
|
||||||
|
String fcmToken = '';
|
||||||
|
try {
|
||||||
|
deviceId = await DeviceUtils.ensureDeviceId(prefs);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[AUTH] device id unavailable, continuing: $e');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fcmToken = await DeviceUtils.ensureFcmToken(prefs);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[AUTH] fcm token unavailable, continuing: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── This attempt is the only thing that may grant a session ──
|
||||||
|
//
|
||||||
|
// The check below asks prefs whether a token exists. Without this line
|
||||||
|
// that question is answered by *any previous session*, so the gate was
|
||||||
|
// broken in both directions: a stale token made a rejected PIN look
|
||||||
|
// accepted, and a fresh install with a token the app could not find made
|
||||||
|
// an accepted PIN look rejected.
|
||||||
|
await ApiConfig.clearToken();
|
||||||
|
|
||||||
final Login res = await _api.loginParsed(
|
final Login res = await _api.loginParsed(
|
||||||
contactNo: phone,
|
contactNo: phone,
|
||||||
deviceType: Platform.operatingSystem,
|
deviceType: Platform.operatingSystem,
|
||||||
@@ -300,9 +346,38 @@ class AuthController extends GetxController {
|
|||||||
pinRaw: inputPin,
|
pinRaw: inputPin,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Three outcomes, not two ──
|
||||||
|
//
|
||||||
|
// `res.status` is the server's verdict on the credentials. The token is
|
||||||
|
// whether this call handed back a session. They are different facts, and
|
||||||
|
// collapsing them into one boolean is what produced **"Login failed"** on
|
||||||
|
// a PIN the server had just accepted — the app could not find the token
|
||||||
|
// in the response, so it reported the rider's PIN as wrong.
|
||||||
|
final bool serverAccepted = res.status == true;
|
||||||
final String? token = await ApiConfig.getToken();
|
final String? token = await ApiConfig.getToken();
|
||||||
final bool ok = res.status == true && token != null && token.isNotEmpty;
|
final bool haveSession = token != null && token.isNotEmpty;
|
||||||
|
final bool ok = serverAccepted && haveSession;
|
||||||
|
|
||||||
|
if (serverAccepted && !haveSession) {
|
||||||
|
// The credentials were right and there is nothing to sign in with.
|
||||||
|
// This is an integration fault, not a rider fault, and it must never
|
||||||
|
// again be reported as a bad PIN. The log line above it names the keys
|
||||||
|
// the response actually carried.
|
||||||
|
debugPrint(
|
||||||
|
'[AUTH] verify-pin accepted the PIN but returned no usable token',
|
||||||
|
);
|
||||||
|
lastPinFailure =
|
||||||
|
'Your MPIN was accepted, but the server did not return a session. '
|
||||||
|
'Please report this to the hub — it is not your PIN.';
|
||||||
|
_showBottomSheet(
|
||||||
|
title: 'Could not start session',
|
||||||
|
message: lastPinFailure!,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (ok) {
|
if (ok) {
|
||||||
|
lastPinFailure = null;
|
||||||
await prefs.setString('dbPin', inputPin);
|
await prefs.setString('dbPin', inputPin);
|
||||||
await prefs.setBool('logged_out', false);
|
await prefs.setBool('logged_out', false);
|
||||||
currentPhone = _normalizePhone(phone);
|
currentPhone = _normalizePhone(phone);
|
||||||
@@ -329,19 +404,40 @@ class AuthController extends GetxController {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Only 401/403 is a statement about the PIN ──
|
||||||
|
//
|
||||||
|
// Everything else — a 502, a gateway timeout, a captive portal, a body
|
||||||
|
// that is not JSON — is the sign-in failing to *complete*, which is a
|
||||||
|
// different problem with a different fix. Reporting all of it as a login
|
||||||
|
// failure is what made a network fault indistinguishable from a wrong
|
||||||
|
// MPIN, on a screen whose whole job is to tell those apart.
|
||||||
|
final int status = res.code ?? 0;
|
||||||
|
final String serverSaid = (res.message ?? '').trim();
|
||||||
|
final bool aboutTheCredentials = status == 401 || status == 403;
|
||||||
|
|
||||||
|
if (aboutTheCredentials) {
|
||||||
|
lastPinFailure = serverSaid.isNotEmpty
|
||||||
|
? serverSaid
|
||||||
|
: 'Incorrect MPIN for $phone. Try again.';
|
||||||
|
_showBottomSheet(title: 'Login failed', message: lastPinFailure!);
|
||||||
|
} else {
|
||||||
|
lastPinFailure = serverSaid.isNotEmpty
|
||||||
|
? 'Sign-in could not complete. $serverSaid'
|
||||||
|
: 'Sign-in could not complete (HTTP $status). This is not your '
|
||||||
|
'MPIN — check the connection and try again.';
|
||||||
_showBottomSheet(
|
_showBottomSheet(
|
||||||
title: 'Login failed',
|
title: 'Sign-in did not complete',
|
||||||
message: (res.message != null && res.message!.trim().isNotEmpty)
|
message: lastPinFailure!,
|
||||||
? res.message!
|
|
||||||
: 'Incorrect phone number or PIN. Please try again.',
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// Never reached the server, or could not read what came back. Whatever
|
||||||
|
// this is, it is NOT the rider's PIN, and saying so is the whole point.
|
||||||
debugPrint('verifyPinWithServer error: $e');
|
debugPrint('verifyPinWithServer error: $e');
|
||||||
_showBottomSheet(
|
lastPinFailure =
|
||||||
title: 'Connection error',
|
'Could not reach the server. Check your connection and try again.';
|
||||||
message: 'Could not reach the server. Check your internet and retry.',
|
_showBottomSheet(title: 'Connection error', message: lastPinFailure!);
|
||||||
);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,6 @@ class LogController extends GetxController {
|
|||||||
final List<String> remaining = [];
|
final List<String> remaining = [];
|
||||||
bool anySuccess = false;
|
bool anySuccess = false;
|
||||||
|
|
||||||
|
|
||||||
for (final itemStr in queue) {
|
for (final itemStr in queue) {
|
||||||
try {
|
try {
|
||||||
final Map<String, dynamic> item = jsonDecode(itemStr);
|
final Map<String, dynamic> item = jsonDecode(itemStr);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'package:miler/data/work_repository.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:latlong2/latlong.dart' show LatLng;
|
import 'package:latlong2/latlong.dart' show LatLng;
|
||||||
import 'package:miler/helpers/miler_router.dart';
|
import 'package:miler/helpers/miler_router.dart';
|
||||||
@@ -17,16 +20,61 @@ import 'package:miler/utils/kalman_filter.dart';
|
|||||||
import 'package:miler/utils/mqtt_service.dart';
|
import 'package:miler/utils/mqtt_service.dart';
|
||||||
import 'package:miler/controllers/connectivity_mixin.dart';
|
import 'package:miler/controllers/connectivity_mixin.dart';
|
||||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||||
|
import 'package:miler/data/meal_run_mock.dart';
|
||||||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||||
|
|
||||||
/// Testing/demo flag. When `true`, the proximity ("you are too far from the
|
/// ── PROXIMITY ENFORCEMENT IS OFF ──
|
||||||
/// location") geofence is skipped so mock stops far from the rider can still be
|
|
||||||
/// completed while testing the design.
|
|
||||||
///
|
///
|
||||||
/// Tied to [kDebugMode] so it is ALWAYS `false` in release builds — the
|
/// Turned off deliberately on 2026-08-19, at the founder's call, because it was
|
||||||
/// geofence can never be accidentally shipped disabled, but debug/testing keeps
|
/// refusing real work: riders pressing **Picked up** at a counter were told
|
||||||
/// working with mock stops.
|
/// "You're 4.2 km from this stop", and a rider who cannot record what he has
|
||||||
const bool kBypassGeofenceForTesting = kDebugMode;
|
/// physically done has no way round it. Correctness of the fence matters less
|
||||||
|
/// than a rider being able to work.
|
||||||
|
///
|
||||||
|
/// It is off for **every** status — accepted, arrived, picked up, out for
|
||||||
|
/// delivery, delivered — and on both gates, this one and the bulk check on
|
||||||
|
/// Home. Half a fence is worse than none: it would block one route into a rung
|
||||||
|
/// and wave through another, with two different messages and no explanation of
|
||||||
|
/// why one worked.
|
||||||
|
///
|
||||||
|
/// ── Turning it back on ──
|
||||||
|
///
|
||||||
|
/// flutter run --dart-define=ENFORCE_GEOFENCE=true
|
||||||
|
/// flutter build apk --dart-define=ENFORCE_GEOFENCE=true
|
||||||
|
///
|
||||||
|
/// Or flip [kGeofenceEnforced]'s default back to `true` when the underlying
|
||||||
|
/// problem is fixed. That problem is worth naming, because it is the reason
|
||||||
|
/// this is off rather than merely loosened: the fence compares the rider's GPS
|
||||||
|
/// against `pickuplat`/`pickuplon` **on the booking**, and those come from
|
||||||
|
/// wherever the customer dropped a pin — so the distance it measures is as
|
||||||
|
/// often the data being wrong as the rider being absent.
|
||||||
|
///
|
||||||
|
/// ── What it costs while it is off ──
|
||||||
|
///
|
||||||
|
/// This is the one control that decided whether the app was telling the truth
|
||||||
|
/// about where a rider was standing when he said a stop was done. With it off,
|
||||||
|
/// "Picked up" means he pressed a button, not that he was there — the
|
||||||
|
/// timestamps and the GPS still ride along on every status write, so the office
|
||||||
|
/// can audit after the fact, but nothing is refused at the moment of the press.
|
||||||
|
///
|
||||||
|
/// Every bypass is logged in every build mode (see `_checkGeofence`), so a
|
||||||
|
/// build's own log says which way it was compiled.
|
||||||
|
///
|
||||||
|
/// ── The history, so neither old mistake comes back ──
|
||||||
|
///
|
||||||
|
/// This was once `kDebugMode`, which meant the fence was off in exactly the
|
||||||
|
/// builds anyone tested with — so it was never really tested. It was then
|
||||||
|
/// pinned to a hard `false`, which left no way to walk the flow at a desk. The
|
||||||
|
/// shape below survives both: one named constant, one define, one default, and
|
||||||
|
/// the default is a product decision rather than a side effect of how the app
|
||||||
|
/// was built.
|
||||||
|
const bool kGeofenceEnforced = bool.fromEnvironment(
|
||||||
|
'ENFORCE_GEOFENCE',
|
||||||
|
defaultValue: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The name every call site reads. Derived, so there is one switch and not two.
|
||||||
|
const bool kBypassGeofenceForTesting = !kGeofenceEnforced;
|
||||||
|
|
||||||
class PickupsController extends GetxController
|
class PickupsController extends GetxController
|
||||||
with ConnectivityControllerMixin {
|
with ConnectivityControllerMixin {
|
||||||
@@ -66,6 +114,17 @@ class PickupsController extends GetxController
|
|||||||
// Bonus Points Tracking
|
// Bonus Points Tracking
|
||||||
final RxInt lastBonusPoints = 0.obs;
|
final RxInt lastBonusPoints = 0.obs;
|
||||||
|
|
||||||
|
/// The tracking number the last `pickup-complete` minted, or '' when the call
|
||||||
|
/// has not run or did not return one.
|
||||||
|
///
|
||||||
|
/// `pickup-complete` is the moment a booking becomes a real consignment, and
|
||||||
|
/// it answers with `{tracking_no, consignment_id, booking_no}` — the shipment
|
||||||
|
/// the rider has just brought into existence. That payload was being
|
||||||
|
/// discarded, so the success screen could only say "done" in the abstract.
|
||||||
|
/// Showing the tracking number makes it a receipt: the rider can read it back
|
||||||
|
/// to a customer who asks, on the spot, without phoning the hub.
|
||||||
|
final RxString lastTrackingNo = ''.obs;
|
||||||
|
|
||||||
// ── Compliance of the stop just completed ──
|
// ── Compliance of the stop just completed ──
|
||||||
//
|
//
|
||||||
// All three are computed here already — the bonus rule is literally "did he
|
// All three are computed here already — the bonus rule is literally "did he
|
||||||
@@ -79,12 +138,35 @@ class PickupsController extends GetxController
|
|||||||
// be measured) — which the UI states as unknown rather than as a failure.
|
// be measured) — which the UI states as unknown rather than as a failure.
|
||||||
final Rxn<bool> lastOnTime = Rxn<bool>();
|
final Rxn<bool> lastOnTime = Rxn<bool>();
|
||||||
final Rxn<Duration> lastLateBy = Rxn<Duration>();
|
final Rxn<Duration> lastLateBy = Rxn<Duration>();
|
||||||
|
|
||||||
|
/// The clock the stop was promised by, as read from `eta_endtime_<orderid>`
|
||||||
|
/// at the moment it closed.
|
||||||
|
///
|
||||||
|
/// The verdict — on time or late by how much — was the only thing kept, and
|
||||||
|
/// it answers "did he make it" without ever answering "by when". A rider
|
||||||
|
/// asked about a stop wants both, and the deadline itself lives in a prefs
|
||||||
|
/// key the next stop overwrites. Held here so the completion path can write
|
||||||
|
/// it onto the record, the same argument [StopCompliance] makes for the two
|
||||||
|
/// figures beside it.
|
||||||
|
final Rxn<DateTime> lastEtaDeadline = Rxn<DateTime>();
|
||||||
final RxDouble lastRiderKms = 0.0.obs;
|
final RxDouble lastRiderKms = 0.0.obs;
|
||||||
|
|
||||||
// Trigger to refresh pickup list across screens
|
// Trigger to refresh pickup list across screens
|
||||||
final RxInt refreshTrigger = 0.obs;
|
final RxInt refreshTrigger = 0.obs;
|
||||||
|
|
||||||
|
/// ── After a mutation, the server is asked, not assumed ──
|
||||||
|
///
|
||||||
|
/// This bumped a counter and every screen listening re-read *its own* copy of
|
||||||
|
/// the day — so accepting a stop on Home updated Home, and Bookings and
|
||||||
|
/// Activity carried on showing the state from before the tap until their own
|
||||||
|
/// poll came round.
|
||||||
|
///
|
||||||
|
/// The shared copy is invalidated first, so the re-read that the counter
|
||||||
|
/// triggers goes to the API rather than to a cached answer that predates the
|
||||||
|
/// mutation. One request, and all three screens land on the state the server
|
||||||
|
/// actually confirmed rather than on the one the button implied.
|
||||||
void triggerRefresh() {
|
void triggerRefresh() {
|
||||||
|
unawaited(WorkRepository.instance.invalidate());
|
||||||
refreshTrigger.value++;
|
refreshTrigger.value++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,11 +177,42 @@ class PickupsController extends GetxController
|
|||||||
int userId,
|
int userId,
|
||||||
int pickupId,
|
int pickupId,
|
||||||
) async {
|
) async {
|
||||||
|
// Nothing to store proof against on the meal line — the crate photo has no
|
||||||
|
// booking behind it yet, so it stays on the phone rather than filling a
|
||||||
|
// bucket with pictures of a day that only exists in memory.
|
||||||
|
if (MealRunMock.active) {
|
||||||
|
debugPrint('[MEAL_MOCK] crate photo kept local: ${imageFile.path}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── These were literals in the source ──
|
||||||
|
//
|
||||||
|
// A DigitalOcean Spaces access key and secret key, compiled into every APK
|
||||||
|
// the app has ever shipped. Anyone with the binary can extract them with
|
||||||
|
// `strings`, and they are read-write on the whole `doormile` bucket — every
|
||||||
|
// rider's proof photo, for every tenant, readable and deletable by anybody
|
||||||
|
// who has installed the app once.
|
||||||
|
//
|
||||||
|
// Moving them to `--dart-define` stops the next build embedding them. It
|
||||||
|
// does **not** undo the exposure: the pair below has been distributed and
|
||||||
|
// is in this repository's history, so it has to be **rotated**, and the
|
||||||
|
// upload belongs behind a server-issued signed URL rather than in the
|
||||||
|
// client at all. Both of those are ops decisions, so this change makes the
|
||||||
|
// dependency explicit and loud rather than pretending to fix it.
|
||||||
|
const String accessKey = String.fromEnvironment('SPACES_KEY');
|
||||||
|
const String secretKey = String.fromEnvironment('SPACES_SECRET');
|
||||||
|
if (accessKey.isEmpty || secretKey.isEmpty) {
|
||||||
|
debugPrint(
|
||||||
|
'[UPLOAD] refused: SPACES_KEY/SPACES_SECRET are not set on this build. '
|
||||||
|
'Pass them with --dart-define, and rotate the pair that used to be '
|
||||||
|
'hard-coded here.',
|
||||||
|
);
|
||||||
|
AppFeedback.errorGlobal('Photo upload is not configured on this build.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// final rng = Random(); // Unused
|
|
||||||
const String region = "sgp1";
|
const String region = "sgp1";
|
||||||
const String accessKey = "DO00NQER7N2FRYZAB2HR";
|
|
||||||
const String secretKey = "nMDewX25IBEu1FM5dakK+v28/WbW3TzBAwq913+dxP0";
|
|
||||||
const String bucketName = "doormile";
|
const String bucketName = "doormile";
|
||||||
// folderName will be "picked" or "Picked up"
|
// folderName will be "picked" or "Picked up"
|
||||||
|
|
||||||
@@ -237,10 +350,40 @@ class PickupsController extends GetxController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Picked
|
// Picked
|
||||||
|
/// ── The meal line has nothing to post to ──
|
||||||
|
///
|
||||||
|
/// There are no meal endpoints. Every status method below would otherwise
|
||||||
|
/// build a payload and fire it at a parcel URL that has never heard of a
|
||||||
|
/// crate — and, worse, run the proximity check against a mock address in
|
||||||
|
/// Coimbatore, so a rider walking the flow anywhere else is blocked at the
|
||||||
|
/// first door by a fence guarding fictional coordinates.
|
||||||
|
///
|
||||||
|
/// So on that line the status is recorded in [MealRunMock] and reported as
|
||||||
|
/// sent. The flow is identical; only the wire is absent. Returns null when
|
||||||
|
/// this is a real parcel call, so the caller carries on.
|
||||||
|
///
|
||||||
|
/// See [MealRunMock] for why this cannot reach a parcel rider.
|
||||||
|
bool? _mockStatus(int pickupId, String status) {
|
||||||
|
if (!MealRunMock.active) return null;
|
||||||
|
lastBlockedReason = null;
|
||||||
|
return MealRunMock.setStatusByPickupId(pickupId, status);
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> updatePickedStatus({
|
Future<bool> updatePickedStatus({
|
||||||
required int pickupId,
|
required int pickupId,
|
||||||
required int orderHeaderId,
|
required int orderHeaderId,
|
||||||
required int pickupLocationId,
|
required int pickupLocationId,
|
||||||
|
|
||||||
|
/// ── The key the delivery leg will look this stop up by ──
|
||||||
|
///
|
||||||
|
/// `pickup-complete` is what creates the consignment, and the id it returns
|
||||||
|
/// is filed against this order so the door can find it later. The payload
|
||||||
|
/// carried no `orderid`, so the provider fell back to the *booking* id —
|
||||||
|
/// and `closeDelivery` reads the map by **order** id. Every collected stop
|
||||||
|
/// was therefore filed under a key nothing would ever ask for, and pressing
|
||||||
|
/// **Delivered** answered "this order was never picked up on the system"
|
||||||
|
/// for a bag the rider was holding. See [rememberConsignmentId].
|
||||||
|
String orderId = '',
|
||||||
String ridersLat = '0',
|
String ridersLat = '0',
|
||||||
String ridersLng = '0',
|
String ridersLng = '0',
|
||||||
String pickupLat = '0',
|
String pickupLat = '0',
|
||||||
@@ -255,6 +398,9 @@ class PickupsController extends GetxController
|
|||||||
double actualKms = 0.0,
|
double actualKms = 0.0,
|
||||||
String? proofImage, // New parameter
|
String? proofImage, // New parameter
|
||||||
}) async {
|
}) async {
|
||||||
|
final mock = _mockStatus(pickupId, 'picked');
|
||||||
|
if (mock != null) return mock;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
_pickedTime = _formatDateTimeFull(now);
|
_pickedTime = _formatDateTimeFull(now);
|
||||||
@@ -307,6 +453,9 @@ class PickupsController extends GetxController
|
|||||||
|
|
||||||
final payload = <String, dynamic>{
|
final payload = <String, dynamic>{
|
||||||
'pickupid': pickupId,
|
'pickupid': pickupId,
|
||||||
|
// Not sent to the backend — the legacy shape carries it and the mapper
|
||||||
|
// reads it to file the consignment under the id the door will use.
|
||||||
|
if (orderId.isNotEmpty) 'orderid': orderId,
|
||||||
'orderheaderid': orderHeaderId,
|
'orderheaderid': orderHeaderId,
|
||||||
'pickuplocationid': pickupLocationId,
|
'pickuplocationid': pickupLocationId,
|
||||||
'orderstatus': 'picked',
|
'orderstatus': 'picked',
|
||||||
@@ -336,6 +485,30 @@ class PickupsController extends GetxController
|
|||||||
if (!ok) {
|
if (!ok) {
|
||||||
debugPrint('[UPDATE][PICKED][FAILED] resp=${jsonEncode(resp)}');
|
debugPrint('[UPDATE][PICKED][FAILED] resp=${jsonEncode(resp)}');
|
||||||
} else {
|
} else {
|
||||||
|
// ── What the stop actually became, in the server's words ──
|
||||||
|
//
|
||||||
|
// The pivot has two legitimate outcomes and the caller must stamp the
|
||||||
|
// one that happened, not the one this build was written against:
|
||||||
|
//
|
||||||
|
// `collectedByMiler` the rider holds it — PICKED, and Start
|
||||||
|
// delivery is what makes it active.
|
||||||
|
// `outForDelivery` compatibility mode — the pivot released it,
|
||||||
|
// so it is ALREADY out for delivery and the
|
||||||
|
// console is right to say Active.
|
||||||
|
//
|
||||||
|
// Stamping 'picked' in the second case is the mismatch this whole
|
||||||
|
// exercise is about: the rider would read PICKED off a consignment his
|
||||||
|
// office reads as Active, and neither of them would be wrong about
|
||||||
|
// what their own screen said.
|
||||||
|
lastPivotConsignmentStatus = (resp?['consignmentstatus'] ?? '')
|
||||||
|
.toString();
|
||||||
|
lastPivotCompatibilityMode = resp?['compatibilitymode'] == true;
|
||||||
|
lastPivotNextAction = (resp?['nextaction'] ?? '').toString();
|
||||||
|
debugPrint(
|
||||||
|
'[UPDATE][PICKED] server state="$lastPivotConsignmentStatus" '
|
||||||
|
'next="$lastPivotNextAction" '
|
||||||
|
'compatibility=$lastPivotCompatibilityMode',
|
||||||
|
);
|
||||||
// --- MQTT LOGIC ---
|
// --- MQTT LOGIC ---
|
||||||
MilerMqttService().publishLog('pickup_picked', payload);
|
MilerMqttService().publishLog('pickup_picked', payload);
|
||||||
}
|
}
|
||||||
@@ -366,7 +539,11 @@ class PickupsController extends GetxController
|
|||||||
String orderId = '', // New parameter for timer/bonus logic
|
String orderId = '', // New parameter for timer/bonus logic
|
||||||
String? proofImage, // New parameter
|
String? proofImage, // New parameter
|
||||||
}) async {
|
}) async {
|
||||||
|
final mock = _mockStatus(pickupId, 'picked');
|
||||||
|
if (mock != null) return mock;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
lastBlockedReason = null;
|
||||||
pickedShimmer.value = true;
|
pickedShimmer.value = true;
|
||||||
// ✅ PARALLEL OPTIMIZATION: Start fetching Prefs immediately
|
// ✅ PARALLEL OPTIMIZATION: Start fetching Prefs immediately
|
||||||
final prefsFuture = SharedPreferences.getInstance();
|
final prefsFuture = SharedPreferences.getInstance();
|
||||||
@@ -620,6 +797,7 @@ class PickupsController extends GetxController
|
|||||||
// stale value from the previous one would be stamped onto this record.
|
// stale value from the previous one would be stamped onto this record.
|
||||||
lastOnTime.value = null;
|
lastOnTime.value = null;
|
||||||
lastLateBy.value = null;
|
lastLateBy.value = null;
|
||||||
|
lastEtaDeadline.value = null;
|
||||||
lastRiderKms.value = calculatedRiderKms;
|
lastRiderKms.value = calculatedRiderKms;
|
||||||
if (orderId.isNotEmpty) {
|
if (orderId.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
@@ -629,6 +807,9 @@ class PickupsController extends GetxController
|
|||||||
if (endSeconds != null) {
|
if (endSeconds != null) {
|
||||||
final currentSeconds =
|
final currentSeconds =
|
||||||
DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
|
lastEtaDeadline.value = DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
endSeconds * 1000,
|
||||||
|
);
|
||||||
lastOnTime.value = currentSeconds <= endSeconds;
|
lastOnTime.value = currentSeconds <= endSeconds;
|
||||||
lastLateBy.value = currentSeconds <= endSeconds
|
lastLateBy.value = currentSeconds <= endSeconds
|
||||||
? Duration.zero
|
? Duration.zero
|
||||||
@@ -770,6 +951,15 @@ class PickupsController extends GetxController
|
|||||||
final resp = await _updateProvider.updatePickup(payload);
|
final resp = await _updateProvider.updatePickup(payload);
|
||||||
final ok = _isSuccess(resp);
|
final ok = _isSuccess(resp);
|
||||||
|
|
||||||
|
// Reset first: a stale number from the previous stop shown on this one's
|
||||||
|
// success screen would be worse than none.
|
||||||
|
lastTrackingNo.value = '';
|
||||||
|
if (ok && resp?['details'] is Map) {
|
||||||
|
final details = resp!['details'] as Map;
|
||||||
|
lastTrackingNo.value =
|
||||||
|
(details['tracking_no'] ?? details['trackingno'] ?? '').toString();
|
||||||
|
}
|
||||||
|
|
||||||
// Save current RIDER GPS location as last pickup location for next pickup
|
// Save current RIDER GPS location as last pickup location for next pickup
|
||||||
if (ok) {
|
if (ok) {
|
||||||
final actualLat = ll['lat'] ?? '0';
|
final actualLat = ll['lat'] ?? '0';
|
||||||
@@ -871,6 +1061,18 @@ class PickupsController extends GetxController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper method to show snackbar reliably in both debug and release builds
|
// Helper method to show snackbar reliably in both debug and release builds
|
||||||
|
//
|
||||||
|
// ── It has to say what actually happened ──
|
||||||
|
//
|
||||||
|
// This took a title and a message, built neither into anything, and printed
|
||||||
|
// the string "Something went wrong" instead — every time, for every reason.
|
||||||
|
// The one case that matters most is the proximity block: the caller composes
|
||||||
|
// "You are 4,200 m from the stop, you need to be within 100 m", and the rider
|
||||||
|
// was shown three words that told him nothing and implied a bug in the app
|
||||||
|
// rather than a thing he could fix by riding to the address.
|
||||||
|
//
|
||||||
|
// The message is now the message. The title is still only for the log — the
|
||||||
|
// snackbar is one line and a heading above it would push the fact off it.
|
||||||
void _showErrorSnackbar(
|
void _showErrorSnackbar(
|
||||||
String title,
|
String title,
|
||||||
String message, {
|
String message, {
|
||||||
@@ -884,15 +1086,10 @@ class PickupsController extends GetxController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debugPrint('[GEOFENCE] $title: $message');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final context = Get.key.currentContext;
|
AppFeedback.errorGlobal(message);
|
||||||
|
|
||||||
// Get bottom safe area padding
|
|
||||||
final double bottomSafePadding = context != null
|
|
||||||
? MediaQuery.of(context).padding.bottom
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
AppFeedback.errorGlobal('Something went wrong');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[GEOFENCE] Error showing snackbar (attempt ${retryCount + 1}): $e',
|
'[GEOFENCE] Error showing snackbar (attempt ${retryCount + 1}): $e',
|
||||||
@@ -903,7 +1100,7 @@ class PickupsController extends GetxController
|
|||||||
|
|
||||||
Future.delayed(const Duration(milliseconds: 400), () {
|
Future.delayed(const Duration(milliseconds: 400), () {
|
||||||
try {
|
try {
|
||||||
AppFeedback.errorGlobal('Something went wrong');
|
AppFeedback.errorGlobal(message);
|
||||||
} catch (e2) {
|
} catch (e2) {
|
||||||
debugPrint('[GEOFENCE] Retry snackbar failed: $e2');
|
debugPrint('[GEOFENCE] Retry snackbar failed: $e2');
|
||||||
|
|
||||||
@@ -923,6 +1120,24 @@ class PickupsController extends GetxController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Why the last status update refused to run, when it refused for a reason
|
||||||
|
/// the rider can act on — today that is only the proximity check. Null when
|
||||||
|
/// nothing blocked it.
|
||||||
|
///
|
||||||
|
/// ── Refused is not failed ──
|
||||||
|
///
|
||||||
|
/// [updatePickedupStatus] and friends return `false` for two very different
|
||||||
|
/// events: the server said no / the request never left the phone, and *the
|
||||||
|
/// app itself declined to send it* because the rider is not at the address.
|
||||||
|
/// Callers treat a `false` of the first kind optimistically — the stop is
|
||||||
|
/// finished locally and the rider moves on, because a dead network must not
|
||||||
|
/// strand him at a door. Doing that for the second kind is how a stop the hub
|
||||||
|
/// was never told about ends up on the success screen.
|
||||||
|
///
|
||||||
|
/// Set immediately before the refusal, cleared at the top of every update, so
|
||||||
|
/// a caller can read it straight after its `await`.
|
||||||
|
String? lastBlockedReason;
|
||||||
|
|
||||||
Future<bool> _checkGeofence(
|
Future<bool> _checkGeofence(
|
||||||
double targetLat,
|
double targetLat,
|
||||||
double targetLng,
|
double targetLng,
|
||||||
@@ -930,12 +1145,21 @@ class PickupsController extends GetxController
|
|||||||
double currentLng,
|
double currentLng,
|
||||||
String action,
|
String action,
|
||||||
) async {
|
) async {
|
||||||
// Testing/demo bypass: skip proximity enforcement so mock stops far from
|
lastBlockedReason = null;
|
||||||
// the rider can still be completed. Remove/flip for production.
|
|
||||||
|
// Opt-in via `--dart-define=BYPASS_GEOFENCE=true`; enforced otherwise. See
|
||||||
|
// the declaration for why it is a define rather than a constant.
|
||||||
|
//
|
||||||
|
// Logged with `debugPrint` in *every* build mode, deliberately — not behind
|
||||||
|
// `kDebugMode` like the diagnostics below. The whole risk of this flag is a
|
||||||
|
// build going out with proximity silently off, so the one thing it must
|
||||||
|
// never be is quiet.
|
||||||
if (kBypassGeofenceForTesting) {
|
if (kBypassGeofenceForTesting) {
|
||||||
if (kDebugMode) {
|
debugPrint(
|
||||||
debugPrint('[GEOFENCE] Bypassed for $action (testing flag on)');
|
'[GEOFENCE] OFF for $action — enforcement is disabled in this build. '
|
||||||
}
|
'Stop completion is NOT proximity-verified. '
|
||||||
|
'See kGeofenceEnforced.',
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -988,12 +1212,18 @@ class PickupsController extends GetxController
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (distance > radius) {
|
if (distance > radius) {
|
||||||
// Show error snackbar with clear message using helper method
|
// ── One sentence, in metres he can act on ──
|
||||||
_showErrorSnackbar(
|
//
|
||||||
'Location Error',
|
// Was three lines of "Distance: 4213 m (4.21 km) / Required: Within
|
||||||
'You are too far from the location to mark as $action.\nDistance: ${distanceMeters.toStringAsFixed(0)} m (${distanceKm.toStringAsFixed(2)} km)\nRequired: Within $radius m',
|
// 100 m" — a readout, in a snackbar, on a phone in a jacket pocket.
|
||||||
seconds: 5,
|
// What the rider needs is how far he still has to go.
|
||||||
);
|
final String away = distanceMeters >= 1000
|
||||||
|
? '${distanceKm.toStringAsFixed(1)} km'
|
||||||
|
: '${distanceMeters.toStringAsFixed(0)} m';
|
||||||
|
lastBlockedReason =
|
||||||
|
"You're $away from this stop — get within $radius m to mark it "
|
||||||
|
'${action.toLowerCase()}';
|
||||||
|
_showErrorSnackbar('Location Error', lastBlockedReason!, seconds: 5);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -1166,6 +1396,9 @@ class PickupsController extends GetxController
|
|||||||
String ridersLng = '0',
|
String ridersLng = '0',
|
||||||
String notes = '',
|
String notes = '',
|
||||||
}) async {
|
}) async {
|
||||||
|
final mock = _mockStatus(pickupId, 'accepted');
|
||||||
|
if (mock != null) return mock;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final ll = await _ensureLatLng(ridersLat, ridersLng);
|
final ll = await _ensureLatLng(ridersLat, ridersLng);
|
||||||
final acceptedTime = _formatDateTimeFull(DateTime.now());
|
final acceptedTime = _formatDateTimeFull(DateTime.now());
|
||||||
@@ -1213,6 +1446,9 @@ class PickupsController extends GetxController
|
|||||||
String notes = '',
|
String notes = '',
|
||||||
String orderId = '',
|
String orderId = '',
|
||||||
}) async {
|
}) async {
|
||||||
|
final mock = _mockStatus(pickupId, 'active');
|
||||||
|
if (mock != null) return mock;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
_activeTime = _formatDateTimeFull(now);
|
_activeTime = _formatDateTimeFull(now);
|
||||||
@@ -1342,6 +1578,29 @@ class PickupsController extends GetxController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Arrived
|
// Arrived
|
||||||
|
/// The consignment state the last `pickup-complete` reported, as a
|
||||||
|
/// [ConsignmentState] name. Empty when the response named none.
|
||||||
|
///
|
||||||
|
/// Read by Home to stamp the rung the **server** produced rather than the
|
||||||
|
/// one this build expects. See [updatePickedStatus].
|
||||||
|
String lastPivotConsignmentStatus = '';
|
||||||
|
|
||||||
|
/// True when that pivot released the consignment itself — the backend's
|
||||||
|
/// compatibility mode, and the reason Admin shows Active for a fresh pickup.
|
||||||
|
bool lastPivotCompatibilityMode = false;
|
||||||
|
|
||||||
|
/// The server's own instruction for what comes next, e.g. `start_delivery`.
|
||||||
|
String lastPivotNextAction = '';
|
||||||
|
|
||||||
|
/// Whether the **server** confirmed the last arrival, not whether the call
|
||||||
|
/// returned 200. See [updateArrivedStatus].
|
||||||
|
final RxBool arrivalConfirmedByServer = true.obs;
|
||||||
|
|
||||||
|
/// What to tell the rider when it did not. Null when there is nothing to
|
||||||
|
/// say, which is the state this should be in once the backend ships a
|
||||||
|
/// `reached` that writes `Arrived_At_Pickup`.
|
||||||
|
String? lastArrivalNotice;
|
||||||
|
|
||||||
Future<bool> updateArrivedStatus({
|
Future<bool> updateArrivedStatus({
|
||||||
required int pickupId,
|
required int pickupId,
|
||||||
required int orderHeaderId,
|
required int orderHeaderId,
|
||||||
@@ -1351,6 +1610,9 @@ class PickupsController extends GetxController
|
|||||||
String pickupLng = '0',
|
String pickupLng = '0',
|
||||||
String notes = '',
|
String notes = '',
|
||||||
}) async {
|
}) async {
|
||||||
|
final mock = _mockStatus(pickupId, 'arrived');
|
||||||
|
if (mock != null) return mock;
|
||||||
|
|
||||||
// START LOADING IMMEDIATELY for better UX
|
// START LOADING IMMEDIATELY for better UX
|
||||||
arrivedShimmer.value = true;
|
arrivedShimmer.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -1398,8 +1660,34 @@ class PickupsController extends GetxController
|
|||||||
|
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
debugPrint('[UPDATE][ARRIVED][FAILED] resp=${jsonEncode(resp)}');
|
debugPrint('[UPDATE][ARRIVED][FAILED] resp=${jsonEncode(resp)}');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return ok;
|
|
||||||
|
// ── Succeeded, but did the hub record it? ──
|
||||||
|
//
|
||||||
|
// Two different answers, and this used to return the first as if it were
|
||||||
|
// the second. `reached` answers 200 `success: true` and leaves the
|
||||||
|
// booking status untouched (verified in production 21 Aug 2026), so the
|
||||||
|
// rider saw ARRIVED and the office saw nothing — for as long as that
|
||||||
|
// endpoint stays a no-op, which is not something the app can fix.
|
||||||
|
//
|
||||||
|
// What the app can do is stop asserting the agreement. The rung still
|
||||||
|
// advances, because a rider standing at a kitchen cannot be blocked by
|
||||||
|
// somebody else's deployment; but it advances as *his* record, and the
|
||||||
|
// discrepancy is named rather than hidden behind a tick.
|
||||||
|
arrivalConfirmedByServer.value = resp?['confirmed'] == true;
|
||||||
|
if (!arrivalConfirmedByServer.value) {
|
||||||
|
lastArrivalNotice =
|
||||||
|
'Marked arrived on your phone. Your hub has not recorded it — '
|
||||||
|
'their system is not accepting arrivals yet.';
|
||||||
|
debugPrint(
|
||||||
|
'[UPDATE][ARRIVED][UNCONFIRMED] server said '
|
||||||
|
'"${resp?['serverstatus']}" — ${resp?['evidence']}',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
lastArrivalNotice = null;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[UPDATE][ARRIVED][ERROR] $e');
|
debugPrint('[UPDATE][ARRIVED][ERROR] $e');
|
||||||
arrivedShimmer.value = false;
|
arrivedShimmer.value = false;
|
||||||
@@ -1407,6 +1695,185 @@ class PickupsController extends GetxController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// THE MILK RUN'S DELIVERY LEG
|
||||||
|
//
|
||||||
|
// Deliberately separate methods rather than a flag on the pickup ones. The
|
||||||
|
// pickup path measures rider kilometres, awards a punctuality bonus, saves a
|
||||||
|
// "last pickup location" for the next leg's distance and recomputes
|
||||||
|
// chargeable weight — none of which describes handing somebody a lunch. A
|
||||||
|
// boolean threading through all of that would make both jobs harder to read
|
||||||
|
// and put the collection path, which every logistics rider depends on, one
|
||||||
|
// typo away from a milk-run change.
|
||||||
|
//
|
||||||
|
// What they DO share is the geofence, the location fix and the provider, so
|
||||||
|
// those are reused as-is.
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// The rider has reached a **customer's** door on his round.
|
||||||
|
///
|
||||||
|
/// **BACKEND DEPENDENCY — this writes nothing.** There is no route that
|
||||||
|
/// records arrival at a delivery address: `/bookings/:id/reached` belongs to
|
||||||
|
/// the pickup phase and the consignment routes have no arrival event. So this
|
||||||
|
/// verifies he is actually there and returns — the rung exists because the
|
||||||
|
/// rider needs it (it is what turns his Deliver button on at the right door),
|
||||||
|
/// not because the hub can see it.
|
||||||
|
///
|
||||||
|
/// Returning true from a method that sent nothing would normally be a lie;
|
||||||
|
/// it is not one here because the method does not claim to have written.
|
||||||
|
/// See [MilkRun.deliveryArrivalIsLocalOnly].
|
||||||
|
Future<bool> updateDeliveryArrived({
|
||||||
|
required int pickupId,
|
||||||
|
String ridersLat = '0',
|
||||||
|
String ridersLng = '0',
|
||||||
|
String dropLat = '0',
|
||||||
|
String dropLng = '0',
|
||||||
|
}) async {
|
||||||
|
arrivedShimmer.value = true;
|
||||||
|
try {
|
||||||
|
final ll = await _ensureLatLng(ridersLat, ridersLng);
|
||||||
|
final rLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0;
|
||||||
|
final rLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0;
|
||||||
|
final dLat = double.tryParse(dropLat) ?? 0.0;
|
||||||
|
final dLng = double.tryParse(dropLng) ?? 0.0;
|
||||||
|
|
||||||
|
// Fenced against the DROP, not the pickup. Using the pickup coordinates
|
||||||
|
// here would fence the rider to the kitchen he left an hour ago.
|
||||||
|
if (dLat != 0 && dLng != 0) {
|
||||||
|
final inFence = await _checkGeofence(
|
||||||
|
dLat,
|
||||||
|
dLng,
|
||||||
|
rLat,
|
||||||
|
rLng,
|
||||||
|
'Delivery arrived',
|
||||||
|
);
|
||||||
|
if (!inFence) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The clock the completion record reads, same key the pickup leg uses.
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(
|
||||||
|
'pickup_start_$pickupId',
|
||||||
|
DateTime.now().toIso8601String(),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[DELIVERY][ARRIVED][ERROR] $e');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
arrivedShimmer.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handed over. `POST /miler/consignments/:consignmentid/deliver`.
|
||||||
|
///
|
||||||
|
/// Keyed on the **consignment**, which is why [consignmentId] is required and
|
||||||
|
/// not derived: it and the booking id come from different sequences, and the
|
||||||
|
/// route answers 404 for the wrong one while the rider is shown success —
|
||||||
|
/// the same trap `bookingassignmentid` sets on the accept side.
|
||||||
|
///
|
||||||
|
/// No OTP: nothing generates one for a milk-run drop and the handler cannot
|
||||||
|
/// verify a code against anything, so sending a placeholder would be
|
||||||
|
/// fabricating proof. See [MilerApi.deliver].
|
||||||
|
Future<bool> updateDeliveredStatus({
|
||||||
|
required int pickupId,
|
||||||
|
required String consignmentId,
|
||||||
|
required String deliveredToName,
|
||||||
|
String ridersLat = '0',
|
||||||
|
String ridersLng = '0',
|
||||||
|
String dropLat = '0',
|
||||||
|
String dropLng = '0',
|
||||||
|
String notes = '',
|
||||||
|
String? proofImage,
|
||||||
|
}) async {
|
||||||
|
lastBlockedReason = null;
|
||||||
|
if (consignmentId.trim().isEmpty) {
|
||||||
|
debugPrint(
|
||||||
|
'[DELIVERED] no consignmentid for pickup $pickupId — refusing',
|
||||||
|
);
|
||||||
|
// Not a wire failure: the app itself refused, and the caller's message
|
||||||
|
// should say what to do rather than suggest a retry.
|
||||||
|
lastBlockedReason =
|
||||||
|
'This order was never picked up on the system — mark it picked '
|
||||||
|
'up first, then deliver.';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pickedShimmer.value = true;
|
||||||
|
try {
|
||||||
|
final ll = await _ensureLatLng(ridersLat, ridersLng);
|
||||||
|
final rLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0;
|
||||||
|
final rLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0;
|
||||||
|
final dLat = double.tryParse(dropLat) ?? 0.0;
|
||||||
|
final dLng = double.tryParse(dropLng) ?? 0.0;
|
||||||
|
|
||||||
|
if (dLat != 0 && dLng != 0) {
|
||||||
|
final inFence = await _checkGeofence(
|
||||||
|
dLat,
|
||||||
|
dLng,
|
||||||
|
rLat,
|
||||||
|
rLng,
|
||||||
|
'Delivered',
|
||||||
|
);
|
||||||
|
if (!inFence) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pickedTime = _formatDateTimeFull(DateTime.now());
|
||||||
|
|
||||||
|
// ── The coordinates must be the DOOR's ──
|
||||||
|
//
|
||||||
|
// The server computes `riderkms` for this leg by haversine from the
|
||||||
|
// pickup coordinates to whatever is sent here, and writes it onto the
|
||||||
|
// earnings record. Sending the pickup's own position would silently zero
|
||||||
|
// the rider's distance — and therefore his pay — for the whole leg.
|
||||||
|
final payload = <String, dynamic>{
|
||||||
|
'pickupid': pickupId,
|
||||||
|
'consignmentid': consignmentId.trim(),
|
||||||
|
'orderstatus': 'delivered',
|
||||||
|
'pickupcustomer': deliveredToName,
|
||||||
|
'pickupedtime': _pickedTime,
|
||||||
|
'riderslat': ll['lat'],
|
||||||
|
'riderslon': ll['lng'],
|
||||||
|
'pickuplat': dropLat,
|
||||||
|
'pickuplong': dropLng,
|
||||||
|
'notes': notes,
|
||||||
|
'dropimage': proofImage ?? '',
|
||||||
|
};
|
||||||
|
|
||||||
|
final resp = await _updateProvider.updatePickup(payload);
|
||||||
|
final ok = _isSuccess(resp);
|
||||||
|
if (!ok) {
|
||||||
|
debugPrint('[DELIVERED][FAILED] resp=${jsonEncode(resp)}');
|
||||||
|
// ── The server's refusal is the rider's message ──
|
||||||
|
//
|
||||||
|
// This swallowed the response and the UI fell back to "Could not
|
||||||
|
// record delivered — try again", which reads as a network blip and
|
||||||
|
// invites a retry that will refuse identically. The backend's message
|
||||||
|
// names the actual condition ("not out for delivery", "not found"),
|
||||||
|
// which is the difference between a rider retrying forever and a
|
||||||
|
// rider knowing what to tell the hub.
|
||||||
|
final serverMsg = (resp?['message'] ?? '').toString().trim();
|
||||||
|
// ── `not out for delivery` is NOT a hub-release message ──
|
||||||
|
//
|
||||||
|
// It was mapped to "the hub hasn't released this yet", which is one of
|
||||||
|
// the two opposite situations the backend spells that way — the other
|
||||||
|
// is **already delivered**, which is what a rider actually meets. The
|
||||||
|
// question is now answered before the call, from the consignment's own
|
||||||
|
// history, so this branch no longer guesses: whatever the server says
|
||||||
|
// is passed through as the server's own words.
|
||||||
|
if (serverMsg.isNotEmpty && serverMsg.length < 140) {
|
||||||
|
lastBlockedReason = 'The office refused this delivery: $serverMsg';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[DELIVERED][ERROR] $e');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
pickedShimmer.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Rejected (remove from queue)
|
// Rejected (remove from queue)
|
||||||
Future<bool> updateRejectedStatus({
|
Future<bool> updateRejectedStatus({
|
||||||
required int pickupId,
|
required int pickupId,
|
||||||
@@ -1415,6 +1882,9 @@ class PickupsController extends GetxController
|
|||||||
String ridersLng = '0',
|
String ridersLng = '0',
|
||||||
String notes = '',
|
String notes = '',
|
||||||
}) async {
|
}) async {
|
||||||
|
final mock = _mockStatus(pickupId, 'rejected');
|
||||||
|
if (mock != null) return mock;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final ll = await _ensureLatLng(ridersLat, ridersLng);
|
final ll = await _ensureLatLng(ridersLat, ridersLng);
|
||||||
final payload = <String, dynamic>{
|
final payload = <String, dynamic>{
|
||||||
@@ -1461,7 +1931,11 @@ class PickupsController extends GetxController
|
|||||||
double actualKms = 0.0,
|
double actualKms = 0.0,
|
||||||
bool wasSkipped = false,
|
bool wasSkipped = false,
|
||||||
}) async {
|
}) async {
|
||||||
|
final mock = _mockStatus(pickupId, 'cancelled');
|
||||||
|
if (mock != null) return mock;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
lastBlockedReason = null;
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
_cancelledTime = _formatDateTimeFull(now);
|
_cancelledTime = _formatDateTimeFull(now);
|
||||||
|
|
||||||
@@ -1746,6 +2220,9 @@ class PickupsController extends GetxController
|
|||||||
String ridersLng = '0',
|
String ridersLng = '0',
|
||||||
String notes = '',
|
String notes = '',
|
||||||
}) async {
|
}) async {
|
||||||
|
final mock = _mockStatus(pickupId, 'skipped');
|
||||||
|
if (mock != null) return mock;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final ll = await _ensureLatLng(ridersLat, ridersLng);
|
final ll = await _ensureLatLng(ridersLat, ridersLng);
|
||||||
final skippedTime = _formatDateTimeFull(DateTime.now());
|
final skippedTime = _formatDateTimeFull(DateTime.now());
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ class RewardsController extends GetxController {
|
|||||||
// earnings.
|
// earnings.
|
||||||
final res = await MilerApi.earnings(period: 'monthly');
|
final res = await MilerApi.earnings(period: 'monthly');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
totalPoints.value =
|
totalPoints.value = int.tryParse('${res.map['total_bonus'] ?? 0}') ?? 0;
|
||||||
int.tryParse('${res.map['total_bonus'] ?? 0}') ?? 0;
|
|
||||||
} else {
|
} else {
|
||||||
error.value = res.message.isEmpty
|
error.value = res.message.isEmpty
|
||||||
? "Couldn't load your points"
|
? "Couldn't load your points"
|
||||||
|
|||||||
@@ -169,7 +169,6 @@ class RiderLogController extends GetxController
|
|||||||
required String latitude,
|
required String latitude,
|
||||||
required String longitude,
|
required String longitude,
|
||||||
}) async {
|
}) async {
|
||||||
|
|
||||||
final payload = {
|
final payload = {
|
||||||
"breakid": breakid,
|
"breakid": breakid,
|
||||||
"logid": logid,
|
"logid": logid,
|
||||||
@@ -471,7 +470,6 @@ class RiderLogController extends GetxController
|
|||||||
payload.remove('lastname');
|
payload.remove('lastname');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
await _saveToOfflineQueue('/miler/logs', payload);
|
await _saveToOfflineQueue('/miler/logs', payload);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -488,15 +486,6 @@ class RiderLogController extends GetxController
|
|||||||
final int? userid = prefs.getInt('userId') ?? prefs.getInt('userid');
|
final int? userid = prefs.getInt('userId') ?? prefs.getInt('userid');
|
||||||
if ((userid ?? 0) == 0) return false;
|
if ((userid ?? 0) == 0) return false;
|
||||||
|
|
||||||
// Demo mode: the mocked login flow in auth.dart stores a fake rider id
|
|
||||||
// (9999) that the live backend rejects, so the real updateriderlog call
|
|
||||||
// always fails. Toggle duty locally and report success so the demo
|
|
||||||
// "Slide to Start Duty" works without a real account or server call.
|
|
||||||
if (userid == 9999) {
|
|
||||||
await prefs.setInt('onduty', on ? 1 : 0);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
final loc = await _ensureLatLng('0', '0');
|
final loc = await _ensureLatLng('0', '0');
|
||||||
final payload = RiderUpdate(
|
final payload = RiderUpdate(
|
||||||
userid: userid,
|
userid: userid,
|
||||||
@@ -898,7 +887,6 @@ class RiderLogController extends GetxController
|
|||||||
final breakdate = _formatDateTimeFull(now); // e.g. 2025-10-16 16:36:16
|
final breakdate = _formatDateTimeFull(now); // e.g. 2025-10-16 16:36:16
|
||||||
final breakstart = _formatTime(now); // e.g. 16:36:16
|
final breakstart = _formatTime(now); // e.g. 16:36:16
|
||||||
|
|
||||||
|
|
||||||
// Build payload with all required fields in the exact format the API expects
|
// Build payload with all required fields in the exact format the API expects
|
||||||
final payload = <String, dynamic>{
|
final payload = <String, dynamic>{
|
||||||
"breakid": localBreakId,
|
"breakid": localBreakId,
|
||||||
@@ -1074,7 +1062,6 @@ class RiderLogController extends GetxController
|
|||||||
final iso = _formatDateTimeFull(now);
|
final iso = _formatDateTimeFull(now);
|
||||||
final loginTime = _formatTime(now);
|
final loginTime = _formatTime(now);
|
||||||
|
|
||||||
|
|
||||||
// ✅ Check if there are active pickup to set status
|
// ✅ Check if there are active pickup to set status
|
||||||
final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false;
|
final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false;
|
||||||
final String riderStatus = hasActivePickups ? 'active' : 'idle';
|
final String riderStatus = hasActivePickups ? 'active' : 'idle';
|
||||||
|
|||||||
@@ -1,12 +1,134 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'package:miler/data/service_day.dart';
|
||||||
|
import 'package:miler/data/service_profile.dart';
|
||||||
|
import 'package:miler/data/work_scope.dart';
|
||||||
|
|
||||||
|
/// ── Every key in this file belongs to a session, not to the handset ──
|
||||||
|
///
|
||||||
|
/// These stores held finished work, skipped work, carried bags and released
|
||||||
|
/// orders under *global* keys — `completed_bookings` and friends — so one
|
||||||
|
/// phone had one drawer and whoever logged in last opened it. Logging out and
|
||||||
|
/// back in as another rider, or a tenant switch that moves the app between the
|
||||||
|
/// milk-man round and the logistics day, showed the previous scope's records
|
||||||
|
/// as if they were yours.
|
||||||
|
///
|
||||||
|
/// The key now carries the identity the session can prove: rider, tenant and
|
||||||
|
/// line. See [WorkScope]. Nothing else in this file changed shape — the
|
||||||
|
/// scoping happens here, at the data boundary, once.
|
||||||
|
Future<String> _scopedKey(String base) async =>
|
||||||
|
(await WorkScope.current()).scoped(base);
|
||||||
|
|
||||||
|
/// Legacy global keys, drained on first scoped access.
|
||||||
|
///
|
||||||
|
/// ── Why draining and not migrating ──
|
||||||
|
///
|
||||||
|
/// A legacy row cannot say whose it is: the global keys predate the identity
|
||||||
|
/// stamp, so a `completed_bookings` blob is *some* rider's, on *some* line,
|
||||||
|
/// and attributing it to whoever happens to log in first would be inventing
|
||||||
|
/// ownership — the exact leak this change exists to close. Rows that prove
|
||||||
|
/// their own ownership ([WorkScope.owns]) are carried across; the rest are
|
||||||
|
/// dropped. The cost is bounded and small: [getCompletedBookings] already
|
||||||
|
/// prunes to today, so at worst one day of local history is lost once, on one
|
||||||
|
/// upgrade, for records nobody can attribute anyway. The server-side history
|
||||||
|
/// is untouched by any of this.
|
||||||
|
Future<void> _drainLegacy(String base, WorkScope scope) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (!prefs.containsKey(base)) return;
|
||||||
|
|
||||||
|
final scopedKey = scope.scoped(base);
|
||||||
|
final Object? legacy = prefs.get(base);
|
||||||
|
|
||||||
|
if (legacy is String && !prefs.containsKey(scopedKey)) {
|
||||||
|
// A JSON blob of records: keep only the rows that prove they are ours.
|
||||||
|
final rows = _decode(legacy);
|
||||||
|
final mine = [
|
||||||
|
for (final r in rows)
|
||||||
|
if (scope.owns(r)) scope.stamp(r),
|
||||||
|
];
|
||||||
|
if (mine.isNotEmpty) {
|
||||||
|
await prefs.setString(scopedKey, jsonEncode(mine));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Id lists carry no identity at all, so there is nothing to carry across.
|
||||||
|
await prefs.remove(base);
|
||||||
|
debugPrint('[SCOPE] drained legacy "$base" into ${scope.key}');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the one-time drain for every legacy key. Cheap after the first call —
|
||||||
|
/// `containsKey` on a loaded prefs map.
|
||||||
|
Future<void> migrateLegacyStores() async {
|
||||||
|
final scope = await WorkScope.current();
|
||||||
|
for (final base in const [
|
||||||
|
_kAcceptedBookingsKeyBase,
|
||||||
|
_kRejectedOrderIdsKeyBase,
|
||||||
|
_kCompletedBookingsKeyBase,
|
||||||
|
_kSkippedBookingsKeyBase,
|
||||||
|
_kCollectedOrderIdsKeyBase,
|
||||||
|
_kConsignmentIdsKeyBase,
|
||||||
|
_kOutForDeliveryKeyBase,
|
||||||
|
_kNotLoadedKeyBase,
|
||||||
|
_kBagLabelsKeyBase,
|
||||||
|
]) {
|
||||||
|
await _drainLegacy(base, scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seeds one of this scope's stores directly. **Tests only.**
|
||||||
|
///
|
||||||
|
/// Fixtures used to write the bare global key (`completed_bookings`) because
|
||||||
|
/// that is what the store read. Now that a store belongs to a rider, a tenant
|
||||||
|
/// and a line, a fixture that writes the bare key is seeding a drawer nothing
|
||||||
|
/// opens — so it goes through the same resolver the app does, and the tests
|
||||||
|
/// exercise the shipped key scheme rather than a retired one.
|
||||||
|
/// [value] is the JSON blob for a record store, or the id list for one of the
|
||||||
|
/// set-shaped stores (`collected_order_ids`, `out_for_delivery_order_ids`,
|
||||||
|
/// `mock_rejected_order_ids`) — the same two shapes the stores themselves use.
|
||||||
|
/// The key [debugSeedStore] writes to, for assertions that read prefs back.
|
||||||
|
@visibleForTesting
|
||||||
|
Future<String> debugStoreKey(String base) => _scopedKey(base);
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
Future<void> debugSeedStore(String base, Object value) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final key = await _scopedKey(base);
|
||||||
|
if (value is List) {
|
||||||
|
await prefs.setStringList(key, [for (final v in value) v.toString()]);
|
||||||
|
} else {
|
||||||
|
await prefs.setString(key, value.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wipes **this scope's** stores. Called on logout so the next rider on this
|
||||||
|
/// handset starts empty — see `AuthController`.
|
||||||
|
Future<void> clearScopedStores() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final scope = await WorkScope.current();
|
||||||
|
for (final base in const [
|
||||||
|
_kAcceptedBookingsKeyBase,
|
||||||
|
_kRejectedOrderIdsKeyBase,
|
||||||
|
_kCompletedBookingsKeyBase,
|
||||||
|
_kSkippedBookingsKeyBase,
|
||||||
|
_kCollectedOrderIdsKeyBase,
|
||||||
|
_kConsignmentIdsKeyBase,
|
||||||
|
_kOutForDeliveryKeyBase,
|
||||||
|
_kNotLoadedKeyBase,
|
||||||
|
_kBagLabelsKeyBase,
|
||||||
|
]) {
|
||||||
|
await prefs.remove(scope.scoped(base));
|
||||||
|
await prefs.remove(base);
|
||||||
|
}
|
||||||
|
debugPrint('[SCOPE] cleared ${scope.key}');
|
||||||
|
}
|
||||||
|
|
||||||
/// Persistent store of bookings the rider has accepted while the app runs on
|
/// Persistent store of bookings the rider has accepted while the app runs on
|
||||||
/// mock data (offline / demo mode). This lets the accept flow be functional
|
/// mock data (offline / demo mode). This lets the accept flow be functional
|
||||||
/// without a server: the Home queue drops accepted bookings (and keeps them
|
/// without a server: the Home queue drops accepted bookings (and keeps them
|
||||||
/// dropped across refetches/navigation), and the Bookings tab picks them up.
|
/// dropped across refetches/navigation), and the Bookings tab picks them up.
|
||||||
const String _kAcceptedBookingsKey = 'mock_accepted_bookings';
|
const String _kAcceptedBookingsKeyBase = 'mock_accepted_bookings';
|
||||||
const String _kRejectedOrderIdsKey = 'mock_rejected_order_ids';
|
const String _kRejectedOrderIdsKeyBase = 'mock_rejected_order_ids';
|
||||||
|
|
||||||
/// Stops finished today, for the Activity tab.
|
/// Stops finished today, for the Activity tab.
|
||||||
///
|
///
|
||||||
@@ -26,7 +148,7 @@ const String _kRejectedOrderIdsKey = 'mock_rejected_order_ids';
|
|||||||
///
|
///
|
||||||
/// This is the record that survives that. It is the same trade the accepted
|
/// This is the record that survives that. It is the same trade the accepted
|
||||||
/// store already makes, for the same reason.
|
/// store already makes, for the same reason.
|
||||||
const String _kCompletedBookingsKey = 'completed_bookings';
|
const String _kCompletedBookingsKeyBase = 'completed_bookings';
|
||||||
|
|
||||||
/// Stops the rider parked mid-shift for a return visit.
|
/// Stops the rider parked mid-shift for a return visit.
|
||||||
///
|
///
|
||||||
@@ -43,8 +165,8 @@ const String _kCompletedBookingsKey = 'completed_bookings';
|
|||||||
///
|
///
|
||||||
/// So a skip is recorded here the moment it is taken, with the reason the rider
|
/// So a skip is recorded here the moment it is taken, with the reason the rider
|
||||||
/// gave, and it is removed when he resumes the stop. Same trade as
|
/// gave, and it is removed when he resumes the stop. Same trade as
|
||||||
/// [_kCompletedBookingsKey], for the same reason.
|
/// [await _scopedKey(_kCompletedBookingsKeyBase)], for the same reason.
|
||||||
const String _kSkippedBookingsKey = 'skipped_bookings';
|
const String _kSkippedBookingsKeyBase = 'skipped_bookings';
|
||||||
|
|
||||||
List<Map<String, dynamic>> _decode(String? raw) {
|
List<Map<String, dynamic>> _decode(String? raw) {
|
||||||
if (raw == null || raw.isEmpty) return [];
|
if (raw == null || raw.isEmpty) return [];
|
||||||
@@ -64,7 +186,7 @@ List<Map<String, dynamic>> _decode(String? raw) {
|
|||||||
/// `orderstatus: 'accepted'`).
|
/// `orderstatus: 'accepted'`).
|
||||||
Future<List<Map<String, dynamic>>> getAcceptedBookings() async {
|
Future<List<Map<String, dynamic>>> getAcceptedBookings() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
return _decode(prefs.getString(_kAcceptedBookingsKey));
|
return _decode(prefs.getString(await _scopedKey(_kAcceptedBookingsKeyBase)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The set of order ids that have been locally accepted.
|
/// The set of order ids that have been locally accepted.
|
||||||
@@ -80,7 +202,9 @@ Future<Set<String>> getAcceptedOrderIds() async {
|
|||||||
/// rejected booking leaves the pending list.
|
/// rejected booking leaves the pending list.
|
||||||
Future<Set<String>> getRejectedOrderIds() async {
|
Future<Set<String>> getRejectedOrderIds() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
return (prefs.getStringList(_kRejectedOrderIdsKey) ?? []).toSet();
|
return (prefs.getStringList(await _scopedKey(_kRejectedOrderIdsKeyBase)) ??
|
||||||
|
[])
|
||||||
|
.toSet();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remember the given order ids as rejected.
|
/// Remember the given order ids as rejected.
|
||||||
@@ -88,9 +212,14 @@ Future<void> addRejectedOrderIds(List<String> ids) async {
|
|||||||
final clean = ids.where((s) => s.isNotEmpty).toSet();
|
final clean = ids.where((s) => s.isNotEmpty).toSet();
|
||||||
if (clean.isEmpty) return;
|
if (clean.isEmpty) return;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final existing = (prefs.getStringList(_kRejectedOrderIdsKey) ?? []).toSet();
|
final existing =
|
||||||
|
(prefs.getStringList(await _scopedKey(_kRejectedOrderIdsKeyBase)) ?? [])
|
||||||
|
.toSet();
|
||||||
existing.addAll(clean);
|
existing.addAll(clean);
|
||||||
await prefs.setStringList(_kRejectedOrderIdsKey, existing.toList());
|
await prefs.setStringList(
|
||||||
|
await _scopedKey(_kRejectedOrderIdsKeyBase),
|
||||||
|
existing.toList(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Undo a rejection.
|
/// Undo a rejection.
|
||||||
@@ -102,10 +231,14 @@ Future<void> removeRejectedOrderIds(List<String> ids) async {
|
|||||||
final drop = ids.where((s) => s.isNotEmpty).toSet();
|
final drop = ids.where((s) => s.isNotEmpty).toSet();
|
||||||
if (drop.isEmpty) return;
|
if (drop.isEmpty) return;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final remaining = (prefs.getStringList(_kRejectedOrderIdsKey) ?? [])
|
final remaining =
|
||||||
|
(prefs.getStringList(await _scopedKey(_kRejectedOrderIdsKeyBase)) ?? [])
|
||||||
.where((id) => !drop.contains(id))
|
.where((id) => !drop.contains(id))
|
||||||
.toList();
|
.toList();
|
||||||
await prefs.setStringList(_kRejectedOrderIdsKey, remaining);
|
await prefs.setStringList(
|
||||||
|
await _scopedKey(_kRejectedOrderIdsKeyBase),
|
||||||
|
remaining,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove the given order ids from the accepted store. Called when a pickup is
|
/// Remove the given order ids from the accepted store. Called when a pickup is
|
||||||
@@ -116,9 +249,12 @@ Future<void> removeAcceptedBookings(List<String> ids) async {
|
|||||||
if (drop.isEmpty) return;
|
if (drop.isEmpty) return;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final remaining = _decode(
|
final remaining = _decode(
|
||||||
prefs.getString(_kAcceptedBookingsKey),
|
prefs.getString(await _scopedKey(_kAcceptedBookingsKeyBase)),
|
||||||
).where((b) => !drop.contains((b['orderid'] ?? '').toString())).toList();
|
).where((b) => !drop.contains((b['orderid'] ?? '').toString())).toList();
|
||||||
await prefs.setString(_kAcceptedBookingsKey, jsonEncode(remaining));
|
await prefs.setString(
|
||||||
|
await _scopedKey(_kAcceptedBookingsKeyBase),
|
||||||
|
jsonEncode(remaining),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stops the rider finished **today**, newest first.
|
/// Stops the rider finished **today**, newest first.
|
||||||
@@ -128,7 +264,9 @@ Future<void> removeAcceptedBookings(List<String> ids) async {
|
|||||||
/// tab — and the store cannot grow without bound.
|
/// tab — and the store cannot grow without bound.
|
||||||
Future<List<Map<String, dynamic>>> getCompletedBookings() async {
|
Future<List<Map<String, dynamic>>> getCompletedBookings() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final all = _decode(prefs.getString(_kCompletedBookingsKey));
|
final all = _decode(
|
||||||
|
prefs.getString(await _scopedKey(_kCompletedBookingsKeyBase)),
|
||||||
|
);
|
||||||
final today = _dayStamp(DateTime.now());
|
final today = _dayStamp(DateTime.now());
|
||||||
|
|
||||||
final mine = all.where((b) => (b['completedday'] ?? '') == today).toList()
|
final mine = all.where((b) => (b['completedday'] ?? '') == today).toList()
|
||||||
@@ -140,7 +278,10 @@ Future<List<Map<String, dynamic>>> getCompletedBookings() async {
|
|||||||
|
|
||||||
// Prune in the background if yesterday's rows are still in there.
|
// Prune in the background if yesterday's rows are still in there.
|
||||||
if (mine.length != all.length) {
|
if (mine.length != all.length) {
|
||||||
await prefs.setString(_kCompletedBookingsKey, jsonEncode(mine));
|
await prefs.setString(
|
||||||
|
await _scopedKey(_kCompletedBookingsKeyBase),
|
||||||
|
jsonEncode(mine),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return mine;
|
return mine;
|
||||||
}
|
}
|
||||||
@@ -169,19 +310,35 @@ String kStopArrivedKey(Object pickupId) => 'pickup_start_$pickupId';
|
|||||||
/// This is the last moment they are all true at once, so this is where they are
|
/// This is the last moment they are all true at once, so this is where they are
|
||||||
/// written down and the keys are cleared. Same argument as [StopCompliance],
|
/// written down and the keys are cleared. Same argument as [StopCompliance],
|
||||||
/// which is stamped one call earlier for the same reason.
|
/// which is stamped one call earlier for the same reason.
|
||||||
|
/// [terminalStatus] names what "finished" means on this line, and defaults to
|
||||||
|
/// the active profile's answer.
|
||||||
|
///
|
||||||
|
/// A logistics stop ends at `picked` — collecting the parcel *is* the job. A
|
||||||
|
/// milk-run stop ends at `delivered`, and stamping `picked` on it would file
|
||||||
|
/// the crate as the finished work and report fifteen lunches complete at the
|
||||||
|
/// moment the rider left the kitchen. One store, two honest endings; see
|
||||||
|
/// [StopStatus.isTerminal].
|
||||||
Future<void> addCompletedBookings(
|
Future<void> addCompletedBookings(
|
||||||
List<Map<String, dynamic>> bookings, {
|
List<Map<String, dynamic>> bookings, {
|
||||||
bool cancelled = false,
|
bool cancelled = false,
|
||||||
|
String? terminalStatus,
|
||||||
}) async {
|
}) async {
|
||||||
if (bookings.isEmpty) return;
|
if (bookings.isEmpty) return;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
final String done = ServiceProfile.active.deliversToCustomer
|
||||||
|
? 'delivered'
|
||||||
|
: 'picked';
|
||||||
|
|
||||||
final Map<String, Map<String, dynamic>> byId = {
|
final Map<String, Map<String, dynamic>> byId = {
|
||||||
for (final b in _decode(prefs.getString(_kCompletedBookingsKey)))
|
for (final b in _decode(
|
||||||
|
prefs.getString(await _scopedKey(_kCompletedBookingsKeyBase)),
|
||||||
|
))
|
||||||
(b['orderid'] ?? '').toString(): b,
|
(b['orderid'] ?? '').toString(): b,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
final scope = await WorkScope.current();
|
||||||
|
|
||||||
for (final b in bookings) {
|
for (final b in bookings) {
|
||||||
final id = (b['orderid'] ?? '').toString();
|
final id = (b['orderid'] ?? '').toString();
|
||||||
if (id.isEmpty) continue;
|
if (id.isEmpty) continue;
|
||||||
@@ -189,7 +346,11 @@ Future<void> addCompletedBookings(
|
|||||||
// Stamped so the same `stopStatusOf` test that drops a stop from Bookings
|
// Stamped so the same `stopStatusOf` test that drops a stop from Bookings
|
||||||
// is the one that picks it up here — the two can never disagree about what
|
// is the one that picks it up here — the two can never disagree about what
|
||||||
// "done" means.
|
// "done" means.
|
||||||
copy['orderstatus'] = cancelled ? 'cancelled' : 'picked';
|
// `terminalStatus` is the caller naming the outcome exactly; `cancelled`
|
||||||
|
// is the older shorthand for "anything that was not a completion". The
|
||||||
|
// precise word wins, so a skipped delivery is filed as a skip rather than
|
||||||
|
// being flattened into a cancellation it was not.
|
||||||
|
copy['orderstatus'] = terminalStatus ?? (cancelled ? 'cancelled' : done);
|
||||||
copy['completedat'] = now.toIso8601String();
|
copy['completedat'] = now.toIso8601String();
|
||||||
copy['completedday'] = _dayStamp(now);
|
copy['completedday'] = _dayStamp(now);
|
||||||
|
|
||||||
@@ -205,11 +366,13 @@ Future<void> addCompletedBookings(
|
|||||||
await prefs.remove(kStopArrivedKey(pickupId));
|
await prefs.remove(kStopArrivedKey(pickupId));
|
||||||
}
|
}
|
||||||
|
|
||||||
byId[id] = copy;
|
// Stamped with the session that produced it, so a row read back later
|
||||||
|
// can prove its own ownership even if the key scheme changes again.
|
||||||
|
byId[id] = scope.stamp(copy);
|
||||||
}
|
}
|
||||||
|
|
||||||
await prefs.setString(
|
await prefs.setString(
|
||||||
_kCompletedBookingsKey,
|
await _scopedKey(_kCompletedBookingsKeyBase),
|
||||||
jsonEncode(byId.values.toList()),
|
jsonEncode(byId.values.toList()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -221,7 +384,9 @@ Future<void> addCompletedBookings(
|
|||||||
/// no longer act on.
|
/// no longer act on.
|
||||||
Future<List<Map<String, dynamic>>> getSkippedBookings() async {
|
Future<List<Map<String, dynamic>>> getSkippedBookings() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final all = _decode(prefs.getString(_kSkippedBookingsKey));
|
final all = _decode(
|
||||||
|
prefs.getString(await _scopedKey(_kSkippedBookingsKeyBase)),
|
||||||
|
);
|
||||||
final today = _dayStamp(DateTime.now());
|
final today = _dayStamp(DateTime.now());
|
||||||
|
|
||||||
final mine = all.where((b) => (b['skippedday'] ?? '') == today).toList()
|
final mine = all.where((b) => (b['skippedday'] ?? '') == today).toList()
|
||||||
@@ -232,11 +397,30 @@ Future<List<Map<String, dynamic>>> getSkippedBookings() async {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (mine.length != all.length) {
|
if (mine.length != all.length) {
|
||||||
await prefs.setString(_kSkippedBookingsKey, jsonEncode(mine));
|
await prefs.setString(
|
||||||
|
await _scopedKey(_kSkippedBookingsKeyBase),
|
||||||
|
jsonEncode(mine),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return mine;
|
return mine;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The set of order ids finished today — picked up or written off.
|
||||||
|
///
|
||||||
|
/// The queue endpoints are a poll or more behind the rider, so this is the only
|
||||||
|
/// thing that knows a stop is done the moment he says so. Home stamps it over
|
||||||
|
/// whatever status the queue is still reporting — see `_fetchQueues` — the same
|
||||||
|
/// way it already does for skips, and for the same reason: a card that keeps
|
||||||
|
/// saying LIVE after the rider has closed the stop reads as the app not having
|
||||||
|
/// heard him.
|
||||||
|
Future<Set<String>> getCompletedOrderIds() async {
|
||||||
|
final list = await getCompletedBookings();
|
||||||
|
return list
|
||||||
|
.map((b) => (b['orderid'] ?? '').toString())
|
||||||
|
.where((s) => s.isNotEmpty)
|
||||||
|
.toSet();
|
||||||
|
}
|
||||||
|
|
||||||
/// The set of order ids currently parked as skipped.
|
/// The set of order ids currently parked as skipped.
|
||||||
Future<Set<String>> getSkippedOrderIds() async {
|
Future<Set<String>> getSkippedOrderIds() async {
|
||||||
final list = await getSkippedBookings();
|
final list = await getSkippedBookings();
|
||||||
@@ -258,7 +442,9 @@ Future<void> addSkippedBooking(
|
|||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
|
||||||
final Map<String, Map<String, dynamic>> byId = {
|
final Map<String, Map<String, dynamic>> byId = {
|
||||||
for (final b in _decode(prefs.getString(_kSkippedBookingsKey)))
|
for (final b in _decode(
|
||||||
|
prefs.getString(await _scopedKey(_kSkippedBookingsKeyBase)),
|
||||||
|
))
|
||||||
(b['orderid'] ?? '').toString(): b,
|
(b['orderid'] ?? '').toString(): b,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -271,7 +457,10 @@ Future<void> addSkippedBooking(
|
|||||||
copy['skippedday'] = _dayStamp(now);
|
copy['skippedday'] = _dayStamp(now);
|
||||||
byId[id] = copy;
|
byId[id] = copy;
|
||||||
|
|
||||||
await prefs.setString(_kSkippedBookingsKey, jsonEncode(byId.values.toList()));
|
await prefs.setString(
|
||||||
|
await _scopedKey(_kSkippedBookingsKeyBase),
|
||||||
|
jsonEncode(byId.values.toList()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forget a skip — the rider resumed the stop, so it is live work again.
|
/// Forget a skip — the rider resumed the stop, so it is live work again.
|
||||||
@@ -280,21 +469,29 @@ Future<void> removeSkippedBookings(List<String> ids) async {
|
|||||||
if (drop.isEmpty) return;
|
if (drop.isEmpty) return;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final remaining = _decode(
|
final remaining = _decode(
|
||||||
prefs.getString(_kSkippedBookingsKey),
|
prefs.getString(await _scopedKey(_kSkippedBookingsKeyBase)),
|
||||||
).where((b) => !drop.contains((b['orderid'] ?? '').toString())).toList();
|
).where((b) => !drop.contains((b['orderid'] ?? '').toString())).toList();
|
||||||
await prefs.setString(_kSkippedBookingsKey, jsonEncode(remaining));
|
await prefs.setString(
|
||||||
|
await _scopedKey(_kSkippedBookingsKeyBase),
|
||||||
|
jsonEncode(remaining),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _dayStamp(DateTime t) =>
|
/// The local calendar date a record belongs to.
|
||||||
'${t.year}-${t.month.toString().padLeft(2, '0')}-'
|
///
|
||||||
'${t.day.toString().padLeft(2, '0')}';
|
/// Delegates to [ServiceDay] so the stamp written here and the day Activity
|
||||||
|
/// asks for are produced by the same line of code — two implementations of a
|
||||||
|
/// date format is how a shift ends up half in one day and half in the next.
|
||||||
|
String _dayStamp(DateTime t) => ServiceDay.stamp(t);
|
||||||
|
|
||||||
/// Persist the given bookings as accepted, deduped by `orderid`.
|
/// Persist the given bookings as accepted, deduped by `orderid`.
|
||||||
Future<void> addAcceptedBookings(List<Map<String, dynamic>> bookings) async {
|
Future<void> addAcceptedBookings(List<Map<String, dynamic>> bookings) async {
|
||||||
if (bookings.isEmpty) return;
|
if (bookings.isEmpty) return;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final Map<String, Map<String, dynamic>> byId = {
|
final Map<String, Map<String, dynamic>> byId = {
|
||||||
for (final b in _decode(prefs.getString(_kAcceptedBookingsKey)))
|
for (final b in _decode(
|
||||||
|
prefs.getString(await _scopedKey(_kAcceptedBookingsKeyBase)),
|
||||||
|
))
|
||||||
(b['orderid'] ?? '').toString(): b,
|
(b['orderid'] ?? '').toString(): b,
|
||||||
};
|
};
|
||||||
for (final b in bookings) {
|
for (final b in bookings) {
|
||||||
@@ -305,7 +502,384 @@ Future<void> addAcceptedBookings(List<Map<String, dynamic>> bookings) async {
|
|||||||
byId[id] = copy;
|
byId[id] = copy;
|
||||||
}
|
}
|
||||||
await prefs.setString(
|
await prefs.setString(
|
||||||
_kAcceptedBookingsKey,
|
await _scopedKey(_kAcceptedBookingsKeyBase),
|
||||||
jsonEncode(byId.values.toList()),
|
jsonEncode(byId.values.toList()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// COLLECTED — parcels that are physically in the rider's hands
|
||||||
|
//
|
||||||
|
// A meal run has a state the parcel backend cannot express. Its ladder is
|
||||||
|
// `accepted → arrived → picked`, and `picked` is terminal: it clears the order
|
||||||
|
// out of the accepted store and files it on Activity as finished. That is the
|
||||||
|
// right shape for a parcel, where collecting it from the customer IS the job.
|
||||||
|
//
|
||||||
|
// It is the wrong shape for a service route, where collecting is the *middle*.
|
||||||
|
// A rider loads ten lunches at a kitchen at 11:50 and delivers the last one at
|
||||||
|
// 13:20; writing `picked` at the kitchen would report all ten orders complete
|
||||||
|
// ninety minutes before anybody ate, and DailyGrubs is billed on that record.
|
||||||
|
//
|
||||||
|
// So `picked` stays where it belongs — the hand-over at the customer's door —
|
||||||
|
// and the intermediate state lives here, on the device, as the set of orders
|
||||||
|
// the rider is carrying. It is what moves a card off Home and onto Bookings.
|
||||||
|
//
|
||||||
|
// BACKEND DEPENDENCY: this is a local stand-in. The hub cannot see that a
|
||||||
|
// rider has loaded a kitchen until the API grows a `collected` status (and a
|
||||||
|
// `delivered` one, so the terminal event can be named for what it is). Until
|
||||||
|
// then a crash between the kitchen and the first door loses only the ordering,
|
||||||
|
// not the work: every order is still accepted server-side and still appears.
|
||||||
|
const String _kCollectedOrderIdsKeyBase = 'collected_order_ids';
|
||||||
|
|
||||||
|
/// Order ids the rider has loaded and is carrying.
|
||||||
|
Future<Set<String>> getCollectedOrderIds() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return (prefs.getStringList(await _scopedKey(_kCollectedOrderIdsKeyBase)) ??
|
||||||
|
[])
|
||||||
|
.toSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records a load. Called once per kitchen, with everything taken from it.
|
||||||
|
Future<void> addCollectedOrderIds(List<String> ids) async {
|
||||||
|
final clean = ids.where((s) => s.isNotEmpty).toSet();
|
||||||
|
if (clean.isEmpty) return;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final existing =
|
||||||
|
(prefs.getStringList(await _scopedKey(_kCollectedOrderIdsKeyBase)) ?? [])
|
||||||
|
.toSet();
|
||||||
|
existing.addAll(clean);
|
||||||
|
await prefs.setStringList(
|
||||||
|
await _scopedKey(_kCollectedOrderIdsKeyBase),
|
||||||
|
existing.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ── The consignment id, captured at the pivot ──
|
||||||
|
///
|
||||||
|
/// `pickup-complete` is the call that *creates* the consignment, and its id is
|
||||||
|
/// the key every delivery action needs: `deliver` and `skip` are consignment
|
||||||
|
/// routes, not booking ones, and passing a booking id gets a 404 while the
|
||||||
|
/// rider is shown success.
|
||||||
|
///
|
||||||
|
/// That id was being thrown away. The response was wrapped into a legacy
|
||||||
|
/// envelope and discarded, and the row the rider then worked from on Deliveries
|
||||||
|
/// is a snapshot taken *before* the conversion — so it had no consignment id
|
||||||
|
/// either. The result: he collected an order, drove it to the door, pressed
|
||||||
|
/// **Delivered**, and the app refused because it did not know what to deliver.
|
||||||
|
///
|
||||||
|
/// The queue does carry the id once the backend catches up, so this is a
|
||||||
|
/// bridge, not a second source of truth: [consignmentIdFor] prefers the row and
|
||||||
|
/// falls back to what was recorded here.
|
||||||
|
const String _kConsignmentIdsKeyBase = 'consignment_ids_by_order';
|
||||||
|
|
||||||
|
/// Records the consignment `pickup-complete` just created for [orderId].
|
||||||
|
Future<void> rememberConsignmentId(String orderId, String consignmentId) async {
|
||||||
|
if (orderId.isEmpty || consignmentId.isEmpty) return;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final map = await getConsignmentIds();
|
||||||
|
map[orderId] = consignmentId;
|
||||||
|
await prefs.setString(
|
||||||
|
await _scopedKey(_kConsignmentIdsKeyBase),
|
||||||
|
jsonEncode(map),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every consignment id this device recorded at a pivot, by order id.
|
||||||
|
Future<Map<String, String>> getConsignmentIds() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final raw = prefs.getString(await _scopedKey(_kConsignmentIdsKeyBase));
|
||||||
|
if (raw == null || raw.isEmpty) return <String, String>{};
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is! Map) return <String, String>{};
|
||||||
|
return {
|
||||||
|
for (final e in decoded.entries)
|
||||||
|
e.key.toString(): e.value?.toString() ?? '',
|
||||||
|
}..removeWhere((_, v) => v.isEmpty);
|
||||||
|
} catch (_) {
|
||||||
|
return <String, String>{};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops ids for orders that are finished, so the map does not grow for the
|
||||||
|
/// life of the install.
|
||||||
|
Future<void> forgetConsignmentIds(List<String> orderIds) async {
|
||||||
|
if (orderIds.isEmpty) return;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final map = await getConsignmentIds();
|
||||||
|
var changed = false;
|
||||||
|
for (final id in orderIds) {
|
||||||
|
if (map.remove(id) != null) changed = true;
|
||||||
|
}
|
||||||
|
if (changed)
|
||||||
|
await prefs.setString(
|
||||||
|
await _scopedKey(_kConsignmentIdsKeyBase),
|
||||||
|
jsonEncode(map),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears ids once they are delivered — or once a short pick says they were
|
||||||
|
/// never in the box to begin with. Without this the set grows for the life of
|
||||||
|
/// the install and yesterday's run keeps today's cards off Home.
|
||||||
|
Future<void> removeCollectedOrderIds(List<String> ids) async {
|
||||||
|
final drop = ids.where((s) => s.isNotEmpty).toSet();
|
||||||
|
if (drop.isEmpty) return;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final remaining =
|
||||||
|
(prefs.getStringList(await _scopedKey(_kCollectedOrderIdsKeyBase)) ?? [])
|
||||||
|
.where((id) => !drop.contains(id))
|
||||||
|
.toList();
|
||||||
|
await prefs.setStringList(
|
||||||
|
await _scopedKey(_kCollectedOrderIdsKeyBase),
|
||||||
|
remaining,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// OUT FOR DELIVERY — the load has been released and the round has begun
|
||||||
|
//
|
||||||
|
// ── Why this is separate from `collected` ──
|
||||||
|
//
|
||||||
|
// Collected means "in my hands". It is written the moment a source hands over
|
||||||
|
// a crate, and on a two-kitchen morning the rider is collected-but-not-driving
|
||||||
|
// for the better part of an hour.
|
||||||
|
//
|
||||||
|
// If collection alone opened the delivery list, his round would build itself
|
||||||
|
// underneath him: he finishes kitchen one, five drops appear, he sets off, and
|
||||||
|
// kitchen two's five arrive behind him — a route he has already half-driven
|
||||||
|
// past. So the round is held until the whole load is aboard and then released
|
||||||
|
// in one gesture, which is what the rider means when he presses START DELIVERY.
|
||||||
|
//
|
||||||
|
// That press is also a real server event — `POST /miler/deliveries/start` moves
|
||||||
|
// the consignments to `Out_for_Delivery`, which is the state the delivery route
|
||||||
|
// requires — so this set is a mirror of a server fact, not a substitute for
|
||||||
|
// one. It exists because the queue endpoints are a poll behind the rider and he
|
||||||
|
// must not watch his round appear late.
|
||||||
|
const String _kOutForDeliveryKeyBase = 'out_for_delivery_order_ids';
|
||||||
|
|
||||||
|
/// Order ids the rider is actively delivering.
|
||||||
|
Future<Set<String>> getOutForDeliveryOrderIds() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return (prefs.getStringList(await _scopedKey(_kOutForDeliveryKeyBase)) ?? [])
|
||||||
|
.toSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases the round. Called once, with the whole load, after the server has
|
||||||
|
/// confirmed the transition.
|
||||||
|
Future<void> addOutForDeliveryOrderIds(List<String> ids) async {
|
||||||
|
final clean = ids.where((s) => s.isNotEmpty).toSet();
|
||||||
|
if (clean.isEmpty) return;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final existing =
|
||||||
|
(prefs.getStringList(await _scopedKey(_kOutForDeliveryKeyBase)) ?? [])
|
||||||
|
.toSet();
|
||||||
|
existing.addAll(clean);
|
||||||
|
await prefs.setStringList(
|
||||||
|
await _scopedKey(_kOutForDeliveryKeyBase),
|
||||||
|
existing.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears ids once they are delivered. Without this the set grows for the life
|
||||||
|
/// of the install and yesterday's round keeps today's cards on the delivery
|
||||||
|
/// list — the same trap [removeCollectedOrderIds] exists to avoid.
|
||||||
|
Future<void> removeOutForDeliveryOrderIds(List<String> ids) async {
|
||||||
|
final drop = ids.where((s) => s.isNotEmpty).toSet();
|
||||||
|
if (drop.isEmpty) return;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final remaining =
|
||||||
|
(prefs.getStringList(await _scopedKey(_kOutForDeliveryKeyBase)) ?? [])
|
||||||
|
.where((id) => !drop.contains(id))
|
||||||
|
.toList();
|
||||||
|
await prefs.setStringList(
|
||||||
|
await _scopedKey(_kOutForDeliveryKeyBase),
|
||||||
|
remaining,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// NOT LOADED — orders the kitchen could not supply
|
||||||
|
//
|
||||||
|
// Nine bags where the manifest said ten. The tenth is not skipped (nobody was
|
||||||
|
// visited), not cancelled (the customer did nothing wrong) and not delivered —
|
||||||
|
// it never entered the rider's box, and the only useful thing the app can do is
|
||||||
|
// take it off his route immediately and say why, rather than let him drive to a
|
||||||
|
// door at 12:50 for a meal that does not exist.
|
||||||
|
const String _kNotLoadedKeyBase = 'not_loaded_orders';
|
||||||
|
|
||||||
|
Future<Set<String>> getNotLoadedOrderIds() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return (prefs.getStringList(await _scopedKey(_kNotLoadedKeyBase)) ?? [])
|
||||||
|
.toSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addNotLoadedOrderIds(List<String> ids) async {
|
||||||
|
final clean = ids.where((s) => s.isNotEmpty).toSet();
|
||||||
|
if (clean.isEmpty) return;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final existing =
|
||||||
|
(prefs.getStringList(await _scopedKey(_kNotLoadedKeyBase)) ?? []).toSet();
|
||||||
|
existing.addAll(clean);
|
||||||
|
await prefs.setStringList(
|
||||||
|
await _scopedKey(_kNotLoadedKeyBase),
|
||||||
|
existing.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// BAG IDENTITY — which bag an order travels in, for as long as it travels
|
||||||
|
//
|
||||||
|
// ── Why this is stored rather than recomputed ──
|
||||||
|
//
|
||||||
|
// A bag number is the order's position in the load it came off — Joe is Bag 1
|
||||||
|
// of the five that left Vidhya Kitchen. Recomputing that later from whatever
|
||||||
|
// list happens to be on screen is how identity breaks: deliver Joe, and a
|
||||||
|
// position-derived Arun silently becomes Bag 1. The rider is then looking for a
|
||||||
|
// bag labelled 1 that is in a customer's hallway.
|
||||||
|
//
|
||||||
|
// So the pairing is fixed once, at the counter, at the moment the manifest is
|
||||||
|
// confirmed — and read back unchanged through delivery, skip, completion and a
|
||||||
|
// day of intermittent signal. See [BagManifest], which derives it; this only
|
||||||
|
// remembers what it derived.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
const String _kBagLabelsKeyBase = 'order_bag_labels';
|
||||||
|
|
||||||
|
/// Remembers which bag each order travels in. Merges, never replaces: a rider
|
||||||
|
/// works two kitchens and the second load must not erase the first.
|
||||||
|
Future<void> saveBagLabels(Map<String, String> byOrderId) async {
|
||||||
|
final clean = {
|
||||||
|
for (final e in byOrderId.entries)
|
||||||
|
if (e.key.trim().isNotEmpty && e.value.trim().isNotEmpty)
|
||||||
|
e.key.trim(): e.value.trim(),
|
||||||
|
};
|
||||||
|
if (clean.isEmpty) return;
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final merged = {...await getBagLabels(), ...clean};
|
||||||
|
// Stored as `id\u0000label` rows: SharedPreferences has no map type, and a
|
||||||
|
// NUL separator cannot collide with an order id or a bag label.
|
||||||
|
await prefs.setStringList(await _scopedKey(_kBagLabelsKeyBase), [
|
||||||
|
for (final e in merged.entries) '${e.key}\u0000${e.value}',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bag each order is in, as recorded at pickup. Empty before any load.
|
||||||
|
Future<Map<String, String>> getBagLabels() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final rows =
|
||||||
|
prefs.getStringList(await _scopedKey(_kBagLabelsKeyBase)) ??
|
||||||
|
const <String>[];
|
||||||
|
return {
|
||||||
|
for (final row in rows)
|
||||||
|
if (row.contains('\u0000'))
|
||||||
|
row.split('\u0000').first: row.split('\u0000').last,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears every trace of a day's service run.
|
||||||
|
///
|
||||||
|
/// Both sets above are keyed by order id with no date on them, so without this
|
||||||
|
/// a rider who never finished yesterday's last drop would find today's Home
|
||||||
|
/// quietly hiding an unrelated order that happened to reuse the id.
|
||||||
|
Future<void> clearServiceRunState() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(await _scopedKey(_kCollectedOrderIdsKeyBase));
|
||||||
|
await prefs.remove(await _scopedKey(_kOutForDeliveryKeyBase));
|
||||||
|
await prefs.remove(await _scopedKey(_kNotLoadedKeyBase));
|
||||||
|
await prefs.remove(await _scopedKey(_kBagLabelsKeyBase));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// PURGING THE OLD DEMO LAYER OFF A DEVICE
|
||||||
|
//
|
||||||
|
// The mock data is gone from the source, and that is not enough. Every store
|
||||||
|
// above is SharedPreferences, and a phone that ran a build with the demo layer
|
||||||
|
// still has those rows on it — the accepted-store key is literally
|
||||||
|
// `mock_accepted_bookings`. Bookings merges the accepted store into its list
|
||||||
|
// and Activity reads the completed and skipped ones, so the demo stops keep
|
||||||
|
// appearing on exactly those two tabs with nothing in the repo producing them.
|
||||||
|
// Deleting the generator cannot reach data it already wrote.
|
||||||
|
//
|
||||||
|
// So this runs once at startup and drops anything the old layer left behind.
|
||||||
|
// It is keyed on the ids that layer used — `MOCK-Q-1001`, `del-mock-d-2003` —
|
||||||
|
// which is safe because a real order id comes from the backend and has never
|
||||||
|
// looked like that. A blanket wipe would take the rider's genuine accepted
|
||||||
|
// bookings with it.
|
||||||
|
const List<String> _demoIdMarkers = <String>['mock-', 'demo-'];
|
||||||
|
|
||||||
|
bool _looksLikeDemoRecord(Map<String, dynamic> booking) {
|
||||||
|
for (final key in const ['orderid', 'pickupid', 'orderheaderid']) {
|
||||||
|
final v = (booking[key] ?? '').toString().toLowerCase();
|
||||||
|
if (v.isEmpty) continue;
|
||||||
|
for (final marker in _demoIdMarkers) {
|
||||||
|
if (v.startsWith(marker) || v.contains('-$marker')) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _looksLikeDemoId(String id) {
|
||||||
|
final v = id.toLowerCase();
|
||||||
|
return _demoIdMarkers.any((m) => v.startsWith(m) || v.contains('-$m'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes every record the retired demo layer wrote, from all six stores.
|
||||||
|
///
|
||||||
|
/// Returns how many were dropped, so a one-off cleanup is visible in the log
|
||||||
|
/// rather than being a silent mutation of the rider's data.
|
||||||
|
Future<int> purgeDemoRecords() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
var dropped = 0;
|
||||||
|
|
||||||
|
for (final key in [
|
||||||
|
await _scopedKey(_kAcceptedBookingsKeyBase),
|
||||||
|
await _scopedKey(_kCompletedBookingsKeyBase),
|
||||||
|
await _scopedKey(_kSkippedBookingsKeyBase),
|
||||||
|
]) {
|
||||||
|
final existing = _decode(prefs.getString(key));
|
||||||
|
if (existing.isEmpty) continue;
|
||||||
|
final kept = existing.where((b) => !_looksLikeDemoRecord(b)).toList();
|
||||||
|
if (kept.length != existing.length) {
|
||||||
|
dropped += existing.length - kept.length;
|
||||||
|
await prefs.setString(key, jsonEncode(kept));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final key in [
|
||||||
|
await _scopedKey(_kRejectedOrderIdsKeyBase),
|
||||||
|
await _scopedKey(_kCollectedOrderIdsKeyBase),
|
||||||
|
await _scopedKey(_kOutForDeliveryKeyBase),
|
||||||
|
await _scopedKey(_kNotLoadedKeyBase),
|
||||||
|
]) {
|
||||||
|
final existing = prefs.getStringList(key) ?? const <String>[];
|
||||||
|
if (existing.isEmpty) continue;
|
||||||
|
final kept = existing.where((id) => !_looksLikeDemoId(id)).toList();
|
||||||
|
if (kept.length != existing.length) {
|
||||||
|
dropped += existing.length - kept.length;
|
||||||
|
await prefs.setStringList(key, kept);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bag map is keyed by order id, so it needs the same sweep — otherwise a
|
||||||
|
// demo day leaves `MOCK-M-1001 → Bag 1` behind for the life of the install.
|
||||||
|
// Harmless on its own, but this store exists to be the one place that knows
|
||||||
|
// which bag an order is in, and a stale entry is exactly the kind of thing
|
||||||
|
// that is trusted later precisely because it is stored.
|
||||||
|
final bags =
|
||||||
|
prefs.getStringList(await _scopedKey(_kBagLabelsKeyBase)) ??
|
||||||
|
const <String>[];
|
||||||
|
if (bags.isNotEmpty) {
|
||||||
|
final kept = bags
|
||||||
|
.where((row) => !_looksLikeDemoId(row.split('\u0000').first))
|
||||||
|
.toList();
|
||||||
|
if (kept.length != bags.length) {
|
||||||
|
dropped += bags.length - kept.length;
|
||||||
|
await prefs.setStringList(await _scopedKey(_kBagLabelsKeyBase), kept);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dropped > 0) {
|
||||||
|
debugPrint('[STORE] purged $dropped demo record(s) left by an old build');
|
||||||
|
}
|
||||||
|
return dropped;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'package:miler/data/api_status.dart';
|
||||||
|
import 'package:miler/data/consignment_state.dart';
|
||||||
|
import 'package:miler/data/route_order.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
/// Base URL, bearer token, and the adapter that turns a v1 booking into the
|
/// Base URL, bearer token, and the adapter that turns a v1 booking into the
|
||||||
@@ -59,6 +65,62 @@ class ApiConfig {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WHAT THE TOKEN ALREADY SAYS ABOUT THE RIDER
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The `tenantid` claim carried in the bearer token, or 0 when there is none.
|
||||||
|
///
|
||||||
|
/// ── Why the app reads its own token ──
|
||||||
|
///
|
||||||
|
/// The rider's tenant decides his entire operational mode, and the server has
|
||||||
|
/// always known it — `GenerateToken` signs `tenantid` into every miler JWT.
|
||||||
|
/// What it did *not* do was put it in the `verify-pin` response body, so the
|
||||||
|
/// app was told the answer and could not hear it: the login carried tenant 13
|
||||||
|
/// in its token and a body with no tenant at all, and every rider resolved to
|
||||||
|
/// the fallback line.
|
||||||
|
///
|
||||||
|
/// The handler now returns it too, but a deployed backend is not the same
|
||||||
|
/// thing as a merged one, and the app should not need a release to be
|
||||||
|
/// redeployed alongside. The claim is the same fact from the same source —
|
||||||
|
/// signed by the server, not asserted by the client — so reading it closes
|
||||||
|
/// the gap without waiting on anything.
|
||||||
|
///
|
||||||
|
/// ── What this is not ──
|
||||||
|
///
|
||||||
|
/// It is **not** a security decision and must never become one. The signature
|
||||||
|
/// is not verified here — the app has no key and does not need one, because
|
||||||
|
/// every request is still authorised server-side by the same token. A rider
|
||||||
|
/// who edited this claim would change which screens his own phone draws and
|
||||||
|
/// nothing else; the API would keep answering for the tenant it verified.
|
||||||
|
///
|
||||||
|
/// Returns 0 for a missing, malformed or unparseable token rather than
|
||||||
|
/// throwing: an unreadable token must fall through to the other signals, not
|
||||||
|
/// take the app down at launch.
|
||||||
|
static int tenantIdFromToken(String? token) {
|
||||||
|
if (token == null || token.isEmpty) return 0;
|
||||||
|
try {
|
||||||
|
final parts = token.split('.');
|
||||||
|
if (parts.length != 3) return 0;
|
||||||
|
// JWT uses base64url without padding; `base64Url.decode` demands it.
|
||||||
|
String payload = parts[1];
|
||||||
|
payload += '=' * ((4 - payload.length % 4) % 4);
|
||||||
|
final decoded = json.decode(utf8.decode(base64Url.decode(payload)));
|
||||||
|
if (decoded is! Map) return 0;
|
||||||
|
final raw = decoded['tenantid'];
|
||||||
|
if (raw is int) return raw;
|
||||||
|
if (raw is num) return raw.toInt();
|
||||||
|
return int.tryParse(raw?.toString() ?? '') ?? 0;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[AUTH] could not read tenant from token: $e');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored session's tenant claim. See [tenantIdFromToken].
|
||||||
|
static Future<int> storedTenantId() async =>
|
||||||
|
tenantIdFromToken(await getToken());
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Response envelope adapter: {success,data,message} -> {status,details}
|
// Response envelope adapter: {success,data,message} -> {status,details}
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -94,26 +156,95 @@ class ApiConfig {
|
|||||||
// / picked / skipped / cancelled / rejected
|
// / picked / skipped / cancelled / rejected
|
||||||
|
|
||||||
static String legacyStatusFromNew(String? newStatus) {
|
static String legacyStatusFromNew(String? newStatus) {
|
||||||
switch ((newStatus ?? '').trim()) {
|
// ── Parsed, not string-matched ──
|
||||||
case 'Miler_Assigned':
|
//
|
||||||
// Admin-assigned but NOT yet accepted by the rider. This must land on
|
// This was a `switch` on raw strings whose `default` returned the value
|
||||||
// the Home tab as a pending booking to accept/reject — it becomes
|
// unchanged, so two things went wrong quietly. `Pending_Pickup` and
|
||||||
// 'accepted' (a client-side marker in accepted_store) only once the
|
// `Created` were not listed at all and fell through as themselves, and a
|
||||||
// rider accepts it, which is what moves it to the Bookings tab.
|
// status added by the backend tomorrow would do the same — arriving in the
|
||||||
return 'assigned';
|
// UI as an unrecognised string that the row logic then had to guess at.
|
||||||
case 'Pickup_Scheduled':
|
//
|
||||||
return 'active';
|
// [BookingStatus] covers the contract exhaustively and folds everything
|
||||||
case 'At_Customer':
|
// else into `unknown`, which maps to the empty string here: a stop the app
|
||||||
return 'arrived';
|
// cannot classify renders as undecided, never as picked, cancelled or
|
||||||
case 'Picked_Up':
|
// otherwise finished. Wrong-but-safe beats wrong-and-settled.
|
||||||
return 'Picked up';
|
return switch (BookingStatus.parse(newStatus)) {
|
||||||
case 'Converted_To_Consignment':
|
// Admin-assigned but NOT yet accepted by the rider. These land on Home as
|
||||||
return 'picked';
|
// pending bookings to accept or reject; the client-side accepted marker
|
||||||
case 'Cancelled':
|
// is what moves one to the work tab.
|
||||||
return 'cancelled';
|
BookingStatus.pendingPickup ||
|
||||||
default:
|
BookingStatus.created ||
|
||||||
return newStatus ?? '';
|
BookingStatus.milerAssigned => 'assigned',
|
||||||
|
// ── `Pickup_Scheduled` is the ACCEPTED rung, not the arrived one ──
|
||||||
|
//
|
||||||
|
// It is the status the backend writes when the rider accepts an
|
||||||
|
// assignment — the flow doc calls it "the pickup is on their route", and
|
||||||
|
// the console maps it to *accepted* for exactly that reason.
|
||||||
|
//
|
||||||
|
// This mapped it to `active`, which every row in this app reads as *the
|
||||||
|
// rider is physically on the stop* (see `stopStateOf`, where a raw
|
||||||
|
// `active` outranks the local accepted record). The effect was that
|
||||||
|
// accepting skipped a whole rung: the moment the queue came back, the
|
||||||
|
// stop reported as arrived, and selecting it offered **Mark as Picked**
|
||||||
|
// for a kitchen the rider had not reached yet. Arrival — the one rung
|
||||||
|
// that has a real endpoint behind it, `reached` — could not be recorded
|
||||||
|
// at all, so the hub never saw it.
|
||||||
|
//
|
||||||
|
// The two applications were reading one status two different ways. This
|
||||||
|
// is the app's half of that; the console's half already said accepted.
|
||||||
|
BookingStatus.pickupScheduled => 'accepted',
|
||||||
|
// The rung `reached` writes. It was only ever reachable through the
|
||||||
|
// undocumented `At_Customer` spelling, handled here as a special case
|
||||||
|
// ahead of the parse; `Arrived_At_Pickup` is the contract name and both
|
||||||
|
// now come through [BookingStatus].
|
||||||
|
BookingStatus.arrivedAtPickup => 'arrived',
|
||||||
|
BookingStatus.pickedUp => 'Picked up',
|
||||||
|
BookingStatus.convertedToConsignment => 'picked',
|
||||||
|
// ── Past the boundary, and it has to say so ──
|
||||||
|
//
|
||||||
|
// A hyperlocal booking is released for delivery by `pickup-complete`
|
||||||
|
// itself, so this is the status most collected DailyGrubs orders carry.
|
||||||
|
// It fell through as `unknown` → '' → *undecided*, which put a bag
|
||||||
|
// already in the rider's box back on Home as work to accept. See
|
||||||
|
// [BookingStatus.outForDelivery].
|
||||||
|
BookingStatus.outForDelivery => 'outfordelivery',
|
||||||
|
BookingStatus.delivered => 'delivered',
|
||||||
|
BookingStatus.cancelled => 'cancelled',
|
||||||
|
BookingStatus.unknown => '',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The delivery half of the same translation.
|
||||||
|
///
|
||||||
|
/// ── Why a second mapper exists ──
|
||||||
|
///
|
||||||
|
/// A booking's story ends at `Converted_To_Consignment`. From there the work
|
||||||
|
/// belongs to a different object with a different vocabulary, and the app
|
||||||
|
/// had no way to see it on a list: every collected stop — in the box, on the
|
||||||
|
/// road, handed over an hour ago — reported the same terminal booking word.
|
||||||
|
/// That is why a delivered stop kept sitting on the Deliveries tab until
|
||||||
|
/// something asked its consignment directly, one round trip per stop.
|
||||||
|
///
|
||||||
|
/// Since 21 Aug 2026 `GET /miler/bookings` carries `consignmentstatus` on
|
||||||
|
/// every row, so the list itself answers it.
|
||||||
|
///
|
||||||
|
/// Returns `''` for the hub-side states (`Created`, `Inwarded_at_Hub`,
|
||||||
|
/// `Tripsheet_Loaded`, `In_Transit`) and for anything unrecognised — the
|
||||||
|
/// caller then keeps the booking's own word. A hub-side consignment is not
|
||||||
|
/// this rider's to act on and has no rung on his card; inventing one would
|
||||||
|
/// put a parcel in somebody else's warehouse on his screen.
|
||||||
|
static String legacyStatusFromConsignment(Object? raw) {
|
||||||
|
return switch (consignmentStateFromRaw(raw)) {
|
||||||
|
// Collected and in the rider's hands. Same rung the booking's
|
||||||
|
// `Converted_To_Consignment` produces — but now it is the consignment
|
||||||
|
// itself saying so.
|
||||||
|
ConsignmentState.collectedByMiler => 'picked',
|
||||||
|
ConsignmentState.outForDelivery => 'outfordelivery',
|
||||||
|
ConsignmentState.delivered => 'delivered',
|
||||||
|
ConsignmentState.cancelled ||
|
||||||
|
ConsignmentState.returnedToSender => 'cancelled',
|
||||||
|
_ => '',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -124,6 +255,9 @@ class ApiConfig {
|
|||||||
// pickuplat/pickuplong, dropaddress/droplat/droplon, pickupcustomer,
|
// pickuplat/pickuplong, dropaddress/droplat/droplon, pickupcustomer,
|
||||||
// pickupcontactno, collectionamt, step, type ...). Translate a new `booking`
|
// pickupcontactno, collectionamt, step, type ...). Translate a new `booking`
|
||||||
// object into that shape so the existing cards/flows render unchanged.
|
// object into that shape so the existing cards/flows render unchanged.
|
||||||
|
/// The sequence spellings this adapter looks for, in [RouteOrder]'s order.
|
||||||
|
static const List<String> sequenceFieldNames = RouteOrder.sequenceKeys;
|
||||||
|
|
||||||
static Map<String, dynamic> pickupFromBooking(Map booking) {
|
static Map<String, dynamic> pickupFromBooking(Map booking) {
|
||||||
String s(dynamic v) => v == null ? '' : v.toString();
|
String s(dynamic v) => v == null ? '' : v.toString();
|
||||||
// First non-null value among several candidate keys — makes the adapter
|
// First non-null value among several candidate keys — makes the adapter
|
||||||
@@ -168,6 +302,21 @@ class ApiConfig {
|
|||||||
'orderstatus',
|
'orderstatus',
|
||||||
'state',
|
'state',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// ── The consignment outranks the booking, when there is one ──
|
||||||
|
//
|
||||||
|
// Not a preference — a correction. `Converted_To_Consignment` is where the
|
||||||
|
// booking stops being informative, so if the row also reports where the
|
||||||
|
// consignment has got to, that is the newer fact and the one the card must
|
||||||
|
// draw. Falls back to the booking's word whenever the consignment says
|
||||||
|
// nothing this rider can act on. See [legacyStatusFromConsignment].
|
||||||
|
final consignmentStatus = pick([
|
||||||
|
'consignmentstatus',
|
||||||
|
'consignmentStatus',
|
||||||
|
'consignment_status',
|
||||||
|
]);
|
||||||
|
final fromConsignment = legacyStatusFromConsignment(consignmentStatus);
|
||||||
|
|
||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
// identity
|
// identity
|
||||||
'pickupid': id,
|
'pickupid': id,
|
||||||
@@ -177,7 +326,12 @@ class ApiConfig {
|
|||||||
'bookingreference': s(ref),
|
'bookingreference': s(ref),
|
||||||
|
|
||||||
// status
|
// status
|
||||||
'orderstatus': legacyStatusFromNew(s(status)),
|
'orderstatus': fromConsignment.isNotEmpty
|
||||||
|
? fromConsignment
|
||||||
|
: legacyStatusFromNew(s(status)),
|
||||||
|
// Kept raw alongside, so anything that needs the consignment's own word
|
||||||
|
// reads it rather than inferring it back out of the legacy one.
|
||||||
|
'consignmentstatus': s(consignmentStatus),
|
||||||
|
|
||||||
// pickup side
|
// pickup side
|
||||||
'pickupcustomer': s(
|
'pickupcustomer': s(
|
||||||
@@ -201,6 +355,9 @@ class ApiConfig {
|
|||||||
'pickupaddress': s(
|
'pickupaddress': s(
|
||||||
pick(['pickupaddress', 'pickupAddress', 'pickup_address']),
|
pick(['pickupaddress', 'pickupAddress', 'pickup_address']),
|
||||||
),
|
),
|
||||||
|
'pickuppincode': s(
|
||||||
|
pick(['pickuppincode', 'pickupPincode', 'pickup_pincode']),
|
||||||
|
),
|
||||||
'pickuplat': s(
|
'pickuplat': s(
|
||||||
pick([
|
pick([
|
||||||
'pickuplatitude',
|
'pickuplatitude',
|
||||||
@@ -232,6 +389,9 @@ class ApiConfig {
|
|||||||
'dropaddress': s(
|
'dropaddress': s(
|
||||||
pick(['deliveryaddress', 'deliveryAddress', 'delivery_address']),
|
pick(['deliveryaddress', 'deliveryAddress', 'delivery_address']),
|
||||||
),
|
),
|
||||||
|
'droppincode': s(
|
||||||
|
pick(['deliverypincode', 'deliveryPincode', 'delivery_pincode']),
|
||||||
|
),
|
||||||
'droplat': s(
|
'droplat': s(
|
||||||
pick(['deliverylatitude', 'deliveryLatitude', 'delivery_latitude']),
|
pick(['deliverylatitude', 'deliveryLatitude', 'delivery_latitude']),
|
||||||
),
|
),
|
||||||
@@ -239,9 +399,80 @@ class ApiConfig {
|
|||||||
pick(['deliverylongitude', 'deliveryLongitude', 'delivery_longitude']),
|
pick(['deliverylongitude', 'deliveryLongitude', 'delivery_longitude']),
|
||||||
),
|
),
|
||||||
|
|
||||||
// stop type — new bookings are first-mile PICKUPS; delivery legs come
|
// ── Where this stop is collected FROM ──
|
||||||
// through the consignment flow. Backend has no per-stop `type` yet.
|
//
|
||||||
'type': 'pickup',
|
// On a milk run the rider works two or three sources in a morning and
|
||||||
|
// Home groups his stops under one heading per source, with the bulk
|
||||||
|
// collect belonging to that group. `stopSourceId` / `stopSourceName` read
|
||||||
|
// exactly these keys — and this adapter builds a fixed map, so a field it
|
||||||
|
// does not name is a field the UI can never see, however faithfully the
|
||||||
|
// backend sends it. That was the bug: every milk-run stop grouped into
|
||||||
|
// one nameless pile.
|
||||||
|
//
|
||||||
|
// Empty on a logistics booking, which is collected from a customer's door
|
||||||
|
// rather than from a source. Nothing is invented when the keys are
|
||||||
|
// absent: an empty string groups as "no source", which is the truth.
|
||||||
|
'sourceid': s(
|
||||||
|
pick([
|
||||||
|
'sourceid',
|
||||||
|
'sourceId',
|
||||||
|
'source_id',
|
||||||
|
'kitchenid',
|
||||||
|
'kitchenId',
|
||||||
|
'pickuplocationid',
|
||||||
|
'pickupLocationId',
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
'sourcename': s(
|
||||||
|
pick([
|
||||||
|
'sourcename',
|
||||||
|
'sourceName',
|
||||||
|
'source_name',
|
||||||
|
'kitchenname',
|
||||||
|
'kitchenName',
|
||||||
|
'providerlocation',
|
||||||
|
'providercompany',
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
'pickuplocationid': s(
|
||||||
|
pick(['pickuplocationid', 'pickupLocationId', 'pickup_location_id']),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Present once the booking has been converted. It is what the delivery
|
||||||
|
// route keys on, so a milk-run drop cannot be closed without it.
|
||||||
|
'consignmentid': s(
|
||||||
|
pick(['consignmentid', 'consignmentId', 'consignment_id']),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── The hub's solved position in the route ──
|
||||||
|
//
|
||||||
|
// Verified live 21 Aug 2026: `GET /miler/bookings` carries `step` on
|
||||||
|
// every row. This adapter builds a **fixed map**, so a field it does not
|
||||||
|
// name is a field the UI can never see however faithfully the backend
|
||||||
|
// sends it — and `step` was not named. The delivery leg therefore had no
|
||||||
|
// sequence at all and fell back to ordering by distance, which is the
|
||||||
|
// app re-planning a route the hub had already solved.
|
||||||
|
//
|
||||||
|
// `0` means *not sequenced* and is passed through as such; see
|
||||||
|
// [RouteOrder.sequenceOf], which treats it as "no answer", never as
|
||||||
|
// position zero.
|
||||||
|
'step': pick(sequenceFieldNames) ?? 0,
|
||||||
|
|
||||||
|
// ── Which leg this stop is ──
|
||||||
|
//
|
||||||
|
// Also live, also previously hardcoded: every row came through as
|
||||||
|
// `'pickup'` because "backend has no per-stop type yet". It does now —
|
||||||
|
// 23 of this rider's 29 rows say `delivery`.
|
||||||
|
'type': s(pick(['stoptype', 'stopType', 'stop_type'])).isEmpty
|
||||||
|
? 'pickup'
|
||||||
|
: s(pick(['stoptype', 'stopType', 'stop_type'])).toLowerCase(),
|
||||||
|
|
||||||
|
// Route estimates, straight from the assignment. Zero until the hub's
|
||||||
|
// optimizer has run — the app shows its own estimate in that case and
|
||||||
|
// says so rather than drawing a confident 0.
|
||||||
|
'etaminutes': pick(['etaminutes', 'etaMinutes']) ?? 0,
|
||||||
|
'cumulativekms': pick(['cumulativekms', 'cumulativeKms']) ?? 0,
|
||||||
|
'cumulativeeta': pick(['cumulativeeta', 'cumulativeEta']) ?? 0,
|
||||||
|
|
||||||
// money — NOT provided by the new booking object yet (see gaps doc)
|
// money — NOT provided by the new booking object yet (see gaps doc)
|
||||||
'collectionamt': booking['collectionamt'] ?? 0,
|
'collectionamt': booking['collectionamt'] ?? 0,
|
||||||
|
|||||||
270
lib/data/api_status.dart
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// THE CONTRACT'S OWN VOCABULARY
|
||||||
|
///
|
||||||
|
/// Assignment, booking and consignment statuses arrive as free-form strings and
|
||||||
|
/// were compared as string literals wherever a screen needed one. That is the
|
||||||
|
/// same mistake [StopStatus] was written to fix on the read side of the parcel
|
||||||
|
/// flow, one layer further out: case-sensitivity landmines, variant spellings,
|
||||||
|
/// and — the expensive one — **an unrecognised value silently taking the
|
||||||
|
/// success branch** because the check was `!= 'Cancelled'`.
|
||||||
|
///
|
||||||
|
/// ── Unknown is a value, not a crash and not a success ──
|
||||||
|
///
|
||||||
|
/// The backend will add statuses this build has never heard of. Every enum here
|
||||||
|
/// therefore carries an [unknown] member and parses by *exact match on a
|
||||||
|
/// normalised string*, so a new server value lands on `unknown` and the screens
|
||||||
|
/// treat it as "not something I can act on" rather than as delivered, accepted
|
||||||
|
/// or complete. `values.byName`-style lookups and `firstWhere` without an
|
||||||
|
/// `orElse` both throw; neither is used.
|
||||||
|
///
|
||||||
|
/// The raw string is kept alongside, because a status this build cannot model
|
||||||
|
/// is still something the rider and the hub can read.
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// `Assigned_To_Miler` / `assigned to miler` / `ASSIGNED-TO-MILER` all reduce
|
||||||
|
/// to one key, so a spelling drift on the wire is not a behaviour change here.
|
||||||
|
String _key(Object? raw) => (raw?.toString() ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll(RegExp(r'[\s\-]+'), '_');
|
||||||
|
|
||||||
|
/// What the hub has done with an offer of work.
|
||||||
|
enum AssignmentStatus {
|
||||||
|
assigned,
|
||||||
|
accepted,
|
||||||
|
rejected,
|
||||||
|
reassigned,
|
||||||
|
completed,
|
||||||
|
cancelled,
|
||||||
|
unknown;
|
||||||
|
|
||||||
|
static const _byKey = <String, AssignmentStatus>{
|
||||||
|
'assigned': AssignmentStatus.assigned,
|
||||||
|
'accepted': AssignmentStatus.accepted,
|
||||||
|
'rejected': AssignmentStatus.rejected,
|
||||||
|
'reassigned': AssignmentStatus.reassigned,
|
||||||
|
'completed': AssignmentStatus.completed,
|
||||||
|
'cancelled': AssignmentStatus.cancelled,
|
||||||
|
};
|
||||||
|
|
||||||
|
static AssignmentStatus parse(Object? raw) =>
|
||||||
|
_byKey[_key(raw)] ?? AssignmentStatus.unknown;
|
||||||
|
|
||||||
|
/// The rider still owes this one a decision.
|
||||||
|
bool get needsDecision => this == AssignmentStatus.assigned;
|
||||||
|
|
||||||
|
/// He has taken it on and it is not finished.
|
||||||
|
bool get isLive => this == AssignmentStatus.accepted;
|
||||||
|
|
||||||
|
/// Nothing further will happen here. **`unknown` is deliberately not
|
||||||
|
/// settled** — a status this build cannot read must not be filed as done.
|
||||||
|
bool get isSettled =>
|
||||||
|
this == AssignmentStatus.rejected ||
|
||||||
|
this == AssignmentStatus.reassigned ||
|
||||||
|
this == AssignmentStatus.completed ||
|
||||||
|
this == AssignmentStatus.cancelled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pickup, from the moment it exists to the moment it becomes a consignment.
|
||||||
|
enum BookingStatus {
|
||||||
|
pendingPickup,
|
||||||
|
created,
|
||||||
|
milerAssigned,
|
||||||
|
pickupScheduled,
|
||||||
|
|
||||||
|
/// The rider is standing at the pickup address.
|
||||||
|
///
|
||||||
|
/// `POST /miler/bookings/:id/reached` writes this — but until 21 Aug 2026 it
|
||||||
|
/// wrote nothing the app could observe, so **I've arrived** appeared to do
|
||||||
|
/// nothing and the console never showed the rung. The backend now persists
|
||||||
|
/// it under this name. `At_Customer` is the older spelling seen in the wild
|
||||||
|
/// and means the same thing; both parse here.
|
||||||
|
arrivedAtPickup,
|
||||||
|
|
||||||
|
pickedUp,
|
||||||
|
convertedToConsignment,
|
||||||
|
|
||||||
|
/// ── The hyperlocal short-circuit ──
|
||||||
|
///
|
||||||
|
/// `pickup-complete` decides routing from the two pincodes: matching 3-digit
|
||||||
|
/// prefixes are hyperlocal and the parcel goes **straight to
|
||||||
|
/// `Out_for_Delivery`** instead of routing via a hub. Every DailyGrubs run is
|
||||||
|
/// hyperlocal, so this is not an edge case on that line — it is the status a
|
||||||
|
/// collected meal actually carries.
|
||||||
|
///
|
||||||
|
/// It was missing from this enum, and the cost was precise: [parse] answered
|
||||||
|
/// [unknown], which the legacy translation maps to the empty string, which
|
||||||
|
/// reads as *undecided* — so a bag already in the rider's box came back from
|
||||||
|
/// the queue looking like work he had not accepted yet. The local collected
|
||||||
|
/// record hid it on the device that did the pickup and nowhere else: a
|
||||||
|
/// restart, a reinstall or a second device showed collected orders sitting on
|
||||||
|
/// Home as pending.
|
||||||
|
outForDelivery,
|
||||||
|
|
||||||
|
/// Handed over. Terminal.
|
||||||
|
delivered,
|
||||||
|
|
||||||
|
cancelled,
|
||||||
|
unknown;
|
||||||
|
|
||||||
|
static const _byKey = <String, BookingStatus>{
|
||||||
|
'pending_pickup': BookingStatus.pendingPickup,
|
||||||
|
'created': BookingStatus.created,
|
||||||
|
'miler_assigned': BookingStatus.milerAssigned,
|
||||||
|
'pickup_scheduled': BookingStatus.pickupScheduled,
|
||||||
|
'arrived_at_pickup': BookingStatus.arrivedAtPickup,
|
||||||
|
'at_customer': BookingStatus.arrivedAtPickup,
|
||||||
|
'picked_up': BookingStatus.pickedUp,
|
||||||
|
'converted_to_consignment': BookingStatus.convertedToConsignment,
|
||||||
|
// Spelled `Out_for_Delivery` on bookings — lower-case `f`, unlike the
|
||||||
|
// consignment enum's `Out_For_Delivery`. `_key` lower-cases before lookup
|
||||||
|
// so both land here, which is deliberate: the difference is a backend
|
||||||
|
// inconsistency, not a distinction, and no caller should have to know it.
|
||||||
|
'out_for_delivery': BookingStatus.outForDelivery,
|
||||||
|
'delivered': BookingStatus.delivered,
|
||||||
|
'cancelled': BookingStatus.cancelled,
|
||||||
|
};
|
||||||
|
|
||||||
|
static BookingStatus parse(Object? raw) =>
|
||||||
|
_byKey[_key(raw)] ?? BookingStatus.unknown;
|
||||||
|
|
||||||
|
/// Collection has happened — the pickup-to-delivery boundary has been
|
||||||
|
/// crossed, server-side.
|
||||||
|
///
|
||||||
|
/// `pickup-complete` is the pivot: it converts the booking into a consignment
|
||||||
|
/// and, on a hyperlocal run, releases it for delivery in the same call. Every
|
||||||
|
/// rung from there on counts, including [delivered] — a delivered order was
|
||||||
|
/// certainly collected, and a predicate that said otherwise would put a
|
||||||
|
/// finished stop back in the pickup domain.
|
||||||
|
///
|
||||||
|
/// This is what [WorkBoundary] reads. It must never include a rung before the
|
||||||
|
/// hand-over: an acceptance is a decision about work still to be done.
|
||||||
|
bool get isCollected =>
|
||||||
|
this == BookingStatus.pickedUp ||
|
||||||
|
this == BookingStatus.convertedToConsignment ||
|
||||||
|
this == BookingStatus.outForDelivery ||
|
||||||
|
this == BookingStatus.delivered;
|
||||||
|
|
||||||
|
/// The rider still has work to do at this address.
|
||||||
|
bool get isOpen =>
|
||||||
|
this == BookingStatus.pendingPickup ||
|
||||||
|
this == BookingStatus.created ||
|
||||||
|
this == BookingStatus.milerAssigned ||
|
||||||
|
this == BookingStatus.pickupScheduled ||
|
||||||
|
this == BookingStatus.arrivedAtPickup;
|
||||||
|
|
||||||
|
/// ── Cancellation is refused once picked up ──
|
||||||
|
///
|
||||||
|
/// The server enforces it; this is the client half, so the control is not
|
||||||
|
/// offered in a state where pressing it can only fail.
|
||||||
|
bool get canCancel => isOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A consignment, from the hub's point of view.
|
||||||
|
enum ConsignmentStatus {
|
||||||
|
created,
|
||||||
|
inwardedAtHub,
|
||||||
|
tripsheetLoaded,
|
||||||
|
inTransit,
|
||||||
|
outForDelivery,
|
||||||
|
delivered,
|
||||||
|
rtoInitiated,
|
||||||
|
returnedToSender,
|
||||||
|
missing,
|
||||||
|
damaged,
|
||||||
|
unknown;
|
||||||
|
|
||||||
|
static const _byKey = <String, ConsignmentStatus>{
|
||||||
|
'created': ConsignmentStatus.created,
|
||||||
|
'inwarded_at_hub': ConsignmentStatus.inwardedAtHub,
|
||||||
|
'tripsheet_loaded': ConsignmentStatus.tripsheetLoaded,
|
||||||
|
'in_transit': ConsignmentStatus.inTransit,
|
||||||
|
'out_for_delivery': ConsignmentStatus.outForDelivery,
|
||||||
|
'delivered': ConsignmentStatus.delivered,
|
||||||
|
'rto_initiated': ConsignmentStatus.rtoInitiated,
|
||||||
|
'returned_to_sender': ConsignmentStatus.returnedToSender,
|
||||||
|
'missing': ConsignmentStatus.missing,
|
||||||
|
'damaged': ConsignmentStatus.damaged,
|
||||||
|
};
|
||||||
|
|
||||||
|
static ConsignmentStatus parse(Object? raw) =>
|
||||||
|
_byKey[_key(raw)] ?? ConsignmentStatus.unknown;
|
||||||
|
|
||||||
|
/// The one state `deliver` and `skip` are legal from — anything else is a
|
||||||
|
/// 400. Offering the control elsewhere is offering a guaranteed failure.
|
||||||
|
bool get isDeliverable => this == ConsignmentStatus.outForDelivery;
|
||||||
|
|
||||||
|
/// Handed over. Only this one.
|
||||||
|
bool get isDelivered => this == ConsignmentStatus.delivered;
|
||||||
|
|
||||||
|
/// Going back, or gone. Not failures the rider caused, and not states he can
|
||||||
|
/// work out of on this screen.
|
||||||
|
bool get isReturning =>
|
||||||
|
this == ConsignmentStatus.rtoInitiated ||
|
||||||
|
this == ConsignmentStatus.returnedToSender;
|
||||||
|
|
||||||
|
/// Something is wrong with the parcel itself and the hub owns it now.
|
||||||
|
bool get isException =>
|
||||||
|
this == ConsignmentStatus.missing || this == ConsignmentStatus.damaged;
|
||||||
|
|
||||||
|
/// Nothing further happens on the rider's phone. **`unknown` is excluded** —
|
||||||
|
/// see the note at the top of this file.
|
||||||
|
bool get isClosed => isDelivered || isReturning || isException;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the rider is doing, as the availability endpoint understands it.
|
||||||
|
///
|
||||||
|
/// `Break`, not `On_Break`: the obvious guess is the wrong one, and it is the
|
||||||
|
/// value the server validates against.
|
||||||
|
enum RiderAvailability {
|
||||||
|
offline,
|
||||||
|
available,
|
||||||
|
assigned,
|
||||||
|
onPickup,
|
||||||
|
atCustomer,
|
||||||
|
pickedUp,
|
||||||
|
onDelivery,
|
||||||
|
onBreak,
|
||||||
|
blocked,
|
||||||
|
unknown;
|
||||||
|
|
||||||
|
static const _byKey = <String, RiderAvailability>{
|
||||||
|
'offline': RiderAvailability.offline,
|
||||||
|
'available': RiderAvailability.available,
|
||||||
|
'assigned': RiderAvailability.assigned,
|
||||||
|
'on_pickup': RiderAvailability.onPickup,
|
||||||
|
'at_customer': RiderAvailability.atCustomer,
|
||||||
|
'picked_up': RiderAvailability.pickedUp,
|
||||||
|
'on_delivery': RiderAvailability.onDelivery,
|
||||||
|
'break': RiderAvailability.onBreak,
|
||||||
|
'blocked': RiderAvailability.blocked,
|
||||||
|
};
|
||||||
|
|
||||||
|
static RiderAvailability parse(Object? raw) =>
|
||||||
|
_byKey[_key(raw)] ?? RiderAvailability.unknown;
|
||||||
|
|
||||||
|
/// The exact string this value is sent back as. Named separately from the
|
||||||
|
/// Dart member so `onBreak` can carry the wire's `Break` without the enum
|
||||||
|
/// having a member called `break`, which is a keyword.
|
||||||
|
String get wire => switch (this) {
|
||||||
|
RiderAvailability.offline => 'Offline',
|
||||||
|
RiderAvailability.available => 'Available',
|
||||||
|
RiderAvailability.assigned => 'Assigned',
|
||||||
|
RiderAvailability.onPickup => 'On_Pickup',
|
||||||
|
RiderAvailability.atCustomer => 'At_Customer',
|
||||||
|
RiderAvailability.pickedUp => 'Picked_Up',
|
||||||
|
RiderAvailability.onDelivery => 'On_Delivery',
|
||||||
|
RiderAvailability.onBreak => 'Break',
|
||||||
|
RiderAvailability.blocked => 'Blocked',
|
||||||
|
// Never sent. A value this build cannot model must not be echoed back to
|
||||||
|
// the server as though it were understood.
|
||||||
|
RiderAvailability.unknown => 'Offline',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// On duty in any sense — anything but offline, blocked, or unreadable.
|
||||||
|
bool get isWorking =>
|
||||||
|
this != RiderAvailability.offline &&
|
||||||
|
this != RiderAvailability.blocked &&
|
||||||
|
this != RiderAvailability.unknown;
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
import 'package:miler/data/api_config.dart';
|
import 'package:miler/data/miler_api.dart';
|
||||||
|
|
||||||
/// Resolves a BOOKING id into the BOOKING ASSIGNMENT id that the accept/reject
|
/// Resolves a BOOKING id into the BOOKING ASSIGNMENT id that the accept/reject
|
||||||
/// endpoints key on.
|
/// endpoints key on.
|
||||||
@@ -21,6 +19,27 @@ class AssignmentLookup {
|
|||||||
|
|
||||||
/// bookingid (as string) -> bookingassignmentid
|
/// bookingid (as string) -> bookingassignmentid
|
||||||
static final Map<String, int> _cache = <String, int>{};
|
static final Map<String, int> _cache = <String, int>{};
|
||||||
|
|
||||||
|
/// bookingid (as string) -> the hub's solved stop number.
|
||||||
|
///
|
||||||
|
/// ── Why the sequence lives here and not on the booking ──
|
||||||
|
///
|
||||||
|
/// `step` is written onto **`bookingassignments`**, not onto the booking: the
|
||||||
|
/// hub's batch-assign endpoint sends each affected rider's whole active set to
|
||||||
|
/// the route optimizer and writes the returned road-network order back onto
|
||||||
|
/// the assignment rows. So `GET /miler/bookings` — the app's one read of the
|
||||||
|
/// day — cannot carry it, and this endpoint is the only place it exists.
|
||||||
|
///
|
||||||
|
/// It was being thrown away. This class fetched the assignment rows for their
|
||||||
|
/// ids and dropped everything else, so `Trip.sortStops` — which documents
|
||||||
|
/// `step` as authoritative and says the app must never second-guess the
|
||||||
|
/// admin's order — never saw one and fell back to booked time on every run.
|
||||||
|
/// The rider was choosing his own order while the hub believed it had solved
|
||||||
|
/// one for him.
|
||||||
|
///
|
||||||
|
/// `step: 0` means *not sequenced*, never *first*, and is not stored.
|
||||||
|
static final Map<String, int> _steps = <String, int>{};
|
||||||
|
|
||||||
static DateTime? _fetchedAt;
|
static DateTime? _fetchedAt;
|
||||||
|
|
||||||
/// Assignments change whenever the hub assigns work, so the map goes stale
|
/// Assignments change whenever the hub assigns work, so the map goes stale
|
||||||
@@ -41,9 +60,22 @@ class AssignmentLookup {
|
|||||||
|
|
||||||
static void invalidate() {
|
static void invalidate() {
|
||||||
_cache.clear();
|
_cache.clear();
|
||||||
|
_steps.clear();
|
||||||
_fetchedAt = null;
|
_fetchedAt = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The hub's stop order, by booking id, refreshed on the same TTL as the ids.
|
||||||
|
///
|
||||||
|
/// Best-effort by contract: sequencing is a separate service and the flow doc
|
||||||
|
/// is explicit that it being down must leave bookings *assigned but
|
||||||
|
/// unordered* rather than undo anything. An empty map therefore means "no
|
||||||
|
/// solved order available", which the sort reads as "fall back to booked
|
||||||
|
/// time" — not as "every stop is step 0".
|
||||||
|
static Future<Map<String, int>> steps() async {
|
||||||
|
if (_isStale) await _refresh();
|
||||||
|
return Map<String, int>.unmodifiable(_steps);
|
||||||
|
}
|
||||||
|
|
||||||
/// The assignment id for [bookingId], or null when the backend has no
|
/// The assignment id for [bookingId], or null when the backend has no
|
||||||
/// assignment row for it (or the call fails).
|
/// assignment row for it (or the call fails).
|
||||||
///
|
///
|
||||||
@@ -67,22 +99,21 @@ class AssignmentLookup {
|
|||||||
|
|
||||||
static Future<void> _refresh() async {
|
static Future<void> _refresh() async {
|
||||||
try {
|
try {
|
||||||
final uri = Uri.parse(ApiConfig.url('/miler/assignments'));
|
// Through [MilerApi], not a hand-rolled `http.get`. It was the latter,
|
||||||
final res = await http
|
// which meant this one call carried its own header building, its own
|
||||||
.get(uri, headers: await ApiConfig.authHeaders())
|
// envelope unwrapping and its own idea of what a 2xx is — and, because it
|
||||||
.timeout(const Duration(seconds: 15));
|
// bypassed `MilerApi.client`, it was the only request in the app that a
|
||||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
// test could not stub, so the suite made real network calls to the
|
||||||
debugPrint('[ASSIGNMENTS] fetch failed: HTTP ${res.statusCode}');
|
// production API while checking a repository.
|
||||||
|
final res = await MilerApi.assignments();
|
||||||
|
if (!res.ok) {
|
||||||
|
debugPrint('[ASSIGNMENTS] fetch failed: HTTP ${res.status}');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final decoded = json.decode(res.body);
|
final List<dynamic> data = res.list;
|
||||||
dynamic data = decoded;
|
if (data.isEmpty && res.data is! List) {
|
||||||
if (decoded is Map) {
|
debugPrint('[ASSIGNMENTS] no assignment rows in the response');
|
||||||
data = decoded['data'] ?? decoded['details'] ?? decoded['assignments'];
|
|
||||||
}
|
|
||||||
if (data is! List) {
|
|
||||||
debugPrint('[ASSIGNMENTS] unexpected payload: ${data.runtimeType}');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,9 +121,14 @@ class AssignmentLookup {
|
|||||||
_cache
|
_cache
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(next);
|
..addAll(next);
|
||||||
|
final nextSteps = buildStepIndex(data);
|
||||||
|
_steps
|
||||||
|
..clear()
|
||||||
|
..addAll(nextSteps);
|
||||||
_fetchedAt = DateTime.now();
|
_fetchedAt = DateTime.now();
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[ASSIGNMENTS] cached ${_cache.length} booking->assignment ids',
|
'[ASSIGNMENTS] cached ${_cache.length} booking->assignment ids, '
|
||||||
|
'${_steps.length} sequenced',
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[ASSIGNMENTS] fetch error: $e');
|
debugPrint('[ASSIGNMENTS] fetch error: $e');
|
||||||
@@ -131,6 +167,44 @@ class AssignmentLookup {
|
|||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Collapses the assignment rows into one bookingid -> step entry.
|
||||||
|
///
|
||||||
|
/// Same newest-first, actionable-wins rule as [buildIndex] — a booking that
|
||||||
|
/// was assigned, rejected and reassigned must take the live assignment's
|
||||||
|
/// sequence, not the rejected corpse's — so the two indexes cannot describe
|
||||||
|
/// different assignment rows for the same booking.
|
||||||
|
@visibleForTesting
|
||||||
|
static Map<String, int> buildStepIndex(List<dynamic> rows) {
|
||||||
|
final index = <String, int>{};
|
||||||
|
final tookActionable = <String>{};
|
||||||
|
|
||||||
|
for (final row in rows.whereType<Map>()) {
|
||||||
|
final bookingKey = row['bookingid']?.toString().trim() ?? '';
|
||||||
|
if (bookingKey.isEmpty) continue;
|
||||||
|
|
||||||
|
final step = _asInt(row['step']) ?? 0;
|
||||||
|
final status = (row['assignmentstatus'] ?? '').toString().trim();
|
||||||
|
final isActionable = _actionable.contains(status);
|
||||||
|
|
||||||
|
final seen =
|
||||||
|
index.containsKey(bookingKey) || tookActionable.contains(bookingKey);
|
||||||
|
if (seen && !(isActionable && !tookActionable.contains(bookingKey))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isActionable) tookActionable.add(bookingKey);
|
||||||
|
|
||||||
|
// 0 is "not sequenced". Storing it would make an unsequenced stop look
|
||||||
|
// like it had been solved into position zero.
|
||||||
|
if (step > 0) {
|
||||||
|
index[bookingKey] = step;
|
||||||
|
} else {
|
||||||
|
index.remove(bookingKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
static int? _asInt(dynamic v) {
|
static int? _asInt(dynamic v) {
|
||||||
if (v is int) return v;
|
if (v is int) return v;
|
||||||
if (v is num) return v.toInt();
|
if (v is num) return v.toInt();
|
||||||
|
|||||||
124
lib/data/bag_manifest.dart
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import 'package:miler/data/milk_run.dart';
|
||||||
|
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||||||
|
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// ONE PICKUP ORDER = ONE BAG
|
||||||
|
///
|
||||||
|
/// The rule the whole rider workflow rests on, in one file so it cannot be
|
||||||
|
/// stated two different ways on two screens.
|
||||||
|
///
|
||||||
|
/// If a kitchen has five pickup orders, the rider is handed **five bags** — one
|
||||||
|
/// per order — and ends up with **five deliveries**, each carrying the bag it
|
||||||
|
/// arrived in:
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// Order 1 → Bag 1 → Joe
|
||||||
|
/// Order 2 → Bag 2 → Arun
|
||||||
|
/// Order 3 → Bag 3 → Priya
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ── Why this is a file and not a `+ 1` at each call site ──
|
||||||
|
///
|
||||||
|
/// Every screen in the pickup half of the app has to answer "how many bags?"
|
||||||
|
/// and "which bag is this?", and every screen that answered it independently
|
||||||
|
/// answered it differently: one showed a backend `Quantity` (an order's item
|
||||||
|
/// count, not a bag count), one showed a bare `baglabel` when the payload
|
||||||
|
/// happened to carry one and nothing at all when it did not, and the kitchen
|
||||||
|
/// heading counted "meals". A rider standing at a counter comparing "5 meals"
|
||||||
|
/// on his phone with four bags on the shelf has no way to tell which of the two
|
||||||
|
/// numbers is wrong.
|
||||||
|
///
|
||||||
|
/// So the count is **derived from the orders**, always, and there is no second
|
||||||
|
/// quantity anywhere that can disagree with it.
|
||||||
|
///
|
||||||
|
/// ── What is derived, and what is never invented ──
|
||||||
|
///
|
||||||
|
/// • **The count** is `orders.length`. It cannot drift, because it is not
|
||||||
|
/// stored — a bag is what an order arrives in.
|
||||||
|
/// • **The identity** prefers the label the backend printed on the physical
|
||||||
|
/// bag ([stopBagLabel]) and falls back to the order's **position in its own
|
||||||
|
/// pickup group** — `Bag 1`, `Bag 2` — which is what a rider counting a
|
||||||
|
/// shelf actually uses.
|
||||||
|
///
|
||||||
|
/// Nothing here fabricates a crate, a tote or a quantity the backend has not
|
||||||
|
/// sent. If a future contract ever puts more than one bag on an order, this is
|
||||||
|
/// the one file that changes.
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
class BagManifest {
|
||||||
|
BagManifest._();
|
||||||
|
|
||||||
|
/// One line of a pickup manifest: the order, the customer it is for, and the
|
||||||
|
/// bag it travels in.
|
||||||
|
///
|
||||||
|
/// Deliberately carries the stop itself: every caller that renders a manifest
|
||||||
|
/// also needs to act on the orders behind it, and pairing them here is what
|
||||||
|
/// stops a screen from rendering five lines and posting four ids.
|
||||||
|
static List<BagLine> forGroup(List<Map<String, dynamic>> stops) => [
|
||||||
|
for (var i = 0; i < stops.length; i++)
|
||||||
|
BagLine(
|
||||||
|
stop: stops[i],
|
||||||
|
orderId: MilkRun.idOf(stops[i]),
|
||||||
|
customer: customerOf(stops[i]),
|
||||||
|
bag: _bagFor(stops[i], i),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The bag one order travels in, given the group it was collected with.
|
||||||
|
///
|
||||||
|
/// [stops] must be the whole pickup group in route order — the position in it
|
||||||
|
/// *is* the bag number when the backend has not printed one.
|
||||||
|
static String bagFor(
|
||||||
|
Map<String, dynamic> stop,
|
||||||
|
List<Map<String, dynamic>> group,
|
||||||
|
) {
|
||||||
|
final id = MilkRun.idOf(stop);
|
||||||
|
final index = group.indexWhere((s) => MilkRun.idOf(s) == id);
|
||||||
|
return _bagFor(stop, index < 0 ? 0 : index);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _bagFor(Map<String, dynamic> stop, int index) {
|
||||||
|
final printed = stopBagLabel(stop);
|
||||||
|
return printed.isNotEmpty ? printed : 'Bag ${index + 1}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `5 bags` — the load, in the unit the rider actually carries.
|
||||||
|
///
|
||||||
|
/// It used to print `5 orders · 5 bags`: the two halves of the one-bag-per-
|
||||||
|
/// order rule side by side, as a check the rider could eyeball. On a device
|
||||||
|
/// that check cost the header the fact it exists for — a real kitchen name
|
||||||
|
/// plus `13 orders · 13 bags` pushed `11 to accept` off the row, and the
|
||||||
|
/// clause that truncated was the one that changes what he does next.
|
||||||
|
///
|
||||||
|
/// One number survives, and it is the physical one: the rows underneath are
|
||||||
|
/// named `Bag 1 … Bag n` and a settled group says `n bags collected`, so
|
||||||
|
/// "bags" is the word this column already speaks. No information is lost —
|
||||||
|
/// the rule makes the counts identical — and the verifiable statement lives
|
||||||
|
/// where the verifying happens: the manifest list itself, one line per bag.
|
||||||
|
static String countLabel(int orders) {
|
||||||
|
return orders == 1 ? '1 bag' : '$orders bags';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The name the bag is going to, for a manifest line.
|
||||||
|
static String customerOf(Map<String, dynamic> stop) =>
|
||||||
|
(stop['pickupcustomer'] ??
|
||||||
|
stop['customername'] ??
|
||||||
|
stop['tenantname'] ??
|
||||||
|
'')
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of a pickup manifest. See [BagManifest.forGroup].
|
||||||
|
class BagLine {
|
||||||
|
final Map<String, dynamic> stop;
|
||||||
|
final String orderId;
|
||||||
|
final String customer;
|
||||||
|
final String bag;
|
||||||
|
|
||||||
|
const BagLine({
|
||||||
|
required this.stop,
|
||||||
|
required this.orderId,
|
||||||
|
required this.customer,
|
||||||
|
required this.bag,
|
||||||
|
});
|
||||||
|
}
|
||||||
316
lib/data/consignment_state.dart
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'package:miler/data/miler_api.dart';
|
||||||
|
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// THE CONSIGNMENT'S OWN VOCABULARY
|
||||||
|
///
|
||||||
|
/// A booking and a consignment are two different objects with two different
|
||||||
|
/// state machines, and the app has been paying for treating them as one.
|
||||||
|
///
|
||||||
|
/// booking Pending_Pickup → Miler_Assigned → Pickup_Scheduled
|
||||||
|
/// → Picked_Up → **Converted_To_Consignment** | Cancelled
|
||||||
|
///
|
||||||
|
/// consignment Created → Inwarded_at_Hub → Tripsheet_Loaded → In_Transit
|
||||||
|
/// → **Out_for_Delivery** → **Delivered** | RTO | …
|
||||||
|
///
|
||||||
|
/// `Converted_To_Consignment` is where the booking's story ENDS. There is no
|
||||||
|
/// booking `Delivered`, and `GET /miler/bookings` therefore reports the same
|
||||||
|
/// terminal word for a parcel sitting in a hub, a parcel on a rider's bike and
|
||||||
|
/// a parcel handed over an hour ago. Any code that decides "is this stop still
|
||||||
|
/// mine to deliver?" from a booking status is asking the wrong object — which
|
||||||
|
/// is exactly the defect this file exists to close.
|
||||||
|
///
|
||||||
|
/// ── What this is not ──
|
||||||
|
///
|
||||||
|
/// Not a local mirror and not a cache to write into. The consignment's state
|
||||||
|
/// belongs to the server; the only honest way to know it is to ask. Nothing
|
||||||
|
/// here ever *sets* a state — see [ConsignmentGate].
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
enum ConsignmentState {
|
||||||
|
created,
|
||||||
|
inwardedAtHub,
|
||||||
|
tripsheetLoaded,
|
||||||
|
inTransit,
|
||||||
|
|
||||||
|
/// **In the rider's hands, not yet on the road.**
|
||||||
|
///
|
||||||
|
/// Added by the backend on 21 Aug 2026 and it closes the gap this app had
|
||||||
|
/// been modelling locally: `pickup-complete` used to push hyperlocal work
|
||||||
|
/// straight to [outForDelivery], so the hub saw "actively delivering" for
|
||||||
|
/// food still on the kitchen counter and there was no server state meaning
|
||||||
|
/// *collected, holding*. Now the pivot lands here and
|
||||||
|
/// `POST /consignments/:id/start-delivery` makes the release.
|
||||||
|
collectedByMiler,
|
||||||
|
|
||||||
|
/// Released to a rider. **The only state `deliver` accepts.**
|
||||||
|
outForDelivery,
|
||||||
|
|
||||||
|
/// Handed over. Terminal.
|
||||||
|
delivered,
|
||||||
|
|
||||||
|
rtoInitiated,
|
||||||
|
returnedToSender,
|
||||||
|
missing,
|
||||||
|
damaged,
|
||||||
|
cancelled,
|
||||||
|
|
||||||
|
/// The server said something this build does not know. Deliberately not
|
||||||
|
/// deliverable and deliberately not "finished" — an unrecognised state is a
|
||||||
|
/// reason to ask, never a reason to act.
|
||||||
|
unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalises the backend's `eventstatus` / `status` spelling.
|
||||||
|
ConsignmentState consignmentStateFromRaw(dynamic raw) {
|
||||||
|
final s = (raw?.toString() ?? '').trim().toLowerCase().replaceAll(' ', '_');
|
||||||
|
switch (s) {
|
||||||
|
case 'created':
|
||||||
|
return ConsignmentState.created;
|
||||||
|
case 'inwarded_at_hub':
|
||||||
|
return ConsignmentState.inwardedAtHub;
|
||||||
|
case 'tripsheet_loaded':
|
||||||
|
return ConsignmentState.tripsheetLoaded;
|
||||||
|
case 'in_transit':
|
||||||
|
return ConsignmentState.inTransit;
|
||||||
|
case 'collected_by_miler':
|
||||||
|
case 'collectedbymiler':
|
||||||
|
return ConsignmentState.collectedByMiler;
|
||||||
|
case 'out_for_delivery':
|
||||||
|
case 'outfordelivery':
|
||||||
|
return ConsignmentState.outForDelivery;
|
||||||
|
case 'delivered':
|
||||||
|
return ConsignmentState.delivered;
|
||||||
|
case 'rto_initiated':
|
||||||
|
return ConsignmentState.rtoInitiated;
|
||||||
|
case 'returned_to_sender':
|
||||||
|
return ConsignmentState.returnedToSender;
|
||||||
|
case 'missing':
|
||||||
|
return ConsignmentState.missing;
|
||||||
|
case 'damaged':
|
||||||
|
return ConsignmentState.damaged;
|
||||||
|
case 'cancelled':
|
||||||
|
case 'canceled':
|
||||||
|
return ConsignmentState.cancelled;
|
||||||
|
default:
|
||||||
|
return ConsignmentState.unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ConsignmentStateX on ConsignmentState {
|
||||||
|
/// `POST /miler/consignments/:id/deliver` is refused unless the consignment
|
||||||
|
/// is `Out_for_Delivery`. One state, no inference, no other spelling.
|
||||||
|
bool get isDeliverable => this == ConsignmentState.outForDelivery;
|
||||||
|
|
||||||
|
/// Already handed over. The work is done server-side; the rider's device is
|
||||||
|
/// the thing that is behind.
|
||||||
|
bool get isDelivered => this == ConsignmentState.delivered;
|
||||||
|
|
||||||
|
/// Closed for good, one way or another — nothing left for a rider to do.
|
||||||
|
bool get isClosed =>
|
||||||
|
this == ConsignmentState.delivered ||
|
||||||
|
this == ConsignmentState.cancelled ||
|
||||||
|
this == ConsignmentState.returnedToSender;
|
||||||
|
|
||||||
|
/// Collected and waiting on the rider's own **Start round**, not on anyone
|
||||||
|
/// else. Deliberately *not* [awaitsHub]: telling a rider the hub has his
|
||||||
|
/// parcel while it is in his own box is the error this state exists to
|
||||||
|
/// prevent.
|
||||||
|
bool get needsRelease => this == ConsignmentState.collectedByMiler;
|
||||||
|
|
||||||
|
/// Still inside the hub's half of the network. **This is the case the
|
||||||
|
/// "not released yet" guard is for** — a logistics consignment sitting at a
|
||||||
|
/// hub genuinely cannot be delivered by this rider, and must stay blocked.
|
||||||
|
bool get awaitsHub =>
|
||||||
|
this == ConsignmentState.created ||
|
||||||
|
this == ConsignmentState.inwardedAtHub ||
|
||||||
|
this == ConsignmentState.tripsheetLoaded ||
|
||||||
|
this == ConsignmentState.inTransit;
|
||||||
|
|
||||||
|
/// After a successful `skip`, whether the stop is **still the rider's
|
||||||
|
/// problem**.
|
||||||
|
///
|
||||||
|
/// A skip is a failed attempt, not a closed consignment, and what the server
|
||||||
|
/// does with one is the server's business: it may move the consignment to a
|
||||||
|
/// failure state, or leave it `Out_for_Delivery` for a second attempt or an
|
||||||
|
/// RTO decision taken elsewhere. The app cannot tell from the skip's own
|
||||||
|
/// 200, so it reads the consignment afterwards and asks this.
|
||||||
|
///
|
||||||
|
/// **Unknown counts as open.** A read that failed is not permission to
|
||||||
|
/// declare a stop finished — writing a terminal local record over a
|
||||||
|
/// consignment the hub still calls open leaves two systems disagreeing about
|
||||||
|
/// whether a parcel is anyone's problem, with the rider's screen the only
|
||||||
|
/// one saying it is not.
|
||||||
|
bool get isOpenAfterSkip =>
|
||||||
|
isDeliverable || needsRelease || this == ConsignmentState.unknown;
|
||||||
|
|
||||||
|
/// `skip` is accepted from both halves of the rider's custody — the backend
|
||||||
|
/// widened it on 21 Aug 2026 so a failed attempt is reportable the moment
|
||||||
|
/// the parcel is collected, not only once the round has started.
|
||||||
|
bool get canSkip => needsRelease || isDeliverable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the app is allowed to do with a consignment, decided from the
|
||||||
|
/// authoritative server state rather than from a booking row or a local flag.
|
||||||
|
enum DeliverGate {
|
||||||
|
/// `Out_for_Delivery` — post the delivery.
|
||||||
|
deliverable,
|
||||||
|
|
||||||
|
/// `Delivered` — the server already has it. Reconcile locally; do not post
|
||||||
|
/// again and do not show the rider an error for work he completed.
|
||||||
|
alreadyDelivered,
|
||||||
|
|
||||||
|
/// Collected but the round has not been started. The rider unblocks this
|
||||||
|
/// himself — **Start round** on the Deliveries tab.
|
||||||
|
needsRelease,
|
||||||
|
|
||||||
|
/// A real hub-side hold. Block, and say so.
|
||||||
|
awaitingHub,
|
||||||
|
|
||||||
|
/// Closed some other way (cancelled, returned). Not deliverable, not an
|
||||||
|
/// error the rider caused.
|
||||||
|
closed,
|
||||||
|
|
||||||
|
/// The state could not be read — no id, no network, an unparseable answer.
|
||||||
|
/// **Not a block.** A read failure is not evidence of anything, so the
|
||||||
|
/// delivery is attempted and the server remains the judge. Blocking here
|
||||||
|
/// would strand a rider at a door because a GET timed out.
|
||||||
|
unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a consignment's authoritative state and answers what may be done.
|
||||||
|
///
|
||||||
|
/// ── Why this needs a network call at all ──
|
||||||
|
///
|
||||||
|
/// Nothing the rider's device already holds can answer it. `GET /miler/bookings`
|
||||||
|
/// carries the *booking* status (terminal at `Converted_To_Consignment`) and no
|
||||||
|
/// consignment status at all — verified against the live API. The local
|
||||||
|
/// collected/out-for-delivery sets record what the *rider* did on *this*
|
||||||
|
/// handset, which is exactly what a reinstall, a second device or a
|
||||||
|
/// hub-side change makes wrong.
|
||||||
|
///
|
||||||
|
/// `GET /miler/consignments/:consignmentid` reports the current state directly
|
||||||
|
/// — shipped 21 Aug 2026 at this app's request. Before it, the only route that
|
||||||
|
/// carried consignment state was `…/logs/:id`, and reading a state machine
|
||||||
|
/// meant pulling its entire history and sorting it. That still works and
|
||||||
|
/// remains the fallback here, because a rider mid-round on a build that meets
|
||||||
|
/// an older deployment must not be blocked by a 404.
|
||||||
|
class ConsignmentGate {
|
||||||
|
ConsignmentGate._();
|
||||||
|
|
||||||
|
/// Reads the current state of [consignmentId].
|
||||||
|
///
|
||||||
|
/// Returns [ConsignmentState.unknown] on any failure — see [DeliverGate].
|
||||||
|
static Future<ConsignmentState> stateOf(Object consignmentId) async {
|
||||||
|
final id = consignmentId.toString().trim();
|
||||||
|
if (id.isEmpty || id == '0') return ConsignmentState.unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final res = await MilerApi.consignment(id);
|
||||||
|
if (res.ok) {
|
||||||
|
final state = _stateFromDetail(res.data);
|
||||||
|
if (state != ConsignmentState.unknown) {
|
||||||
|
debugPrint('[CONSIGNMENT] $id is ${state.name}');
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debugPrint('[CONSIGNMENT] get $id -> ${res.status} ${res.message}');
|
||||||
|
}
|
||||||
|
// Either the route is not deployed yet, or it answered something this
|
||||||
|
// build cannot read. The history still holds the answer.
|
||||||
|
return _stateFromLogs(id);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[CONSIGNMENT] could not read $id: $e');
|
||||||
|
return ConsignmentState.unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads `GET /miler/consignments/:id`.
|
||||||
|
///
|
||||||
|
/// The response carries both a `status` string and the derived booleans
|
||||||
|
/// (`collected`, `out_for_delivery`, `delivered`, `can_deliver`…). The
|
||||||
|
/// string is preferred: it is the state itself, whereas the flags are the
|
||||||
|
/// server's opinion *about* the state and can be extended independently.
|
||||||
|
/// The flags are only consulted when the string is a word this build has
|
||||||
|
/// never heard of — and there, `delivered` first, because mistaking a
|
||||||
|
/// completed delivery for an open one is the failure that makes a rider
|
||||||
|
/// re-post work he has already done.
|
||||||
|
static ConsignmentState _stateFromDetail(Map<String, dynamic> data) {
|
||||||
|
if (data.isEmpty) return ConsignmentState.unknown;
|
||||||
|
|
||||||
|
final raw = data['consignmentstatus'] ?? data['status'] ?? data['state'];
|
||||||
|
final named = consignmentStateFromRaw(raw);
|
||||||
|
if (named != ConsignmentState.unknown) return named;
|
||||||
|
|
||||||
|
bool flag(String key) => data[key] == true || '${data[key]}' == 'true';
|
||||||
|
|
||||||
|
// State flags first — they describe where the consignment *is*.
|
||||||
|
if (flag('delivered')) return ConsignmentState.delivered;
|
||||||
|
if (flag('out_for_delivery')) return ConsignmentState.outForDelivery;
|
||||||
|
if (flag('collected')) return ConsignmentState.collectedByMiler;
|
||||||
|
|
||||||
|
// Then the permission flags, which describe what may be *done*. A weaker
|
||||||
|
// signal — `can_deliver` is the server having already decided the answer
|
||||||
|
// this app derives from the state — but a far better one than giving up:
|
||||||
|
// `unknown` blocks the Start delivery bar and makes the door guess.
|
||||||
|
if (flag('can_deliver')) return ConsignmentState.outForDelivery;
|
||||||
|
if (flag('can_start_delivery')) return ConsignmentState.collectedByMiler;
|
||||||
|
return ConsignmentState.unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pre-21-Aug-2026 read: the whole history, newest row wins.
|
||||||
|
static Future<ConsignmentState> _stateFromLogs(String id) async {
|
||||||
|
final res = await MilerApi.consignmentLogs(id);
|
||||||
|
if (!res.ok) {
|
||||||
|
debugPrint('[CONSIGNMENT] logs $id -> ${res.status} ${res.message}');
|
||||||
|
return ConsignmentState.unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rows arrive oldest-first; the state is whatever happened last. Sorted
|
||||||
|
// on `historyid` rather than trusting arrival order, because a state
|
||||||
|
// machine read out of order is worse than not read.
|
||||||
|
final rows = <Map<String, dynamic>>[
|
||||||
|
for (final r in res.list)
|
||||||
|
if (r is Map) r.map((k, v) => MapEntry(k.toString(), v)),
|
||||||
|
];
|
||||||
|
if (rows.isEmpty) return ConsignmentState.unknown;
|
||||||
|
|
||||||
|
rows.sort((a, b) {
|
||||||
|
final ai = int.tryParse('${a['historyid'] ?? 0}') ?? 0;
|
||||||
|
final bi = int.tryParse('${b['historyid'] ?? 0}') ?? 0;
|
||||||
|
if (ai != bi) return ai.compareTo(bi);
|
||||||
|
return (a['createdat'] ?? '').toString().compareTo(
|
||||||
|
(b['createdat'] ?? '').toString(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final state = consignmentStateFromRaw(
|
||||||
|
rows.last['eventstatus'] ?? rows.last['status'],
|
||||||
|
);
|
||||||
|
debugPrint('[CONSIGNMENT] $id is ${state.name} (from logs)');
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [_stateFromDetail], reachable from a test.
|
||||||
|
///
|
||||||
|
/// The flag-reading path is the one that runs when the backend adds a state
|
||||||
|
/// this build has never heard of — the case that cannot be produced by
|
||||||
|
/// naming a status, and is exactly the case worth pinning.
|
||||||
|
@visibleForTesting
|
||||||
|
static ConsignmentState stateFromDetailForTest(Map<String, dynamic> data) =>
|
||||||
|
_stateFromDetail(data);
|
||||||
|
|
||||||
|
/// Maps a state to what the delivery flow may do about it.
|
||||||
|
static DeliverGate gateFor(ConsignmentState state) {
|
||||||
|
if (state.isDeliverable) return DeliverGate.deliverable;
|
||||||
|
if (state.isDelivered) return DeliverGate.alreadyDelivered;
|
||||||
|
if (state.needsRelease) return DeliverGate.needsRelease;
|
||||||
|
if (state.awaitsHub) return DeliverGate.awaitingHub;
|
||||||
|
if (state.isClosed) return DeliverGate.closed;
|
||||||
|
return DeliverGate.unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: read and classify in one call.
|
||||||
|
static Future<DeliverGate> gateOf(Object consignmentId) async =>
|
||||||
|
gateFor(await stateOf(consignmentId));
|
||||||
|
}
|
||||||
276
lib/data/lifecycle.dart
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'package:miler/data/api_status.dart';
|
||||||
|
import 'package:miler/data/consignment_state.dart';
|
||||||
|
import 'package:miler/data/miler_api.dart';
|
||||||
|
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// WHAT THE SERVER ACTUALLY CONFIRMED
|
||||||
|
///
|
||||||
|
/// Every rung this app draws — Accepted, Arrived, Picked, Active, Delivered —
|
||||||
|
/// is a claim about what the **hub** believes. The app had no way to check
|
||||||
|
/// that claim: a mutation returned `ok`, the screen advanced, and whether the
|
||||||
|
/// office could see the same thing was never asked.
|
||||||
|
///
|
||||||
|
/// ── The bug that made this a file ──
|
||||||
|
///
|
||||||
|
/// Verified against production on 21 Aug 2026, booking 78:
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// BEFORE GET /miler/bookings status = Miler_Assigned
|
||||||
|
/// CALL POST /miler/bookings/78/reached
|
||||||
|
/// → 200 {"success":true,"data":{"bookingid":78,
|
||||||
|
/// "status":"Miler_Assigned"}}
|
||||||
|
/// AFTER GET /miler/bookings status = Miler_Assigned
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// `reached` **returns success and writes nothing.** It echoes the booking's
|
||||||
|
/// current status. So the rider pressed *I've arrived*, the app got `ok: true`,
|
||||||
|
/// drew ARRIVED, and the hub never heard about it — not because the app failed
|
||||||
|
/// to call, but because a 200 was taken as proof of a transition that never
|
||||||
|
/// happened.
|
||||||
|
///
|
||||||
|
/// ── The rule ──
|
||||||
|
///
|
||||||
|
/// A 200 is proof the call was accepted. It is **not** proof of a state
|
||||||
|
/// change. The only proof of a state change is the state coming back in the
|
||||||
|
/// response. So every lifecycle mutation is read through this file, which
|
||||||
|
/// answers three separate questions the caller used to conflate:
|
||||||
|
///
|
||||||
|
/// • did the call succeed? → [ApiResult.ok]
|
||||||
|
/// • did the state actually move? → [TransitionOutcome.confirmed]
|
||||||
|
/// • what is the state now? → [bookingStatus] / [consignmentState]
|
||||||
|
///
|
||||||
|
/// Nothing here blocks a rider. A backend that does not record his arrival is
|
||||||
|
/// the backend's fault, and stranding him at a kitchen over it would turn one
|
||||||
|
/// broken endpoint into a stopped operation. What it does is stop the app
|
||||||
|
/// *claiming* the hub agrees when it demonstrably does not — the claim is
|
||||||
|
/// downgraded, logged with the evidence, and surfaced.
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
enum TransitionOutcome {
|
||||||
|
/// The response carries the state the call was supposed to produce. The rung
|
||||||
|
/// the rider sees is the rung the office sees.
|
||||||
|
confirmed,
|
||||||
|
|
||||||
|
/// The call succeeded and the state did **not** move — or moved somewhere
|
||||||
|
/// the response does not name. The rider may carry on; the app must not
|
||||||
|
/// pretend the hub knows. See [MilerLifecycle.reached].
|
||||||
|
unconfirmed,
|
||||||
|
|
||||||
|
/// The server refused. A real failure with a reason.
|
||||||
|
refused,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One lifecycle mutation, read for what it proves.
|
||||||
|
@immutable
|
||||||
|
class StateTransition {
|
||||||
|
const StateTransition({
|
||||||
|
required this.outcome,
|
||||||
|
required this.bookingStatus,
|
||||||
|
required this.consignmentState,
|
||||||
|
this.consignmentId = '',
|
||||||
|
this.nextAction = '',
|
||||||
|
this.code = '',
|
||||||
|
this.message = '',
|
||||||
|
this.evidence = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
final TransitionOutcome outcome;
|
||||||
|
|
||||||
|
/// The booking status the response reported, parsed. [BookingStatus.unknown]
|
||||||
|
/// when the response named none.
|
||||||
|
final BookingStatus bookingStatus;
|
||||||
|
|
||||||
|
/// The consignment state the response reported, parsed.
|
||||||
|
final ConsignmentState consignmentState;
|
||||||
|
|
||||||
|
/// The id minted at the pivot, when this transition was one.
|
||||||
|
final String consignmentId;
|
||||||
|
|
||||||
|
/// The server's own instruction for what happens next — `start_delivery`,
|
||||||
|
/// `inward_at_hub`. Read, never assumed.
|
||||||
|
final String nextAction;
|
||||||
|
|
||||||
|
/// The stable failure code, for callers that must branch on *why*.
|
||||||
|
final String code;
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
/// What the response actually said, for a log line that can be pasted into a
|
||||||
|
/// backend ticket without re-running anything.
|
||||||
|
final String evidence;
|
||||||
|
|
||||||
|
bool get isConfirmed => outcome == TransitionOutcome.confirmed;
|
||||||
|
bool get isUnconfirmed => outcome == TransitionOutcome.unconfirmed;
|
||||||
|
bool get isRefused => outcome == TransitionOutcome.refused;
|
||||||
|
|
||||||
|
/// True when the pivot left the consignment in the rider's hands awaiting a
|
||||||
|
/// **Start delivery** press — the post-flag lifecycle.
|
||||||
|
bool get awaitsStartDelivery =>
|
||||||
|
consignmentState.needsRelease || nextAction == 'start_delivery';
|
||||||
|
|
||||||
|
/// True when the pivot released the consignment itself, which is the
|
||||||
|
/// pre-flag lifecycle the backend calls compatibility mode.
|
||||||
|
///
|
||||||
|
/// **This is the reason Admin shows Active for a stop the rider just
|
||||||
|
/// picked.** Not a client bug and not something the app may paper over: the
|
||||||
|
/// consignment really is out for delivery, and a rider told otherwise would
|
||||||
|
/// be looking at a different truth from his office.
|
||||||
|
bool get isCompatibilityMode =>
|
||||||
|
consignmentState.isDeliverable && nextAction != 'start_delivery';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads lifecycle mutations. Pure — no I/O of its own.
|
||||||
|
abstract final class MilerLifecycle {
|
||||||
|
/// First non-empty value among [keys], searched shallow then one level in.
|
||||||
|
static String _str(Map<String, dynamic> data, List<String> keys) {
|
||||||
|
for (final k in keys) {
|
||||||
|
final v = data[k];
|
||||||
|
if (v == null || v is Map || v is List) continue;
|
||||||
|
final s = v.toString().trim();
|
||||||
|
if (s.isNotEmpty && s != 'null' && s != '0') return s;
|
||||||
|
}
|
||||||
|
for (final v in data.values) {
|
||||||
|
if (v is Map) {
|
||||||
|
final nested = v.map((k, x) => MapEntry(k.toString(), x));
|
||||||
|
final found = _str(nested, keys);
|
||||||
|
if (found.isNotEmpty) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /miler/bookings/:id/reached`.
|
||||||
|
///
|
||||||
|
/// Confirmed **only** when the response reports
|
||||||
|
/// [BookingStatus.arrivedAtPickup]. Anything else — including the 200 that
|
||||||
|
/// echoes the unchanged status, which is what production returns today — is
|
||||||
|
/// [TransitionOutcome.unconfirmed], and the evidence says exactly what came
|
||||||
|
/// back so the report writes itself.
|
||||||
|
static StateTransition reached(ApiResult res) {
|
||||||
|
if (!res.ok) {
|
||||||
|
return StateTransition(
|
||||||
|
outcome: TransitionOutcome.refused,
|
||||||
|
bookingStatus: BookingStatus.unknown,
|
||||||
|
consignmentState: ConsignmentState.unknown,
|
||||||
|
code: res.code,
|
||||||
|
message: res.message,
|
||||||
|
evidence: 'HTTP ${res.status} ${res.code} ${res.message}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final raw = _str(res.data, const [
|
||||||
|
'status',
|
||||||
|
'bookingstatus',
|
||||||
|
'booking_status',
|
||||||
|
]);
|
||||||
|
final parsed = BookingStatus.parse(raw);
|
||||||
|
final confirmed = parsed == BookingStatus.arrivedAtPickup;
|
||||||
|
|
||||||
|
return StateTransition(
|
||||||
|
outcome: confirmed
|
||||||
|
? TransitionOutcome.confirmed
|
||||||
|
: TransitionOutcome.unconfirmed,
|
||||||
|
bookingStatus: parsed,
|
||||||
|
consignmentState: ConsignmentState.unknown,
|
||||||
|
evidence: raw.isEmpty
|
||||||
|
? 'the response named no status at all'
|
||||||
|
: 'the response reported status="$raw"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /miler/bookings/:id/pickup-complete`.
|
||||||
|
///
|
||||||
|
/// The pivot has two legitimate outcomes and the app must not choose between
|
||||||
|
/// them from a build-time assumption:
|
||||||
|
///
|
||||||
|
/// `Collected_By_Miler` + `next_action: start_delivery`
|
||||||
|
/// the rider holds it; **Start delivery** releases it.
|
||||||
|
/// `Out_for_Delivery`
|
||||||
|
/// compatibility mode — the pivot released it in the same call.
|
||||||
|
///
|
||||||
|
/// Both are confirmed transitions. Which one happened is [isCompatibilityMode],
|
||||||
|
/// read from the response and never from a flag mirrored into this app.
|
||||||
|
static StateTransition pickupComplete(ApiResult res) {
|
||||||
|
if (!res.ok) {
|
||||||
|
return StateTransition(
|
||||||
|
outcome: TransitionOutcome.refused,
|
||||||
|
bookingStatus: BookingStatus.unknown,
|
||||||
|
consignmentState: ConsignmentState.unknown,
|
||||||
|
code: res.code,
|
||||||
|
message: res.message,
|
||||||
|
evidence: 'HTTP ${res.status} ${res.code} ${res.message}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final bookingRaw = _str(res.data, const [
|
||||||
|
'booking_status',
|
||||||
|
'bookingstatus',
|
||||||
|
'status',
|
||||||
|
]);
|
||||||
|
final consignmentRaw = _str(res.data, const [
|
||||||
|
'consignmentstatus',
|
||||||
|
'consignment_status',
|
||||||
|
'consignmentstate',
|
||||||
|
]);
|
||||||
|
final id = _str(res.data, const [
|
||||||
|
'consignment_id',
|
||||||
|
'consignmentid',
|
||||||
|
'consignmentId',
|
||||||
|
'consignmentno',
|
||||||
|
]);
|
||||||
|
final next = _str(res.data, const [
|
||||||
|
'next_action',
|
||||||
|
'nextaction',
|
||||||
|
]).toLowerCase();
|
||||||
|
|
||||||
|
final booking = BookingStatus.parse(bookingRaw);
|
||||||
|
var consignment = consignmentStateFromRaw(consignmentRaw);
|
||||||
|
|
||||||
|
// A response that names only the booking still answers the question when
|
||||||
|
// the booking word is one of the two that carries the delivery half.
|
||||||
|
if (consignment == ConsignmentState.unknown) {
|
||||||
|
if (booking == BookingStatus.outForDelivery) {
|
||||||
|
consignment = ConsignmentState.outForDelivery;
|
||||||
|
} else if (next == 'start_delivery') {
|
||||||
|
consignment = ConsignmentState.collectedByMiler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pivot is confirmed by the id it minted. Without one there is nothing
|
||||||
|
// to deliver against, whatever the words say.
|
||||||
|
final confirmed =
|
||||||
|
id.isNotEmpty ||
|
||||||
|
booking == BookingStatus.convertedToConsignment ||
|
||||||
|
consignment != ConsignmentState.unknown;
|
||||||
|
|
||||||
|
return StateTransition(
|
||||||
|
outcome: confirmed
|
||||||
|
? TransitionOutcome.confirmed
|
||||||
|
: TransitionOutcome.unconfirmed,
|
||||||
|
bookingStatus: booking,
|
||||||
|
consignmentState: consignment,
|
||||||
|
consignmentId: id,
|
||||||
|
nextAction: next,
|
||||||
|
evidence:
|
||||||
|
'booking="$bookingRaw" consignment="$consignmentRaw" '
|
||||||
|
'id="$id" next="$next"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line, in the shape a backend ticket wants.
|
||||||
|
static void report(String verb, StateTransition t) {
|
||||||
|
switch (t.outcome) {
|
||||||
|
case TransitionOutcome.confirmed:
|
||||||
|
debugPrint('[LIFECYCLE][$verb] confirmed — ${t.evidence}');
|
||||||
|
case TransitionOutcome.unconfirmed:
|
||||||
|
debugPrint(
|
||||||
|
'[LIFECYCLE][$verb] NOT CONFIRMED BY THE SERVER — ${t.evidence}. '
|
||||||
|
'The call succeeded and the state did not move. This is a backend '
|
||||||
|
'deployment mismatch, not a client failure.',
|
||||||
|
);
|
||||||
|
case TransitionOutcome.refused:
|
||||||
|
debugPrint('[LIFECYCLE][$verb] refused — ${t.evidence}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
100
lib/data/load_state.dart
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// WHAT A SCREEN IS SHOWING, AS ONE VALUE
|
||||||
|
///
|
||||||
|
/// Screens carried three or four independent booleans — `_firstLoadDone`,
|
||||||
|
/// `_fetching`, `_failed`, plus a list that might be empty — and every screen
|
||||||
|
/// combined them slightly differently. That is how a page ends up showing an
|
||||||
|
/// empty state during a refresh, or a spinner over stale data, or nothing at
|
||||||
|
/// all when a request fails.
|
||||||
|
///
|
||||||
|
/// One value, and the combinations that cannot happen are unrepresentable.
|
||||||
|
///
|
||||||
|
/// ── Empty is not a failure, and unavailable is neither ──
|
||||||
|
///
|
||||||
|
/// Three answers a rider acts on differently, which were all `[]` before:
|
||||||
|
///
|
||||||
|
/// * **[LoadEmpty]** — the hub has given him nothing today. Wait.
|
||||||
|
/// * **[LoadFailure]** — the request did not complete. Retry.
|
||||||
|
/// * **[LoadUnavailable]** — his line of work has no endpoint behind it. Neither
|
||||||
|
/// waiting nor retrying will help, and telling him to do either is a lie.
|
||||||
|
/// See `ServiceProfile.hasBookingsEndpoint`.
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// Why a load did not produce data. Kept separate from the HTTP status because
|
||||||
|
/// the rider's next action is what differs, not the number.
|
||||||
|
enum LoadFailureKind {
|
||||||
|
/// No usable connection, a timeout, or a socket that died mid-flight.
|
||||||
|
offline,
|
||||||
|
|
||||||
|
/// The session is over. The shell signs the rider out; the screen should not
|
||||||
|
/// offer a retry that cannot succeed.
|
||||||
|
unauthorized,
|
||||||
|
|
||||||
|
/// Too many requests. Retrying immediately makes it worse.
|
||||||
|
rateLimited,
|
||||||
|
|
||||||
|
/// The server answered and refused, or answered with something unreadable.
|
||||||
|
server;
|
||||||
|
|
||||||
|
/// Whether offering "Try again" is honest.
|
||||||
|
bool get isRetryable => this != LoadFailureKind.unauthorized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The state of one screen's data.
|
||||||
|
sealed class LoadState<T> {
|
||||||
|
const LoadState();
|
||||||
|
|
||||||
|
/// The data, when there is any. Null in every other state — so a screen that
|
||||||
|
/// forgets to handle a case renders nothing rather than stale content.
|
||||||
|
T? get valueOrNull => switch (this) {
|
||||||
|
LoadData<T>(:final value) => value,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
bool get isLoading => this is LoadLoading<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First load, or a refresh with nothing to show yet.
|
||||||
|
class LoadLoading<T> extends LoadState<T> {
|
||||||
|
const LoadLoading();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Data arrived and there is something in it.
|
||||||
|
class LoadData<T> extends LoadState<T> {
|
||||||
|
final T value;
|
||||||
|
|
||||||
|
/// True while a refresh is running behind data that is already on screen.
|
||||||
|
///
|
||||||
|
/// The distinction the old booleans lost: a pull-to-refresh must not blank
|
||||||
|
/// the list it is refreshing.
|
||||||
|
final bool refreshing;
|
||||||
|
|
||||||
|
const LoadData(this.value, {this.refreshing = false});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The request succeeded and the answer was "nothing today".
|
||||||
|
class LoadEmpty<T> extends LoadState<T> {
|
||||||
|
const LoadEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The request did not complete.
|
||||||
|
class LoadFailure<T> extends LoadState<T> {
|
||||||
|
final LoadFailureKind kind;
|
||||||
|
|
||||||
|
/// The server's own sentence where it gave one — it is more useful than
|
||||||
|
/// anything this app can invent about a failure it did not cause.
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const LoadFailure(this.kind, {this.message = ''});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// There is no backend for this rider's line of work.
|
||||||
|
///
|
||||||
|
/// Not an error and not an empty day: a capability the deployment does not have
|
||||||
|
/// yet. Retrying cannot fix it and neither can waiting.
|
||||||
|
class LoadUnavailable<T> extends LoadState<T> {
|
||||||
|
final String reason;
|
||||||
|
|
||||||
|
const LoadUnavailable(this.reason);
|
||||||
|
}
|
||||||
358
lib/data/meal_run_mock.dart
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'package:miler/data/mock_backend.dart';
|
||||||
|
import 'package:miler/data/service_profile.dart';
|
||||||
|
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// A MEAL DAY, WITH NO BACKEND BEHIND IT
|
||||||
|
///
|
||||||
|
/// The meal line has no endpoints. The Doormile backend serves parcel bookings
|
||||||
|
/// and consignments and knows nothing about kitchens, crates or subscribers, and
|
||||||
|
/// building against invented URLs would produce a flow that compiles, demos, and
|
||||||
|
/// is wrong the day a real contract arrives.
|
||||||
|
///
|
||||||
|
/// So the meal line runs on this: a complete day, held in memory, walked with
|
||||||
|
/// the real screens. Every status change the rider makes is recorded here
|
||||||
|
/// instead of being sent, which means the whole process — accept, arrive, load
|
||||||
|
/// the crate, deliver, skip, fail — can be designed, used and judged before a
|
||||||
|
/// single endpoint exists.
|
||||||
|
///
|
||||||
|
/// ── Why this is not the demo layer that was deleted ──
|
||||||
|
///
|
||||||
|
/// A mock layer was ripped out of this app for a good reason: it wrote rows into
|
||||||
|
/// the same SharedPreferences stores the real work uses, so demo stops surfaced
|
||||||
|
/// on a live rider's tabs weeks later, with nothing left in the repo to explain
|
||||||
|
/// them. Three rules keep this one from becoming that:
|
||||||
|
///
|
||||||
|
/// 1. **It cannot reach a parcel rider.** [active] is false unless the signed-in
|
||||||
|
/// profile is the meal line, and an unrecognised tenant resolves to parcel.
|
||||||
|
/// There is no path from a normal login to this data.
|
||||||
|
/// 2. **Its ids are self-identifying.** Every id is `MOCK-…`, which is exactly
|
||||||
|
/// what `purgeDemoRecords()` already looks for — so anything that does leak
|
||||||
|
/// into a store is removed at the next launch, by code that already ships.
|
||||||
|
/// 3. **It has one switch.** [kMealMockEnabled] is the whole feature. When the
|
||||||
|
/// meal endpoints land, that constant goes false and the provider falls
|
||||||
|
/// through to the real call; nothing else in the app knows this file exists.
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Whether the day below is served instead of calling the API.
|
||||||
|
///
|
||||||
|
/// ── One switch for the whole app, not two ──
|
||||||
|
///
|
||||||
|
/// This used to be its own constant, and it was turned off on 2026-08-12 for a
|
||||||
|
/// real reason: while it was on, a meal rider tapped **Arrived** and **Picked
|
||||||
|
/// up**, watched the card advance, and the hub console never changed. Every
|
||||||
|
/// status write on this line was recorded in a `Map` on the phone and reported
|
||||||
|
/// as a success.
|
||||||
|
///
|
||||||
|
/// That failure mode has not gone away — it is simply what running without a
|
||||||
|
/// backend *means*, and it now applies to the whole app rather than to this
|
||||||
|
/// file alone. So the two switches became one: [kMockBackend] takes sign-in,
|
||||||
|
/// the day and every status write off the network together, and this follows
|
||||||
|
/// it. There is no state where the meal day is fictional but the statuses are
|
||||||
|
/// real, which is the confusing half of what used to be possible.
|
||||||
|
///
|
||||||
|
/// Turn the app back on to the real backend with:
|
||||||
|
///
|
||||||
|
/// flutter run --dart-define=MOCK_BACKEND=false
|
||||||
|
///
|
||||||
|
/// The meal line then falls through to the three booking routes it actually
|
||||||
|
/// depends on, all of which exist and are proven by the parcel line:
|
||||||
|
///
|
||||||
|
/// list `GET /miler/bookings`
|
||||||
|
/// arrived `POST /miler/bookings/:id/reached`
|
||||||
|
/// picked up `POST /miler/bookings/:id/pickup-complete`
|
||||||
|
/// Follows the one mock switch at runtime rather than at compile time, so a
|
||||||
|
/// test (or a bench session) that turns the canned backend off turns this off
|
||||||
|
/// with it. See [MockBackend.enabled].
|
||||||
|
bool get kMealMockEnabled => MockBackend.enabled;
|
||||||
|
|
||||||
|
/// Wall-clock helper so the mock day always looks like *today's* shift rather
|
||||||
|
/// than a fixed date that reads as stale the moment anybody opens it.
|
||||||
|
String _slot(int hour, int minute) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final t = DateTime(now.year, now.month, now.day, hour, minute);
|
||||||
|
return t.toIso8601String();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A meal run held in memory: two kitchens, seven subscribers, one rider.
|
||||||
|
///
|
||||||
|
/// Coordinates are real Coimbatore addresses in route order, because a route
|
||||||
|
/// that doubles back looks like a bug in the sequencing rather than what it is.
|
||||||
|
class MealRunMock {
|
||||||
|
MealRunMock._();
|
||||||
|
|
||||||
|
/// True when the app should serve this instead of calling the API.
|
||||||
|
static bool get active =>
|
||||||
|
kMealMockEnabled && ServiceProfile.active.sourceIsKitchen;
|
||||||
|
|
||||||
|
/// Status overrides the rider has caused this session, keyed by order id.
|
||||||
|
///
|
||||||
|
/// The mock's own rows are `assigned`; everything after that is something he
|
||||||
|
/// did. Held separately rather than mutated into the rows so a reset is one
|
||||||
|
/// `clear()` and the day itself stays declarative.
|
||||||
|
static final Map<String, String> _status = <String, String>{};
|
||||||
|
|
||||||
|
/// Records what a status call *would* have sent. Always succeeds — there is
|
||||||
|
/// nothing to fail.
|
||||||
|
static bool setStatus(String orderId, String status) {
|
||||||
|
if (orderId.isEmpty) return true;
|
||||||
|
_status[orderId] = status;
|
||||||
|
debugPrint('[MEAL_MOCK] $orderId → $status');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same, keyed by the numeric booking id the controllers carry.
|
||||||
|
static bool setStatusByPickupId(int pickupId, String status) {
|
||||||
|
if (pickupId <= 0) return true;
|
||||||
|
final row = _day.firstWhere(
|
||||||
|
(r) => r['pickupid'] == pickupId,
|
||||||
|
orElse: () => const <String, dynamic>{},
|
||||||
|
);
|
||||||
|
final id = (row['orderid'] ?? '').toString();
|
||||||
|
return setStatus(id, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts the day back to the top. Wired to nothing yet — it exists so a
|
||||||
|
/// demo can be run twice without reinstalling the app.
|
||||||
|
static void reset() => _status.clear();
|
||||||
|
|
||||||
|
/// The day as the cards read it, with whatever the rider has done applied.
|
||||||
|
///
|
||||||
|
/// A fresh copy every call: the screens mutate stop maps in place (compliance
|
||||||
|
/// stamps, proof blocks), and handing out the master rows would let one run's
|
||||||
|
/// leftovers show up in the next.
|
||||||
|
static List<Map<String, dynamic>> stops() => [
|
||||||
|
for (final row in _day)
|
||||||
|
{...row, 'orderstatus': _status[row['orderid']] ?? row['orderstatus']},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── The day itself ──────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Two kitchens is deliberate rather than decorative: a single-kitchen day
|
||||||
|
// hides every question the grouping exists to answer — which crate is this,
|
||||||
|
// which bags belong to it, what happens when the second load is still at a
|
||||||
|
// counter he has not reached.
|
||||||
|
//
|
||||||
|
// Five orders on the first counter and four on the second, because the whole
|
||||||
|
// pickup flow is a claim about a *set*: `5 orders · 5 bags`, five names on the
|
||||||
|
// confirmation, five deliveries afterwards. A day of ones would let every one
|
||||||
|
// of those read correctly while being wrong.
|
||||||
|
static final List<Map<String, dynamic>> _day = [
|
||||||
|
// ── Vidhya Kitchen · Gandhi Nagar, Peelamedu ──
|
||||||
|
_drop(
|
||||||
|
id: 1001,
|
||||||
|
step: 1,
|
||||||
|
name: 'Joe Mathew',
|
||||||
|
phone: '9840012001',
|
||||||
|
address: '12, SNS Colony, Peelamedu',
|
||||||
|
lat: 11.0271,
|
||||||
|
lng: 76.9962,
|
||||||
|
kitchen: _vidhya,
|
||||||
|
bag: 'DG-1001',
|
||||||
|
meals: 1,
|
||||||
|
),
|
||||||
|
_drop(
|
||||||
|
id: 1002,
|
||||||
|
step: 2,
|
||||||
|
name: 'Arun Prakash',
|
||||||
|
phone: '9840012002',
|
||||||
|
address: '21, New Street, Peelamedu',
|
||||||
|
lat: 11.0248,
|
||||||
|
lng: 76.9941,
|
||||||
|
kitchen: _vidhya,
|
||||||
|
bag: 'DG-1002',
|
||||||
|
meals: 1,
|
||||||
|
),
|
||||||
|
_drop(
|
||||||
|
id: 1003,
|
||||||
|
step: 3,
|
||||||
|
name: 'Priya Venkatesh',
|
||||||
|
phone: '9840012003',
|
||||||
|
address: '45, West Avenue, RS Puram',
|
||||||
|
lat: 11.0043,
|
||||||
|
lng: 76.9518,
|
||||||
|
kitchen: _vidhya,
|
||||||
|
bag: 'DG-1003',
|
||||||
|
meals: 1,
|
||||||
|
),
|
||||||
|
_drop(
|
||||||
|
id: 1004,
|
||||||
|
step: 4,
|
||||||
|
name: 'Kumar Selvam',
|
||||||
|
phone: '9840012004',
|
||||||
|
address: 'Flat 3C, Lakshmi Towers, 100 Feet Road, Gandhipuram',
|
||||||
|
lat: 11.0168,
|
||||||
|
lng: 76.9558,
|
||||||
|
kitchen: _vidhya,
|
||||||
|
bag: 'DG-1004',
|
||||||
|
meals: 1,
|
||||||
|
),
|
||||||
|
_drop(
|
||||||
|
id: 1005,
|
||||||
|
step: 5,
|
||||||
|
name: 'Ravi Shankar',
|
||||||
|
phone: '9840012005',
|
||||||
|
address: '8, Krishna Colony, 2nd Street, Singanallur',
|
||||||
|
lat: 10.9925,
|
||||||
|
lng: 77.0289,
|
||||||
|
kitchen: _vidhya,
|
||||||
|
bag: 'DG-1005',
|
||||||
|
meals: 1,
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Annapoorna Mess · Thadagam Road ──
|
||||||
|
_drop(
|
||||||
|
id: 2001,
|
||||||
|
step: 6,
|
||||||
|
name: 'Meena Krishnan',
|
||||||
|
phone: '9840012006',
|
||||||
|
address: '31, Sarojini Street, Gandhipuram',
|
||||||
|
lat: 11.0181,
|
||||||
|
lng: 76.9603,
|
||||||
|
kitchen: _annapoorna,
|
||||||
|
bag: 'AM-2001',
|
||||||
|
meals: 1,
|
||||||
|
),
|
||||||
|
_drop(
|
||||||
|
id: 2002,
|
||||||
|
step: 7,
|
||||||
|
name: 'Sai Ganesh',
|
||||||
|
phone: '9840012007',
|
||||||
|
address: '14, Trichy Road, above the pharmacy, Ramanathapuram',
|
||||||
|
lat: 10.9871,
|
||||||
|
lng: 76.9931,
|
||||||
|
kitchen: _annapoorna,
|
||||||
|
bag: 'AM-2002',
|
||||||
|
meals: 1,
|
||||||
|
),
|
||||||
|
_drop(
|
||||||
|
id: 2003,
|
||||||
|
step: 8,
|
||||||
|
name: 'Devi Lakshmi',
|
||||||
|
phone: '9840012008',
|
||||||
|
address: '22, Kamarajar Road, near the school gate, Uppilipalayam',
|
||||||
|
lat: 10.9903,
|
||||||
|
lng: 76.9977,
|
||||||
|
kitchen: _annapoorna,
|
||||||
|
bag: 'AM-2003',
|
||||||
|
meals: 1,
|
||||||
|
),
|
||||||
|
_drop(
|
||||||
|
id: 2004,
|
||||||
|
step: 9,
|
||||||
|
name: 'Karthik Raja',
|
||||||
|
phone: '9840012009',
|
||||||
|
address: '5B, Thadagam Road, opposite the water tank',
|
||||||
|
lat: 11.0221,
|
||||||
|
lng: 76.9391,
|
||||||
|
kitchen: _annapoorna,
|
||||||
|
bag: 'AM-2004',
|
||||||
|
meals: 1,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// A counter the rider collects from: what it is called, where it is, and the
|
||||||
|
/// id everything groups on.
|
||||||
|
static const _vidhya = (
|
||||||
|
id: 'K1',
|
||||||
|
name: 'Vidhya Kitchen',
|
||||||
|
address: 'Gandhi Nagar, Peelamedu, Coimbatore - 641004',
|
||||||
|
lat: 11.0261,
|
||||||
|
lng: 76.9931,
|
||||||
|
locationId: 901,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const _annapoorna = (
|
||||||
|
id: 'K2',
|
||||||
|
name: 'Annapoorna Mess',
|
||||||
|
address: '19, Thadagam Road, RS Puram, Coimbatore - 641002',
|
||||||
|
lat: 11.0195,
|
||||||
|
lng: 76.9412,
|
||||||
|
locationId: 902,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// One subscriber drop, in the legacy stop shape every card in the app reads.
|
||||||
|
///
|
||||||
|
/// ── Both ends, in the right fields ──
|
||||||
|
///
|
||||||
|
/// The `pickup*` fields are the **kitchen** and the `drop*` fields are the
|
||||||
|
/// **customer**, because that is what the app navigates on: before collection
|
||||||
|
/// `navigationTarget` reads `pickuplat/pickuplon`, and after it reads
|
||||||
|
/// `droplat/droplon` — see [MilkRun]. This fixture used to put the customer in
|
||||||
|
/// both, which sent a rider to a subscriber's flat to collect a lunch that was
|
||||||
|
/// still at a counter three kilometres away.
|
||||||
|
///
|
||||||
|
/// `type: delivery` is stated rather than inferred: the app would reach the
|
||||||
|
/// same answer from the profile, but a fixture that relies on a fallback is
|
||||||
|
/// testing the fallback rather than the screen.
|
||||||
|
static Map<String, dynamic> _drop({
|
||||||
|
required int id,
|
||||||
|
required int step,
|
||||||
|
required String name,
|
||||||
|
required String phone,
|
||||||
|
required String address,
|
||||||
|
required double lat,
|
||||||
|
required double lng,
|
||||||
|
required ({
|
||||||
|
String id,
|
||||||
|
String name,
|
||||||
|
String address,
|
||||||
|
double lat,
|
||||||
|
double lng,
|
||||||
|
int locationId,
|
||||||
|
})
|
||||||
|
kitchen,
|
||||||
|
required String bag,
|
||||||
|
required int meals,
|
||||||
|
}) => <String, dynamic>{
|
||||||
|
// `MOCK-` so `purgeDemoRecords()` recognises anything that leaks into a
|
||||||
|
// store. See the note at the top of this file.
|
||||||
|
'orderid': 'MOCK-M-$id',
|
||||||
|
'pickupid': id,
|
||||||
|
'orderheaderid': id,
|
||||||
|
'pickuplocationid': kitchen.locationId,
|
||||||
|
'orderstatus': 'assigned',
|
||||||
|
'step': step,
|
||||||
|
|
||||||
|
// Who is being handed food.
|
||||||
|
'pickupcustomer': name,
|
||||||
|
'pickupcontactno': phone,
|
||||||
|
|
||||||
|
// Where he collects: the counter.
|
||||||
|
'pickupaddress': kitchen.address,
|
||||||
|
'pickuplat': kitchen.lat,
|
||||||
|
'pickuplon': kitchen.lng,
|
||||||
|
'pickuplong': kitchen.lng,
|
||||||
|
|
||||||
|
// Where it goes: the door.
|
||||||
|
'dropaddress': '$address, Coimbatore, Tamil Nadu 641004',
|
||||||
|
'droplat': lat,
|
||||||
|
'droplon': lng,
|
||||||
|
|
||||||
|
// The counter itself. The grouping, the headings, the manifest and the
|
||||||
|
// bag identity all key off these.
|
||||||
|
'kitchenid': kitchen.id,
|
||||||
|
'kitchenname': kitchen.name,
|
||||||
|
'baglabel': bag,
|
||||||
|
|
||||||
|
// A drop, and how many boxes are in it. One order is one bag — the count
|
||||||
|
// here is the *meal* count inside that bag, and nothing reads it as a bag
|
||||||
|
// count. See [BagManifest].
|
||||||
|
'type': 'delivery',
|
||||||
|
'deliveryqty': meals,
|
||||||
|
'quantity': meals,
|
||||||
|
|
||||||
|
// Nothing is owed at any door — a subscriber paid the client by the month.
|
||||||
|
// Stated as zero rather than omitted so a payload reader cannot mistake a
|
||||||
|
// missing field for an unknown amount.
|
||||||
|
'collectionamt': 0,
|
||||||
|
'pickupamt': 0,
|
||||||
|
|
||||||
|
// The lunch slot the whole run belongs to.
|
||||||
|
'starttime': _slot(11, 30),
|
||||||
|
'endtime': _slot(13, 30),
|
||||||
|
'eta': '8',
|
||||||
|
'kms': '2.4',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import 'package:miler/data/api_config.dart';
|
import 'package:miler/data/api_config.dart';
|
||||||
|
import 'package:miler/data/mock_backend.dart';
|
||||||
|
import 'package:miler/data/mutation_guard.dart';
|
||||||
|
|
||||||
/// ─────────────────────────────────────────────────────────────────────────
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
/// THE MILER API — every `/miler/*` route, in one place.
|
/// THE MILER API — every `/miler/*` route, in one place.
|
||||||
@@ -48,8 +50,87 @@ class MilerApi {
|
|||||||
/// The partition riders live in.
|
/// The partition riders live in.
|
||||||
static const int configId = 1001;
|
static const int configId = 1001;
|
||||||
|
|
||||||
|
/// ── The operating company this build signs riders in for ──
|
||||||
|
///
|
||||||
|
/// Sent as `tenantid` on both auth calls, so the backend can scope the login
|
||||||
|
/// to one company. Set at build time:
|
||||||
|
///
|
||||||
|
/// flutter build apk --dart-define=TENANT_ID=7
|
||||||
|
///
|
||||||
|
/// Zero means "not specified" and the field is **omitted** rather than sent
|
||||||
|
/// as `0` — a zero looks like a real tenant to a server doing a lookup, and
|
||||||
|
/// an omitted field is the only way to say "you decide".
|
||||||
|
///
|
||||||
|
/// ── What this value is and is not ──
|
||||||
|
///
|
||||||
|
/// It is a *hint from the build*, not an authorisation claim. The rider's
|
||||||
|
/// real tenant is a column on his account row and the server is the only
|
||||||
|
/// thing that knows it; a client that asserts its own scope is a client that
|
||||||
|
/// can be modified to assert somebody else's. So the response wins wherever
|
||||||
|
/// it answers — see the tenant resolution in `auth_provider.dart`, which
|
||||||
|
/// falls back to this only while `verify-pin` returns no tenant at all.
|
||||||
|
///
|
||||||
|
/// The day the backend returns `tenantid` on the profile, this keeps being
|
||||||
|
/// sent and stops being read, and nothing else has to change.
|
||||||
|
static const int tenantId = int.fromEnvironment('TENANT_ID');
|
||||||
|
|
||||||
|
/// True when this build was given a tenant to declare.
|
||||||
|
static bool get hasTenantId => tenantId > 0;
|
||||||
|
|
||||||
static const Duration _timeout = Duration(seconds: 20);
|
static const Duration _timeout = Duration(seconds: 20);
|
||||||
|
|
||||||
|
/// How long to wait before asking again for the result of a write the server
|
||||||
|
/// says it is already running, and how many times. Kept small: the rider is
|
||||||
|
/// standing at a door with his thumb on the screen, and beyond a second or
|
||||||
|
/// so a spinner stops reading as *working* and starts reading as *stuck*.
|
||||||
|
static const Duration _inFlightBackoff = Duration(milliseconds: 600);
|
||||||
|
static const int _inFlightRetries = 2;
|
||||||
|
|
||||||
|
/// ── The one seam in the transport ──
|
||||||
|
///
|
||||||
|
/// Every `/miler/*` route goes through [_send], and [_send] went straight to
|
||||||
|
/// the `http` package's top-level functions — which are not injectable, so the
|
||||||
|
/// only way to assert what this client actually *puts on the wire* was to let
|
||||||
|
/// it reach the network.
|
||||||
|
///
|
||||||
|
/// That matters more here than it usually would, because several fields in
|
||||||
|
/// this contract are quietly load-bearing: `device_token` is snake_case where
|
||||||
|
/// everything around it is not, telemetry numbers are **strings**, telemetry
|
||||||
|
/// bodies must carry no `userid`, availability sends `Break` and not
|
||||||
|
/// `On_Break`, and `vehicle-required` takes query parameters rather than a
|
||||||
|
/// body. Every one of those is a silent failure if it drifts, and none of them
|
||||||
|
/// was covered.
|
||||||
|
///
|
||||||
|
/// A swappable client makes the request itself the thing under test. Nothing
|
||||||
|
/// in the app assigns this; only tests do.
|
||||||
|
@visibleForTesting
|
||||||
|
static http.Client client = http.Client();
|
||||||
|
|
||||||
|
/// Wraps a non-idempotent mutation so a double tap cannot send it twice.
|
||||||
|
///
|
||||||
|
/// A dropped duplicate comes back as a **failed result carrying the reason**
|
||||||
|
/// rather than as null, so a caller that does not know about the guard still
|
||||||
|
/// takes its error path instead of reading a null as success. See
|
||||||
|
/// [MutationGuard] for why a second press is dropped rather than queued.
|
||||||
|
static Future<ApiResult> _guarded(
|
||||||
|
String key,
|
||||||
|
Future<ApiResult> Function() send,
|
||||||
|
) async =>
|
||||||
|
await MutationGuard.run(key, send) ??
|
||||||
|
const ApiResult(
|
||||||
|
ok: false,
|
||||||
|
status: 0,
|
||||||
|
message: 'That is already going through — give it a moment.',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Called once when an authenticated call is refused by the server.
|
||||||
|
///
|
||||||
|
/// The transport does the irreversible half — dropping the dead token — and
|
||||||
|
/// this hands the rest to whoever owns navigation, so this file never has to
|
||||||
|
/// know what a screen is. Set by the app shell; null in tests unless one is
|
||||||
|
/// asserting on it.
|
||||||
|
static void Function()? onUnauthorized;
|
||||||
|
|
||||||
/// Telemetry wants strings for values that are numbers everywhere else.
|
/// Telemetry wants strings for values that are numbers everywhere else.
|
||||||
static String _str(dynamic v) => v == null ? '' : v.toString();
|
static String _str(dynamic v) => v == null ? '' : v.toString();
|
||||||
|
|
||||||
@@ -76,16 +157,53 @@ class MilerApi {
|
|||||||
Object? body,
|
Object? body,
|
||||||
Map<String, String>? query,
|
Map<String, String>? query,
|
||||||
bool auth = true,
|
bool auth = true,
|
||||||
|
|
||||||
|
/// Sent as `Idempotency-Key`. A retry with the same key replays the first
|
||||||
|
/// response instead of writing twice — see [_idempotencyKey].
|
||||||
|
String? idempotencyKey,
|
||||||
|
|
||||||
|
/// How many times an `IDEMPOTENCY_IN_PROGRESS` answer has already been
|
||||||
|
/// waited out on this call. Internal — see [_inFlightRetries].
|
||||||
|
int inFlightAttempt = 0,
|
||||||
}) async {
|
}) async {
|
||||||
|
// ── Nothing leaves the device while the mock is on ──
|
||||||
|
//
|
||||||
|
// Intercepted here rather than per call site, because this is the one door
|
||||||
|
// every `/miler/*` route goes through — and a mock that covers 38 routes by
|
||||||
|
// covering one function cannot drift out of step with the ones it missed.
|
||||||
|
// The canned body is handed to exactly the same envelope handling as a real
|
||||||
|
// 200, so the app cannot tell the difference and neither can this file.
|
||||||
|
if (MockBackend.enabled) {
|
||||||
|
final canned = MockBackend.respond(
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
body: body,
|
||||||
|
query: query,
|
||||||
|
);
|
||||||
|
if (canned != null) {
|
||||||
|
return ApiResult(
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: canned['data'] ?? canned,
|
||||||
|
raw: canned,
|
||||||
|
message: _str(canned['message']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final uri = Uri.parse(
|
final uri = Uri.parse(
|
||||||
ApiConfig.url(path),
|
ApiConfig.url(path),
|
||||||
).replace(queryParameters: (query == null || query.isEmpty) ? null : query);
|
).replace(queryParameters: (query == null || query.isEmpty) ? null : query);
|
||||||
|
|
||||||
final headers = auth
|
final headers = <String, String>{
|
||||||
|
...(auth
|
||||||
? await ApiConfig.authHeaders()
|
? await ApiConfig.authHeaders()
|
||||||
: const {
|
: const {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
|
}),
|
||||||
|
if (idempotencyKey != null && idempotencyKey.isNotEmpty)
|
||||||
|
'Idempotency-Key': idempotencyKey,
|
||||||
};
|
};
|
||||||
final encoded = body == null ? null : json.encode(body);
|
final encoded = body == null ? null : json.encode(body);
|
||||||
|
|
||||||
@@ -93,17 +211,17 @@ class MilerApi {
|
|||||||
late http.Response res;
|
late http.Response res;
|
||||||
switch (method) {
|
switch (method) {
|
||||||
case 'GET':
|
case 'GET':
|
||||||
res = await http.get(uri, headers: headers).timeout(_timeout);
|
res = await client.get(uri, headers: headers).timeout(_timeout);
|
||||||
case 'POST':
|
case 'POST':
|
||||||
res = await http
|
res = await client
|
||||||
.post(uri, headers: headers, body: encoded)
|
.post(uri, headers: headers, body: encoded)
|
||||||
.timeout(_timeout);
|
.timeout(_timeout);
|
||||||
case 'PUT':
|
case 'PUT':
|
||||||
res = await http
|
res = await client
|
||||||
.put(uri, headers: headers, body: encoded)
|
.put(uri, headers: headers, body: encoded)
|
||||||
.timeout(_timeout);
|
.timeout(_timeout);
|
||||||
case 'PATCH':
|
case 'PATCH':
|
||||||
res = await http
|
res = await client
|
||||||
.patch(uri, headers: headers, body: encoded)
|
.patch(uri, headers: headers, body: encoded)
|
||||||
.timeout(_timeout);
|
.timeout(_timeout);
|
||||||
default:
|
default:
|
||||||
@@ -131,13 +249,60 @@ class MilerApi {
|
|||||||
data: decoded is Map ? (decoded['data'] ?? decoded) : decoded,
|
data: decoded is Map ? (decoded['data'] ?? decoded) : decoded,
|
||||||
raw: decoded,
|
raw: decoded,
|
||||||
message: decoded is Map ? _str(decoded['message']) : '',
|
message: decoded is Map ? _str(decoded['message']) : '',
|
||||||
|
code: decoded is Map ? _str(decoded['code']) : '',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── The duplicate that is not a failure ──
|
||||||
|
//
|
||||||
|
// `IDEMPOTENCY_IN_PROGRESS` means the server is *at this moment* running
|
||||||
|
// the very request being retried — the first attempt's response was
|
||||||
|
// lost, not its effect. Surfacing it would tell a rider his delivery
|
||||||
|
// failed while it is in the act of succeeding, and a fresh press would
|
||||||
|
// meet the same answer.
|
||||||
|
//
|
||||||
|
// It is also not a reason to declare success: the write in flight can
|
||||||
|
// still fail. So the call waits and asks again, and the idempotent
|
||||||
|
// replay hands back the first attempt's real outcome. Two short waits,
|
||||||
|
// then whatever the server says is passed through as-is.
|
||||||
|
if (!result.ok &&
|
||||||
|
result.isInFlight &&
|
||||||
|
inFlightAttempt < _inFlightRetries) {
|
||||||
|
debugPrint('[API] $method $path -> in flight, waiting for the answer');
|
||||||
|
await Future<void>.delayed(_inFlightBackoff * (inFlightAttempt + 1));
|
||||||
|
return _send(
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
body: body,
|
||||||
|
query: query,
|
||||||
|
auth: auth,
|
||||||
|
idempotencyKey: idempotencyKey,
|
||||||
|
inFlightAttempt: inFlightAttempt + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[API] $method $path -> ${res.statusCode} ${result.message} '
|
'[API] $method $path -> ${res.statusCode} '
|
||||||
|
'${result.code.isEmpty ? '' : '[${result.code}] '}${result.message} '
|
||||||
'${res.body.length > 300 ? '${res.body.substring(0, 300)}…' : res.body}',
|
'${res.body.length > 300 ? '${res.body.substring(0, 300)}…' : res.body}',
|
||||||
);
|
);
|
||||||
|
// ── One place decides that a session is over ──
|
||||||
|
//
|
||||||
|
// A 401 used to surface as an ordinary failed call, so every screen
|
||||||
|
// handled it — or, mostly, did not: the rider stayed visually signed in
|
||||||
|
// with a dead token, every list refreshed to empty, and nothing said
|
||||||
|
// why. Handled here because this is the one door all 38 routes pass
|
||||||
|
// through, and because the decision is the same wherever it happens.
|
||||||
|
//
|
||||||
|
// The token is dropped so nothing retries with it, and [onUnauthorized]
|
||||||
|
// lets the shell take the rider back to sign-in. Auth's own two routes
|
||||||
|
// are exempt: a wrong PIN is a 401 about a credential, not an expired
|
||||||
|
// session, and clearing state there would be a logout in response to a
|
||||||
|
// typo.
|
||||||
|
if (res.statusCode == 401 && auth) {
|
||||||
|
await ApiConfig.clearToken();
|
||||||
|
onUnauthorized?.call();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -156,7 +321,11 @@ class MilerApi {
|
|||||||
'POST',
|
'POST',
|
||||||
'/miler/login',
|
'/miler/login',
|
||||||
auth: false,
|
auth: false,
|
||||||
body: {'phone': phone, 'configid': configId},
|
body: {
|
||||||
|
'phone': phone,
|
||||||
|
'configid': configId,
|
||||||
|
if (hasTenantId) 'tenantid': tenantId,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Step 2. On success the token is stored and the caller gets `user`.
|
/// Step 2. On success the token is stored and the caller gets `user`.
|
||||||
@@ -176,6 +345,7 @@ class MilerApi {
|
|||||||
'phone': phone,
|
'phone': phone,
|
||||||
'pin': pin,
|
'pin': pin,
|
||||||
'configid': configId,
|
'configid': configId,
|
||||||
|
if (hasTenantId) 'tenantid': tenantId,
|
||||||
if (deviceToken != null && deviceToken.isNotEmpty)
|
if (deviceToken != null && deviceToken.isNotEmpty)
|
||||||
'device_token': deviceToken,
|
'device_token': deviceToken,
|
||||||
},
|
},
|
||||||
@@ -198,12 +368,16 @@ class MilerApi {
|
|||||||
String? profilePhotoUrl,
|
String? profilePhotoUrl,
|
||||||
String? defaultVehicleType,
|
String? defaultVehicleType,
|
||||||
String? phone,
|
String? phone,
|
||||||
}) => _send('PUT', '/miler/profile', body: {
|
}) => _send(
|
||||||
|
'PUT',
|
||||||
|
'/miler/profile',
|
||||||
|
body: {
|
||||||
if (displayName != null) 'displayname': displayName,
|
if (displayName != null) 'displayname': displayName,
|
||||||
if (profilePhotoUrl != null) 'profilephotourl': profilePhotoUrl,
|
if (profilePhotoUrl != null) 'profilephotourl': profilePhotoUrl,
|
||||||
if (defaultVehicleType != null) 'defaultvehicletype': defaultVehicleType,
|
if (defaultVehicleType != null) 'defaultvehicletype': defaultVehicleType,
|
||||||
if (phone != null) 'phone': phone,
|
if (phone != null) 'phone': phone,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/// snake_case, unlike the rest of the contract.
|
/// snake_case, unlike the rest of the contract.
|
||||||
static Future<ApiResult> setDeviceToken(String token) =>
|
static Future<ApiResult> setDeviceToken(String token) =>
|
||||||
@@ -221,22 +395,29 @@ class MilerApi {
|
|||||||
String? pincode,
|
String? pincode,
|
||||||
double? speed,
|
double? speed,
|
||||||
double? heading,
|
double? heading,
|
||||||
}) => _send('PUT', '/miler/location', body: {
|
}) => _send(
|
||||||
|
'PUT',
|
||||||
|
'/miler/location',
|
||||||
|
body: {
|
||||||
'latitude': latitude,
|
'latitude': latitude,
|
||||||
'longitude': longitude,
|
'longitude': longitude,
|
||||||
if (pincode != null && pincode.isNotEmpty) 'pincode': pincode,
|
if (pincode != null && pincode.isNotEmpty) 'pincode': pincode,
|
||||||
if (speed != null) 'speed': speed,
|
if (speed != null) 'speed': speed,
|
||||||
if (heading != null) 'heading': heading,
|
if (heading != null) 'heading': heading,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/// One of [availabilityStatuses]. Sent under both keys the backend has
|
/// One of [availabilityStatuses]. Sent under both keys the backend has
|
||||||
/// accepted at different times, so neither a doc nor a handler change can
|
/// accepted at different times, so neither a doc nor a handler change can
|
||||||
/// silently drop it.
|
/// silently drop it.
|
||||||
static Future<ApiResult> setAvailability(String status) =>
|
static Future<ApiResult> setAvailability(String status) => _guarded(
|
||||||
_send('PUT', '/miler/availability', body: {
|
'availability',
|
||||||
'status': status,
|
() => _send(
|
||||||
'availabilitystatus': status,
|
'PUT',
|
||||||
});
|
'/miler/availability',
|
||||||
|
body: {'status': status, 'availabilitystatus': status},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
/// Note `Break`, not `On_Break` — the obvious guess is the wrong one.
|
/// Note `Break`, not `On_Break` — the obvious guess is the wrong one.
|
||||||
static const List<String> availabilityStatuses = [
|
static const List<String> availabilityStatuses = [
|
||||||
@@ -259,23 +440,35 @@ class MilerApi {
|
|||||||
// reconcile (re-read `dutyCurrent`), not as failures to retry.
|
// reconcile (re-read `dutyCurrent`), not as failures to retry.
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
static Future<ApiResult> startDuty({double? lat, double? lon}) =>
|
// Guarded on one key for both directions: starting duty twice is a
|
||||||
_send('POST', '/miler/duty/start', body: {
|
// server-side error, and so is ending it twice — and a rider who taps the
|
||||||
'lat': lat ?? 0,
|
// switch again because the first tap "did nothing" is the commonest way to
|
||||||
'lon': lon ?? 0,
|
// produce either.
|
||||||
});
|
static Future<ApiResult> startDuty({double? lat, double? lon}) => _guarded(
|
||||||
|
'duty',
|
||||||
|
() => _send(
|
||||||
|
'POST',
|
||||||
|
'/miler/duty/start',
|
||||||
|
body: {'lat': lat ?? 0, 'lon': lon ?? 0},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
static Future<ApiResult> endDuty() => _send('PUT', '/miler/duty/end');
|
static Future<ApiResult> endDuty() =>
|
||||||
|
_guarded('duty', () => _send('PUT', '/miler/duty/end'));
|
||||||
|
|
||||||
static Future<ApiResult> dutyCurrent() =>
|
static Future<ApiResult> dutyCurrent() => _send('GET', '/miler/duty/current');
|
||||||
_send('GET', '/miler/duty/current');
|
|
||||||
|
|
||||||
static Future<ApiResult> startBreak(String breakType) =>
|
static Future<ApiResult> startBreak(String breakType) => _guarded(
|
||||||
_send('POST', '/miler/breaks/start', body: {
|
'break',
|
||||||
'breaktype': breakType.isEmpty ? 'Personal' : breakType,
|
() => _send(
|
||||||
});
|
'POST',
|
||||||
|
'/miler/breaks/start',
|
||||||
|
body: {'breaktype': breakType.isEmpty ? 'Personal' : breakType},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
static Future<ApiResult> endBreak() => _send('PUT', '/miler/breaks/end');
|
static Future<ApiResult> endBreak() =>
|
||||||
|
_guarded('break', () => _send('PUT', '/miler/breaks/end'));
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// ASSIGNMENTS
|
// ASSIGNMENTS
|
||||||
@@ -289,8 +482,10 @@ class MilerApi {
|
|||||||
/// Keyed on `bookingassignmentid`, **not** `bookingid` — they come from
|
/// Keyed on `bookingassignmentid`, **not** `bookingid` — they come from
|
||||||
/// different sequences. Resolve through `AssignmentLookup` first or the
|
/// different sequences. Resolve through `AssignmentLookup` first or the
|
||||||
/// backend answers 404 while the rider is shown success.
|
/// backend answers 404 while the rider is shown success.
|
||||||
static Future<ApiResult> acceptAssignment(Object assignmentId) =>
|
static Future<ApiResult> acceptAssignment(Object assignmentId) => _guarded(
|
||||||
_send('POST', '/miler/assignments/$assignmentId/accept', body: {});
|
'accept:$assignmentId',
|
||||||
|
() => _send('POST', '/miler/assignments/$assignmentId/accept', body: {}),
|
||||||
|
);
|
||||||
|
|
||||||
/// The deployed handler reads `reason` from the query string; the contract
|
/// The deployed handler reads `reason` from the query string; the contract
|
||||||
/// doc says the body. Sent both ways — this route has never had a real
|
/// doc says the body. Sent both ways — this route has never had a real
|
||||||
@@ -298,11 +493,14 @@ class MilerApi {
|
|||||||
static Future<ApiResult> rejectAssignment(
|
static Future<ApiResult> rejectAssignment(
|
||||||
Object assignmentId, {
|
Object assignmentId, {
|
||||||
required String reason,
|
required String reason,
|
||||||
}) => _send(
|
}) => _guarded(
|
||||||
|
'reject:$assignmentId',
|
||||||
|
() => _send(
|
||||||
'POST',
|
'POST',
|
||||||
'/miler/assignments/$assignmentId/reject',
|
'/miler/assignments/$assignmentId/reject',
|
||||||
query: {'reason': reason},
|
query: {'reason': reason},
|
||||||
body: {'reason': reason},
|
body: {'reason': reason},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
@@ -314,10 +512,62 @@ class MilerApi {
|
|||||||
Object bookingId, {
|
Object bookingId, {
|
||||||
double? lat,
|
double? lat,
|
||||||
double? lon,
|
double? lon,
|
||||||
}) => _send('POST', '/miler/bookings/$bookingId/reached', body: {
|
}) => _guarded(
|
||||||
|
'reached:$bookingId',
|
||||||
|
() => _send(
|
||||||
|
'POST',
|
||||||
|
'/miler/bookings/$bookingId/reached',
|
||||||
|
body: {
|
||||||
if (lat != null) 'latitude': lat,
|
if (lat != null) 'latitude': lat,
|
||||||
if (lon != null) 'longitude': lon,
|
if (lon != null) 'longitude': lon,
|
||||||
});
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Step 1b — **the FROM and TO the rider confirms at the door.**
|
||||||
|
///
|
||||||
|
/// A logistics booking often reaches the rider half-addressed: raised from a
|
||||||
|
/// map pin, destination described as a phone number and a landmark. He is the
|
||||||
|
/// first person who can stand in front of the sender and ask, and the answers
|
||||||
|
/// decide the consignment's routing and its pricing zone.
|
||||||
|
///
|
||||||
|
/// Every field is optional and only non-empty ones are applied server-side,
|
||||||
|
/// so this is a correction and never a wipe. Must land **before**
|
||||||
|
/// [pickupComplete], which builds the consignment from these values; the
|
||||||
|
/// handler refuses it once the booking has been converted.
|
||||||
|
static Future<ApiResult> updateBookingAddresses(
|
||||||
|
Object bookingId, {
|
||||||
|
String? pickupAddress,
|
||||||
|
String? pickupPincode,
|
||||||
|
double? pickupLat,
|
||||||
|
double? pickupLon,
|
||||||
|
String? deliveryAddress,
|
||||||
|
String? deliveryPincode,
|
||||||
|
double? deliveryLat,
|
||||||
|
double? deliveryLon,
|
||||||
|
String? deliveryCity,
|
||||||
|
}) => _send(
|
||||||
|
'PATCH',
|
||||||
|
'/miler/bookings/$bookingId/addresses',
|
||||||
|
body: {
|
||||||
|
if (pickupAddress != null && pickupAddress.isNotEmpty)
|
||||||
|
'pickupaddress': pickupAddress,
|
||||||
|
if (pickupPincode != null && pickupPincode.isNotEmpty)
|
||||||
|
'pickuppincode': pickupPincode,
|
||||||
|
if (pickupLat != null && pickupLat != 0) 'pickuplatitude': pickupLat,
|
||||||
|
if (pickupLon != null && pickupLon != 0) 'pickuplongitude': pickupLon,
|
||||||
|
if (deliveryAddress != null && deliveryAddress.isNotEmpty)
|
||||||
|
'deliveryaddress': deliveryAddress,
|
||||||
|
if (deliveryPincode != null && deliveryPincode.isNotEmpty)
|
||||||
|
'deliverypincode': deliveryPincode,
|
||||||
|
if (deliveryLat != null && deliveryLat != 0)
|
||||||
|
'deliverylatitude': deliveryLat,
|
||||||
|
if (deliveryLon != null && deliveryLon != 0)
|
||||||
|
'deliverylongitude': deliveryLon,
|
||||||
|
if (deliveryCity != null && deliveryCity.isNotEmpty)
|
||||||
|
'deliverycity': deliveryCity,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/// Step 2. The dimensions here are what `pickup-complete` recomputes
|
/// Step 2. The dimensions here are what `pickup-complete` recomputes
|
||||||
/// chargeable weight from, so this is the one moment the parcel's measured
|
/// chargeable weight from, so this is the one moment the parcel's measured
|
||||||
@@ -325,9 +575,16 @@ class MilerApi {
|
|||||||
static Future<ApiResult> submitParcels(
|
static Future<ApiResult> submitParcels(
|
||||||
Object bookingId,
|
Object bookingId,
|
||||||
List<ParcelEntry> parcels,
|
List<ParcelEntry> parcels,
|
||||||
) => _send('POST', '/miler/bookings/$bookingId/parcel', body: {
|
) => _guarded(
|
||||||
|
'parcel:$bookingId',
|
||||||
|
() => _send(
|
||||||
|
'POST',
|
||||||
|
'/miler/bookings/$bookingId/parcel',
|
||||||
|
body: {
|
||||||
'parcels': [for (final p in parcels) p.toJson()],
|
'parcels': [for (final p in parcels) p.toJson()],
|
||||||
});
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
/// Step 3. `amount` must be > 0; `paymentmode` is one of [paymentModes].
|
/// Step 3. `amount` must be > 0; `paymentmode` is one of [paymentModes].
|
||||||
static Future<ApiResult> submitPayment(
|
static Future<ApiResult> submitPayment(
|
||||||
@@ -335,11 +592,19 @@ class MilerApi {
|
|||||||
required double amount,
|
required double amount,
|
||||||
required String paymentMode,
|
required String paymentMode,
|
||||||
String? transactionRef,
|
String? transactionRef,
|
||||||
}) => _send('POST', '/miler/bookings/$bookingId/payment', body: {
|
}) => _guarded(
|
||||||
|
'payment:$bookingId',
|
||||||
|
() => _send(
|
||||||
|
'POST',
|
||||||
|
'/miler/bookings/$bookingId/payment',
|
||||||
|
idempotencyKey: _idempotencyKey('payment', bookingId),
|
||||||
|
body: {
|
||||||
'amount': amount,
|
'amount': amount,
|
||||||
'paymentmode': paymentMode,
|
'paymentmode': paymentMode,
|
||||||
'transactionref': transactionRef ?? '',
|
'transactionref': transactionRef ?? '',
|
||||||
});
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
static const List<String> paymentModes = ['Cash', 'UPI', 'Card', 'Wallet'];
|
static const List<String> paymentModes = ['Cash', 'UPI', 'Card', 'Wallet'];
|
||||||
|
|
||||||
@@ -348,14 +613,84 @@ class MilerApi {
|
|||||||
/// step 2, and decides routing — a shared 3-digit pincode prefix means
|
/// step 2, and decides routing — a shared 3-digit pincode prefix means
|
||||||
/// hyperlocal and the consignment goes straight to `Out_for_Delivery` in this
|
/// hyperlocal and the consignment goes straight to `Out_for_Delivery` in this
|
||||||
/// rider's hands, otherwise it routes via the hub.
|
/// rider's hands, otherwise it routes via the hub.
|
||||||
|
/// A key that identifies **one rider action**, so a retry after a dropped
|
||||||
|
/// acknowledgement replays the first result instead of writing twice.
|
||||||
|
///
|
||||||
|
/// Derived, not random: the same action retried must produce the *same* key
|
||||||
|
/// or the replay never matches. `verb:resource:day` is stable across a
|
||||||
|
/// retry, an app restart and a lost response, while still letting a genuine
|
||||||
|
/// second attempt tomorrow through.
|
||||||
|
///
|
||||||
|
/// This complements [MutationGuard] rather than replacing it — the guard
|
||||||
|
/// stops a double *press* on this device, the key stops a double *write* on
|
||||||
|
/// the server after the network lost the first answer.
|
||||||
|
static String _idempotencyKey(String verb, Object resource) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final day =
|
||||||
|
'${now.year}${now.month.toString().padLeft(2, '0')}'
|
||||||
|
'${now.day.toString().padLeft(2, '0')}';
|
||||||
|
return '$verb:$resource:$day';
|
||||||
|
}
|
||||||
|
|
||||||
static Future<ApiResult> pickupComplete(
|
static Future<ApiResult> pickupComplete(
|
||||||
Object bookingId, {
|
Object bookingId, {
|
||||||
double? lat,
|
double? lat,
|
||||||
double? lon,
|
double? lon,
|
||||||
}) => _send('POST', '/miler/bookings/$bookingId/pickup-complete', body: {
|
}) => _guarded(
|
||||||
|
'pickup-complete:$bookingId',
|
||||||
|
() => _send(
|
||||||
|
'POST',
|
||||||
|
'/miler/bookings/$bookingId/pickup-complete',
|
||||||
|
idempotencyKey: _idempotencyKey('pickup-complete', bookingId),
|
||||||
|
body: {
|
||||||
if (lat != null) 'latitude': lat,
|
if (lat != null) 'latitude': lat,
|
||||||
if (lon != null) 'longitude': lon,
|
if (lon != null) 'longitude': lon,
|
||||||
});
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// THE RELEASE — collected → out for delivery
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Moves a `Collected_By_Miler` consignment to `Out_for_Delivery`.
|
||||||
|
///
|
||||||
|
/// ── This is the step the app spent months without ──
|
||||||
|
///
|
||||||
|
/// `pickup-complete` used to push hyperlocal work straight to
|
||||||
|
/// `Out_for_Delivery`, so the hub saw a rider "actively delivering" food
|
||||||
|
/// still on the kitchen counter, and there was no server state for
|
||||||
|
/// *collected, holding*. The app modelled the difference locally, which the
|
||||||
|
/// hub could not see and a reinstall erased.
|
||||||
|
///
|
||||||
|
/// Now `pickup-complete` lands on `Collected_By_Miler` and this call — the
|
||||||
|
/// rider pressing **Start round** — makes the release. Two consequences
|
||||||
|
/// worth knowing:
|
||||||
|
///
|
||||||
|
/// • **The receiver OTP is issued here**, not at pickup, so a code is not
|
||||||
|
/// in the customer's hands during the holding period.
|
||||||
|
/// • **The rider's availability flips** `Picked_Up` → `On_Delivery`.
|
||||||
|
///
|
||||||
|
/// Replaces the old `POST /miler/deliveries/start`, which never existed.
|
||||||
|
static Future<ApiResult> startDelivery(Object consignmentId) => _guarded(
|
||||||
|
'start-delivery:$consignmentId',
|
||||||
|
() => _send(
|
||||||
|
'POST',
|
||||||
|
'/miler/consignments/$consignmentId/start-delivery',
|
||||||
|
idempotencyKey: _idempotencyKey('start-delivery', consignmentId),
|
||||||
|
body: const {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The consignment's current state and what may be done to it.
|
||||||
|
///
|
||||||
|
/// Returns `status` plus the backend's own derived flags — `collected`,
|
||||||
|
/// `out_for_delivery`, `delivered`, `can_start_delivery`, `can_deliver`,
|
||||||
|
/// `can_skip` — and the COD figures. Replaces walking
|
||||||
|
/// `GET /miler/consignments/logs/:id` and reading the last event, which was
|
||||||
|
/// a history replay standing in for a state read.
|
||||||
|
static Future<ApiResult> consignment(Object consignmentId) =>
|
||||||
|
_send('GET', '/miler/consignments/$consignmentId');
|
||||||
|
|
||||||
/// Query strings, not a body — the handler reads `c.Query`.
|
/// Query strings, not a body — the handler reads `c.Query`.
|
||||||
static Future<ApiResult> vehicleRequired(
|
static Future<ApiResult> vehicleRequired(
|
||||||
@@ -372,19 +707,96 @@ class MilerApi {
|
|||||||
static Future<ApiResult> cancelBooking(
|
static Future<ApiResult> cancelBooking(
|
||||||
Object bookingId, {
|
Object bookingId, {
|
||||||
required String reason,
|
required String reason,
|
||||||
}) => _send('POST', '/miler/bookings/$bookingId/cancel', body: {
|
}) => _guarded(
|
||||||
'reason': reason,
|
'cancel:$bookingId',
|
||||||
});
|
() => _send(
|
||||||
|
'POST',
|
||||||
|
'/miler/bookings/$bookingId/cancel',
|
||||||
|
body: {'reason': reason},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// PRICING — what this shipment costs, from the real numbers
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// The fare band for a shipment, quoted at the door.
|
||||||
|
///
|
||||||
|
/// `POST /api/v1/pricing/check` — note it is **not** under `/miler`, and it
|
||||||
|
/// takes no auth. The zone is derived server-side from the two pincodes when
|
||||||
|
/// [zone] is omitted, which is the path the rider app uses: he has just
|
||||||
|
/// captured both addresses and has no business deciding what a zone is.
|
||||||
|
///
|
||||||
|
/// Returns `{found, zone, service_type, weight, currency, results: [{category,
|
||||||
|
/// category_label, min_price, max_price}]}` — a *band* per category, not a
|
||||||
|
/// single number, because that is what the pricing table holds. When `found`
|
||||||
|
/// is false the combination has no rule configured and the rider must be told
|
||||||
|
/// that rather than shown a zero. See [ShipmentQuote].
|
||||||
|
static Future<ApiResult> checkPrice({
|
||||||
|
required double weight,
|
||||||
|
required String serviceType,
|
||||||
|
String? zone,
|
||||||
|
String? pickupPincode,
|
||||||
|
String? deliveryPincode,
|
||||||
|
String? category,
|
||||||
|
}) => _send(
|
||||||
|
'POST',
|
||||||
|
'/pricing/check',
|
||||||
|
body: {
|
||||||
|
'weight': weight,
|
||||||
|
'service_type': serviceType,
|
||||||
|
if (zone != null && zone.isNotEmpty) 'zone': zone,
|
||||||
|
if (pickupPincode != null && pickupPincode.isNotEmpty)
|
||||||
|
'pickup_pincode': pickupPincode,
|
||||||
|
if (deliveryPincode != null && deliveryPincode.isNotEmpty)
|
||||||
|
'delivery_pincode': deliveryPincode,
|
||||||
|
if (category != null && category.isNotEmpty) 'category': category,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The service tiers the pricing table is keyed on. `Express` — not
|
||||||
|
/// `Fast`/`Superfast`, which is what the *booking* service options use.
|
||||||
|
static const List<String> serviceTypes = ['Normal', 'Express'];
|
||||||
|
|
||||||
|
/// Parcel categories the pricing table recognises.
|
||||||
|
static const List<String> parcelCategories = [
|
||||||
|
'General',
|
||||||
|
'Documents',
|
||||||
|
'Electronics',
|
||||||
|
'Clothing',
|
||||||
|
'Fragile',
|
||||||
|
'Medical',
|
||||||
|
'Automotive',
|
||||||
|
'Food',
|
||||||
|
];
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// DELIVERY
|
// DELIVERY
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ── `POST /miler/deliveries/start` is not on this API ──
|
||||||
|
//
|
||||||
|
// It was declared here and called after every pickup. It is absent from the
|
||||||
|
// contract's 38 routes and from the backend's route table, so it 404'd every
|
||||||
|
// time — and the app was built to believe a consignment only became
|
||||||
|
// deliverable once it had "worked".
|
||||||
|
//
|
||||||
|
// It never needed to. `pickup-complete` decides routing itself: matching
|
||||||
|
// 3-digit pickup and delivery pincode prefixes are hyperlocal and the
|
||||||
|
// consignment goes straight to `Out_for_Delivery` in the rider's hands. There
|
||||||
|
// is no rider-facing release step to make, so there is no endpoint to call.
|
||||||
|
//
|
||||||
|
// Do not add it back without a contract change. What the rider presses is a
|
||||||
|
// *local* record of having set off — see `OrderEvent.outForDelivery`.
|
||||||
|
|
||||||
/// The consignment must be `Out_for_Delivery` or this is a 400.
|
/// The consignment must be `Out_for_Delivery` or this is a 400.
|
||||||
///
|
///
|
||||||
/// `otp` is only required when the tenant has `requiredeliveryotp` on — it is
|
/// `otp` is **optional**. There is no delivery-OTP column on consignments and
|
||||||
/// off by default. When it is on the code is checked server-side, so a
|
/// nothing generates one, so the handler cannot check a code against
|
||||||
/// non-empty string is not enough.
|
/// anything; it records whether one was presented. Requiring it used to force
|
||||||
|
/// any caller without an OTP — a milk-run drop, where a subscriber's lunch is
|
||||||
|
/// handed over with no code — to invent a value, which is worse than
|
||||||
|
/// recording that there was none. Do not start sending a placeholder.
|
||||||
///
|
///
|
||||||
/// `lat`/`lon` must be the actual delivery point: the server computes
|
/// `lat`/`lon` must be the actual delivery point: the server computes
|
||||||
/// `riderkms` from the pickup coords by haversine and writes it onto the
|
/// `riderkms` from the pickup coords by haversine and writes it onto the
|
||||||
@@ -398,14 +810,22 @@ class MilerApi {
|
|||||||
String? receiverSignatureUrl,
|
String? receiverSignatureUrl,
|
||||||
double? lat,
|
double? lat,
|
||||||
double? lon,
|
double? lon,
|
||||||
}) => _send('POST', '/miler/consignments/$consignmentId/deliver', body: {
|
}) => _guarded(
|
||||||
|
'deliver:$consignmentId',
|
||||||
|
() => _send(
|
||||||
|
'POST',
|
||||||
|
'/miler/consignments/$consignmentId/deliver',
|
||||||
|
idempotencyKey: _idempotencyKey('deliver', consignmentId),
|
||||||
|
body: {
|
||||||
'deliveredtoname': deliveredToName,
|
'deliveredtoname': deliveredToName,
|
||||||
if (otp != null && otp.isNotEmpty) 'otp': otp,
|
if (otp != null && otp.isNotEmpty) 'otp': otp,
|
||||||
'photourl': photoUrl ?? '',
|
'photourl': photoUrl ?? '',
|
||||||
'receiversignatureurl': receiverSignatureUrl ?? '',
|
'receiversignatureurl': receiverSignatureUrl ?? '',
|
||||||
if (lat != null) 'lat': lat,
|
if (lat != null) 'lat': lat,
|
||||||
if (lon != null) 'lon': lon,
|
if (lon != null) 'lon': lon,
|
||||||
});
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
/// Bumps `attemptcount` rather than failing the consignment — a skip is a
|
/// Bumps `attemptcount` rather than failing the consignment — a skip is a
|
||||||
/// return visit, not an outcome.
|
/// return visit, not an outcome.
|
||||||
@@ -414,28 +834,43 @@ class MilerApi {
|
|||||||
required String reason,
|
required String reason,
|
||||||
double? lat,
|
double? lat,
|
||||||
double? lon,
|
double? lon,
|
||||||
}) => _send('POST', '/miler/consignments/$consignmentId/skip', body: {
|
}) => _guarded(
|
||||||
|
'skip:$consignmentId',
|
||||||
|
() => _send(
|
||||||
|
'POST',
|
||||||
|
'/miler/consignments/$consignmentId/skip',
|
||||||
|
idempotencyKey: _idempotencyKey('skip', consignmentId),
|
||||||
|
body: {
|
||||||
'reason': reason,
|
'reason': reason,
|
||||||
if (lat != null) 'lat': lat,
|
if (lat != null) 'lat': lat,
|
||||||
if (lon != null) 'lon': lon,
|
if (lon != null) 'lon': lon,
|
||||||
});
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// BOOKINGS & EARNINGS
|
// BOOKINGS & EARNINGS
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
static Future<ApiResult> bookings({String? status, String? date}) =>
|
static Future<ApiResult> bookings({String? status, String? date}) => _send(
|
||||||
_send('GET', '/miler/bookings', query: {
|
'GET',
|
||||||
|
'/miler/bookings',
|
||||||
|
query: {
|
||||||
if (status != null && status.isNotEmpty) 'status': status,
|
if (status != null && status.isNotEmpty) 'status': status,
|
||||||
if (date != null && date.isNotEmpty) 'date': date,
|
if (date != null && date.isNotEmpty) 'date': date,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/// `bonuspoints` on this response is always zero — nothing writes it yet.
|
/// `bonuspoints` on this response is always zero — nothing writes it yet.
|
||||||
static Future<ApiResult> earnings({String period = 'daily', String? date}) =>
|
static Future<ApiResult> earnings({String period = 'daily', String? date}) =>
|
||||||
_send('GET', '/miler/earnings', query: {
|
_send(
|
||||||
|
'GET',
|
||||||
|
'/miler/earnings',
|
||||||
|
query: {
|
||||||
'period': period,
|
'period': period,
|
||||||
if (date != null && date.isNotEmpty) 'date': date,
|
if (date != null && date.isNotEmpty) 'date': date,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// TELEMETRY — Redis-backed, high frequency
|
// TELEMETRY — Redis-backed, high frequency
|
||||||
@@ -464,7 +899,10 @@ class MilerApi {
|
|||||||
String? locationService,
|
String? locationService,
|
||||||
bool isBackground = false,
|
bool isBackground = false,
|
||||||
DateTime? at,
|
DateTime? at,
|
||||||
}) => _send('POST', '/miler/logs', body: {
|
}) => _send(
|
||||||
|
'POST',
|
||||||
|
'/miler/logs',
|
||||||
|
body: {
|
||||||
'logdate': logStamp(at),
|
'logdate': logStamp(at),
|
||||||
'latitude': _str(latitude),
|
'latitude': _str(latitude),
|
||||||
'longitude': _str(longitude),
|
'longitude': _str(longitude),
|
||||||
@@ -478,7 +916,8 @@ class MilerApi {
|
|||||||
if (connection != null) 'connection': connection,
|
if (connection != null) 'connection': connection,
|
||||||
if (locationService != null) 'location_service': locationService,
|
if (locationService != null) 'location_service': locationService,
|
||||||
'is_background': isBackground,
|
'is_background': isBackground,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
static Future<ApiResult> getLogs() => _send('GET', '/miler/logs');
|
static Future<ApiResult> getLogs() => _send('GET', '/miler/logs');
|
||||||
|
|
||||||
@@ -521,10 +960,11 @@ class MilerApi {
|
|||||||
static Future<ApiResult> createSupportTicket({
|
static Future<ApiResult> createSupportTicket({
|
||||||
required String subject,
|
required String subject,
|
||||||
required String description,
|
required String description,
|
||||||
}) => _send('POST', '/miler/support', body: {
|
}) => _send(
|
||||||
'subject': subject,
|
'POST',
|
||||||
'description': description,
|
'/miler/support',
|
||||||
});
|
body: {'subject': subject, 'description': description},
|
||||||
|
);
|
||||||
|
|
||||||
static Future<ApiResult> supportTickets() => _send('GET', '/miler/support');
|
static Future<ApiResult> supportTickets() => _send('GET', '/miler/support');
|
||||||
|
|
||||||
@@ -540,6 +980,15 @@ class MilerApi {
|
|||||||
'a real notifications table with read state',
|
'a real notifications table with read state',
|
||||||
'anything that writes bonuspoints',
|
'anything that writes bonuspoints',
|
||||||
'cancelled / total counts on GET /miler/earnings',
|
'cancelled / total counts on GET /miler/earnings',
|
||||||
|
// The quoted fare has nowhere to be recorded: `POST /bookings/:id/payment`
|
||||||
|
// stores what was COLLECTED, and `bookingserviceoptions.estimatedprice` is
|
||||||
|
// written when the customer books and is not rider-writable. So a rider who
|
||||||
|
// re-prices a shipment at the door leaves the original estimate on the
|
||||||
|
// booking and only the collected amount reflects the new figure.
|
||||||
|
'a rider-writable quoted price on the booking (only the collected amount lands)',
|
||||||
|
// A delivery-OTP column on consignments. Until one exists, `deliver`
|
||||||
|
// records whether a code was presented, not whether it was correct.
|
||||||
|
'a real delivery OTP on consignments (deliver cannot verify a code today)',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,6 +1021,113 @@ class ParcelEntry {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the pricing table says this shipment costs.
|
||||||
|
///
|
||||||
|
/// ── Why a band and not a number ──
|
||||||
|
///
|
||||||
|
/// `POST /pricing/check` answers with a min/max per category, because that is
|
||||||
|
/// what the table holds — a rule is a weight slab in a zone, and it prices a
|
||||||
|
/// range. The rider has to quote one figure to a customer, so [payable] takes
|
||||||
|
/// the **minimum**: it is the number the customer was shown when the booking
|
||||||
|
/// was raised, and quoting the top of a band at the door is how a rider ends up
|
||||||
|
/// arguing about money on a doorstep.
|
||||||
|
///
|
||||||
|
/// [found] false means no rule covers this weight/zone/category combination.
|
||||||
|
/// That is not a zero — it is "this app cannot price this", and the rider must
|
||||||
|
/// be told so rather than shown a free shipment. Nothing here invents a fare.
|
||||||
|
@immutable
|
||||||
|
class ShipmentQuote {
|
||||||
|
/// True when the table had a rule for this combination.
|
||||||
|
final bool found;
|
||||||
|
|
||||||
|
/// `Local` / `Regional` / `National`, as resolved from the two pincodes.
|
||||||
|
final String zone;
|
||||||
|
final String serviceType;
|
||||||
|
final double weight;
|
||||||
|
final String currency;
|
||||||
|
final String category;
|
||||||
|
final double minPrice;
|
||||||
|
final double maxPrice;
|
||||||
|
|
||||||
|
/// The server's own words when it could not price this.
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const ShipmentQuote({
|
||||||
|
required this.found,
|
||||||
|
this.zone = '',
|
||||||
|
this.serviceType = '',
|
||||||
|
this.weight = 0,
|
||||||
|
this.currency = 'INR',
|
||||||
|
this.category = '',
|
||||||
|
this.minPrice = 0,
|
||||||
|
this.maxPrice = 0,
|
||||||
|
this.message = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The figure quoted to the customer and taken at the door.
|
||||||
|
double get payable => minPrice;
|
||||||
|
|
||||||
|
/// True when the band is wide enough that the two ends are different money.
|
||||||
|
bool get isRange => maxPrice > minPrice;
|
||||||
|
|
||||||
|
static double _num(dynamic v) {
|
||||||
|
if (v is num) return v.toDouble();
|
||||||
|
return double.tryParse(v?.toString() ?? '') ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the `data` block of a `/pricing/check` response.
|
||||||
|
///
|
||||||
|
/// [preferredCategory] picks a row out of `results` when the rider named a
|
||||||
|
/// category; without one the cheapest row wins, for the same reason [payable]
|
||||||
|
/// takes the minimum.
|
||||||
|
factory ShipmentQuote.fromData(
|
||||||
|
Map<String, dynamic> data, {
|
||||||
|
String preferredCategory = '',
|
||||||
|
}) {
|
||||||
|
final found = data['found'] == true;
|
||||||
|
final results = (data['results'] is List)
|
||||||
|
? (data['results'] as List).whereType<Map>().toList()
|
||||||
|
: const <Map>[];
|
||||||
|
|
||||||
|
if (!found || results.isEmpty) {
|
||||||
|
return ShipmentQuote(
|
||||||
|
found: false,
|
||||||
|
zone: (data['zone'] ?? '').toString(),
|
||||||
|
serviceType: (data['service_type'] ?? '').toString(),
|
||||||
|
weight: _num(data['weight']),
|
||||||
|
message: (data['message'] ?? 'no price configured for this shipment')
|
||||||
|
.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map? chosen;
|
||||||
|
if (preferredCategory.isNotEmpty) {
|
||||||
|
for (final r in results) {
|
||||||
|
if ((r['category'] ?? '').toString().toLowerCase() ==
|
||||||
|
preferredCategory.toLowerCase()) {
|
||||||
|
chosen = r;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chosen ??= results.reduce(
|
||||||
|
(a, b) => _num(a['min_price']) <= _num(b['min_price']) ? a : b,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ShipmentQuote(
|
||||||
|
found: true,
|
||||||
|
zone: (data['zone'] ?? '').toString(),
|
||||||
|
serviceType: (data['service_type'] ?? '').toString(),
|
||||||
|
weight: _num(data['weight']),
|
||||||
|
currency: (data['currency'] ?? 'INR').toString(),
|
||||||
|
category: (chosen['category_label'] ?? chosen['category'] ?? '')
|
||||||
|
.toString(),
|
||||||
|
minPrice: _num(chosen['min_price']),
|
||||||
|
maxPrice: _num(chosen['max_price']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One `ConsignmentLog`. Numeric fields are strings, as with [MilerApi.postLog].
|
/// One `ConsignmentLog`. Numeric fields are strings, as with [MilerApi.postLog].
|
||||||
class ConsignmentLogEntry {
|
class ConsignmentLogEntry {
|
||||||
final Object consignmentId;
|
final Object consignmentId;
|
||||||
@@ -622,6 +1178,16 @@ class ApiResult {
|
|||||||
final bool ok;
|
final bool ok;
|
||||||
final int status;
|
final int status;
|
||||||
|
|
||||||
|
/// The backend's stable machine-readable failure code — `INVALID_STATE`,
|
||||||
|
/// `OTP_REQUIRED`, `CONSIGNMENT_NOT_ASSIGNED`, `IDEMPOTENCY_IN_PROGRESS`…
|
||||||
|
///
|
||||||
|
/// Added to the `/miler/*` 4xx envelope on 21 Aug 2026. **Branch on this,
|
||||||
|
/// never on [message]** — string-matching a human sentence is how the app
|
||||||
|
/// came to tell riders "the hub hasn't released this" for a consignment that
|
||||||
|
/// had already been delivered. Empty on success and on any route that does
|
||||||
|
/// not carry one yet.
|
||||||
|
final String code;
|
||||||
|
|
||||||
/// The envelope's `data`, or the whole body when there is no `data` key.
|
/// The envelope's `data`, or the whole body when there is no `data` key.
|
||||||
final dynamic data;
|
final dynamic data;
|
||||||
|
|
||||||
@@ -637,11 +1203,20 @@ class ApiResult {
|
|||||||
this.data,
|
this.data,
|
||||||
this.raw,
|
this.raw,
|
||||||
this.message = '',
|
this.message = '',
|
||||||
|
this.code = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// True when the backend refused because the entity is in the wrong state.
|
||||||
|
bool get isInvalidState => code == 'INVALID_STATE';
|
||||||
|
|
||||||
|
/// True when an identical request is still running server-side — retry
|
||||||
|
/// shortly rather than treating it as a failure.
|
||||||
|
bool get isInFlight => code == 'IDEMPOTENCY_IN_PROGRESS';
|
||||||
|
|
||||||
/// [data] as a map, or an empty one.
|
/// [data] as a map, or an empty one.
|
||||||
Map<String, dynamic> get map =>
|
Map<String, dynamic> get map => data is Map
|
||||||
data is Map ? Map<String, dynamic>.from(data as Map) : <String, dynamic>{};
|
? Map<String, dynamic>.from(data as Map)
|
||||||
|
: <String, dynamic>{};
|
||||||
|
|
||||||
/// [data] as a list, tolerating the envelope shapes seen in the wild:
|
/// [data] as a list, tolerating the envelope shapes seen in the wild:
|
||||||
/// `[…]`, `{data: […]}`, `{data: {items: […]}}`, `{bookings: […]}`.
|
/// `[…]`, `{data: […]}`, `{data: {items: […]}}`, `{bookings: […]}`.
|
||||||
|
|||||||
441
lib/data/milk_run.dart
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
import 'package:miler/Models/stop_status.dart';
|
||||||
|
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||||||
|
import 'package:miler/data/service_profile.dart';
|
||||||
|
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// THE MILK RUN — accept per order, collect per kitchen, deliver per order.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// HOME DELIVERIES ACTIVITY
|
||||||
|
/// ──── ────────── ────────
|
||||||
|
/// 6 orders ─ accept ─┬─ Vidhya ×3 ─ arrived ─ picked ─▶ deliver ─▶ done
|
||||||
|
/// ├─ Priyanka×2 ─ arrived ─ picked ─▶ deliver ─▶ done
|
||||||
|
/// └─ ABC ×1 ─ arrived ─ picked ─▶ deliver ─▶ done
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ── The three units, and why they differ ──
|
||||||
|
///
|
||||||
|
/// **Acceptance is per order.** The rider takes on the morning's work in one
|
||||||
|
/// press, whichever counters it comes from.
|
||||||
|
///
|
||||||
|
/// **Pickup is per kitchen.** Three orders from Vidhya Kitchen are one ride,
|
||||||
|
/// one counter and one handover. He arrives once and is then given bags one at
|
||||||
|
/// a time, so arrival is grouped and collection is per bag — and a selection
|
||||||
|
/// must never span two kitchens, or he posts "arrived" for a shop he is
|
||||||
|
/// nowhere near. See [sameSourceAs].
|
||||||
|
///
|
||||||
|
/// **Delivery is per order.** Each bag goes to its own door.
|
||||||
|
///
|
||||||
|
/// Accepted orders therefore stay individually visible on Home; the kitchen is
|
||||||
|
/// what bounds a pickup operation, not what replaces the cards.
|
||||||
|
///
|
||||||
|
/// ── Collected work goes out immediately ──
|
||||||
|
///
|
||||||
|
/// There is no "start delivery" gate. A kitchen's orders become live
|
||||||
|
/// deliveries the moment they are in the rider's hands, so he can drop the
|
||||||
|
/// first three while a second kitchen is still cooking. Holding them until
|
||||||
|
/// every counter was done made the first customers wait for food already on
|
||||||
|
/// the bike.
|
||||||
|
///
|
||||||
|
/// ── How client stages map to the real backend ──
|
||||||
|
///
|
||||||
|
/// The UI has more stages than the API has statuses, which is fine as long as
|
||||||
|
/// every one either writes something real or is honestly local. None invents a
|
||||||
|
/// server state:
|
||||||
|
///
|
||||||
|
/// | Rider does | UI stage | Server call |
|
||||||
|
/// |-------------------|--------------------|--------------------------------------|
|
||||||
|
/// | Accepts work | `accepted` | `POST /assignments/:aid/accept` |
|
||||||
|
/// | Reaches a kitchen | `arrived` | `POST /bookings/:id/reached` |
|
||||||
|
/// | Takes a bag | `picked` | `POST /bookings/:id/pickup-complete` |
|
||||||
|
/// | (rides on) | `outForDelivery` | `POST /deliveries/start` ¹ |
|
||||||
|
/// | Reaches a door | `deliveryArrived` | *(local — no route exists)* ² |
|
||||||
|
/// | Hands it over | `delivered` | `POST /consignments/:cid/deliver` |
|
||||||
|
///
|
||||||
|
/// ¹ **not deployed.** Fired best-effort as part of pickup; until it ships the
|
||||||
|
/// consignments stay `Inwarded_at_Hub` and `deliver` refuses them.
|
||||||
|
/// ² **no endpoint exists.** See [deliveryArrivalIsLocalOnly].
|
||||||
|
///
|
||||||
|
/// Everything here is a pure function over the stop maps the rest of the app
|
||||||
|
/// already passes around, so it can be tested without a widget tree, a GetX
|
||||||
|
/// container or a network. Same reasoning as `AssignmentLookup.buildIndex`.
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
class MilkRun {
|
||||||
|
MilkRun._();
|
||||||
|
|
||||||
|
/// **BACKEND DEPENDENCY.** There is no route that records a rider arriving at
|
||||||
|
/// a *delivery* address — `/bookings/:id/reached` keys on a booking in its
|
||||||
|
/// pickup phase, and the consignment routes have no arrival event. So the
|
||||||
|
/// `deliveryArrived` rung is held on the device and the hub sees the round
|
||||||
|
/// jump from out-for-delivery straight to delivered.
|
||||||
|
///
|
||||||
|
/// Nothing is faked to cover this: no call is made and no success is
|
||||||
|
/// invented. The rung exists because the *rider* needs the distinction — it
|
||||||
|
/// is what turns his Deliver button on at the right door — and the flag is
|
||||||
|
/// here so the gap is greppable rather than folklore.
|
||||||
|
static const bool deliveryArrivalIsLocalOnly = true;
|
||||||
|
|
||||||
|
/// Order id of a stop, however the payload spells it.
|
||||||
|
static String idOf(Map<String, dynamic> stop) =>
|
||||||
|
(stop['orderid'] ?? stop['orderId'] ?? '').toString();
|
||||||
|
|
||||||
|
/// The consignment this stop became once it was collected, or '' before that.
|
||||||
|
///
|
||||||
|
/// The delivery route keys on this and not on the booking id — they come from
|
||||||
|
/// different sequences, the same trap [AssignmentLookup] exists for on the
|
||||||
|
/// accept side. A stop without one cannot be delivered yet.
|
||||||
|
static String consignmentIdOf(Map<String, dynamic> stop) =>
|
||||||
|
(stop['consignmentid'] ?? stop['consignmentId'] ?? '').toString().trim();
|
||||||
|
|
||||||
|
// ── Which counter a stop is collected from ──
|
||||||
|
//
|
||||||
|
// Pickup is grouped by kitchen even though acceptance and delivery are per
|
||||||
|
// order. Three orders from Vidhya Kitchen are one ride, one counter and one
|
||||||
|
// handover; the rider works them together and must not be able to sweep an
|
||||||
|
// order from a different kitchen into that operation.
|
||||||
|
//
|
||||||
|
// The key is the **stable id** where the payload carries one, because two
|
||||||
|
// kitchens can share a display name across areas and the id is what the hub
|
||||||
|
// controls. A name-only payload still groups rather than collapsing into one
|
||||||
|
// nameless pile, and a stop with neither gets its own bucket rather than a
|
||||||
|
// fabricated kitchen.
|
||||||
|
|
||||||
|
/// The stable key a stop groups under for pickup.
|
||||||
|
static String sourceKeyOf(Map<String, dynamic> stop) {
|
||||||
|
final id =
|
||||||
|
(stop['sourceid'] ??
|
||||||
|
stop['kitchenid'] ??
|
||||||
|
stop['pickuplocationid'] ??
|
||||||
|
'')
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
if (id.isNotEmpty && id != '0') return 'id:$id';
|
||||||
|
final name = sourceNameOf(stop).toLowerCase();
|
||||||
|
if (name.isNotEmpty) return 'name:$name';
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The kitchen's name as the rider reads it, or '' when the payload has none.
|
||||||
|
/// **The** reader for a place's name. `stopSourceName` delegates here.
|
||||||
|
///
|
||||||
|
/// ── Two readers, one question, different answers ──
|
||||||
|
///
|
||||||
|
/// This read `sourcename` / `kitchenname`. `stopSourceName` read those *and*
|
||||||
|
/// the CamelCase `KitchenName` / `SourceName` the payload sometimes carries.
|
||||||
|
/// A booking with only the capitalised key therefore had a source according
|
||||||
|
/// to one function and none according to the other, and the route card used
|
||||||
|
/// both in the same expression:
|
||||||
|
///
|
||||||
|
/// • `stopSourceName` said "there is a counter", so the group was **not**
|
||||||
|
/// flat — the header became a foldable place with its orders under it.
|
||||||
|
/// • `sourceNameOf` said "there is no counter", so `navigationLabel` fell
|
||||||
|
/// through to its leg description and the header was titled **"pickup"**.
|
||||||
|
///
|
||||||
|
/// A group headed by the word *pickup* that folds open onto one order named
|
||||||
|
/// after the same stop — which is the shape the `flat` flag exists to
|
||||||
|
/// prevent, produced by the two halves of the decision disagreeing.
|
||||||
|
///
|
||||||
|
/// One key list, one answer.
|
||||||
|
static String sourceNameOf(Map<String, dynamic> stop) =>
|
||||||
|
(stop['sourcename'] ??
|
||||||
|
stop['SourceName'] ??
|
||||||
|
stop['kitchenname'] ??
|
||||||
|
stop['KitchenName'] ??
|
||||||
|
'')
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
/// True when two stops are collected from the same counter.
|
||||||
|
static bool sameSource(Map<String, dynamic> a, Map<String, dynamic> b) =>
|
||||||
|
sourceKeyOf(a) == sourceKeyOf(b);
|
||||||
|
|
||||||
|
/// Every stop collected from the same counter as [stop], out of [stops].
|
||||||
|
///
|
||||||
|
/// What the rider ticks at a kitchen, and the bound on what one pickup
|
||||||
|
/// operation may touch.
|
||||||
|
static List<Map<String, dynamic>> sameSourceAs(
|
||||||
|
Map<String, dynamic> stop,
|
||||||
|
List<Map<String, dynamic>> stops,
|
||||||
|
) {
|
||||||
|
final key = sourceKeyOf(stop);
|
||||||
|
return [
|
||||||
|
for (final s in stops)
|
||||||
|
if (sourceKeyOf(s) == key) s,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
// WHERE "NAVIGATE" GOES
|
||||||
|
//
|
||||||
|
// The same button on the same order means two different places depending on
|
||||||
|
// where the order is in its day, and getting it wrong is expensive in both
|
||||||
|
// directions: sending a rider to a customer for an order still sitting in a
|
||||||
|
// kitchen wastes the trip *and* the customer's slot, and sending him back to
|
||||||
|
// a kitchen for a bag already in his box is a ride to collect nothing.
|
||||||
|
//
|
||||||
|
// So the destination follows the stop's stage, in one place, rather than
|
||||||
|
// being decided by whichever screen happens to own the button.
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Whether this stop's next journey is to the customer rather than the
|
||||||
|
/// kitchen.
|
||||||
|
///
|
||||||
|
/// True once it is collected. On a logistics booking this is always false:
|
||||||
|
/// that line's collected parcel goes to a hub and is delivered by somebody
|
||||||
|
/// else, so the rider never drives to its customer.
|
||||||
|
static bool navigatesToCustomer(
|
||||||
|
Map<String, dynamic> stop, {
|
||||||
|
Set<String> collectedIds = const {},
|
||||||
|
}) {
|
||||||
|
if (!ServiceProfile.active.deliversToCustomer) return false;
|
||||||
|
if (collectedIds.contains(idOf(stop))) return true;
|
||||||
|
final status = stopStatusOf(stop);
|
||||||
|
return status.isPicked || status.isDeliveryLeg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The kind of work this stop is **for the leg the rider is on**.
|
||||||
|
///
|
||||||
|
/// ── Why [stopKindOf] is not enough on its own ──
|
||||||
|
///
|
||||||
|
/// `stopKindOf` answers "what kind of stop is this?" from the payload, and the
|
||||||
|
/// payload is wrong about it on the one line where it matters most: the
|
||||||
|
/// booking adapter stamps `type: pickup` on every row it builds, because a v1
|
||||||
|
/// booking *is* a first-mile pickup and the backend has no per-stop type to
|
||||||
|
/// send. See `ApiConfig.pickupFromBooking`.
|
||||||
|
///
|
||||||
|
/// So a milk-run order the rider has already collected — a bag in his box, on
|
||||||
|
/// its way to a subscriber's door — still described itself as a pickup. The
|
||||||
|
/// card offered **Start Pickup**, the confirmation sheet asked him to
|
||||||
|
/// **Confirm pickup**, and the write path behind that button took the pickup
|
||||||
|
/// branch. He was being asked to collect something he was carrying.
|
||||||
|
///
|
||||||
|
/// The leg is the honest question, and this app already knows how to answer
|
||||||
|
/// it: [navigatesToCustomer] is true exactly when the load is in his hands.
|
||||||
|
/// Everything the rider reads and everything the button posts should follow
|
||||||
|
/// that, not the stamped type.
|
||||||
|
///
|
||||||
|
/// Returns [stopKindOf]'s answer unchanged on every other line and every
|
||||||
|
/// other stage, so a parcel booking is untouched.
|
||||||
|
static StopKind workingKind(
|
||||||
|
Map<String, dynamic> stop, {
|
||||||
|
Set<String> collectedIds = const {},
|
||||||
|
}) => navigatesToCustomer(stop, collectedIds: collectedIds)
|
||||||
|
? StopKind.delivery
|
||||||
|
: stopKindOf(stop);
|
||||||
|
|
||||||
|
/// Whether this stop's next rung is worked on **its own screen** — the map,
|
||||||
|
/// then I'VE ARRIVED, then the confirmation sheet — or in bulk on Home.
|
||||||
|
///
|
||||||
|
/// ── The rule ──
|
||||||
|
///
|
||||||
|
/// On **logistics**, every stop is worked on its own screen. The rider drives
|
||||||
|
/// to one customer, weighs one parcel, raises one shipment and takes one
|
||||||
|
/// payment; there is nothing to batch.
|
||||||
|
///
|
||||||
|
/// On a **kitchen line** the pickup half is not a per-stop journey at all. He
|
||||||
|
/// makes one trip to one counter and is handed a stack of bags, so the rungs
|
||||||
|
/// up to *picked up* are a bulk gesture on Home — select the orders, slide
|
||||||
|
/// once — and the single-stop map/arrive/confirm screen is only ever the
|
||||||
|
/// **delivery** leg, one subscriber's door at a time.
|
||||||
|
///
|
||||||
|
/// ── What it prevents ──
|
||||||
|
///
|
||||||
|
/// A stop still to be collected could be opened on that screen from the
|
||||||
|
/// Deliveries tab's live strip. It took the rider through a map, an I'VE
|
||||||
|
/// ARRIVED and a confirmation sheet for a collection he was supposed to make
|
||||||
|
/// at the kitchen with everything else — a second, contradictory way to work
|
||||||
|
/// the same rung, on the tab that is supposed to be his load. The screens ask
|
||||||
|
/// this before they offer that route in.
|
||||||
|
static bool worksOnOwnScreen(
|
||||||
|
Map<String, dynamic> stop, {
|
||||||
|
Set<String> collectedIds = const {},
|
||||||
|
}) =>
|
||||||
|
!ServiceProfile.active.handsOffAtCollection ||
|
||||||
|
navigatesToCustomer(stop, collectedIds: collectedIds);
|
||||||
|
|
||||||
|
/// The coordinates Navigate should open, or null when the stop carries none
|
||||||
|
/// for the leg it is on.
|
||||||
|
///
|
||||||
|
/// Returns null rather than falling back to the other end: a Navigate button
|
||||||
|
/// that quietly opens the wrong destination is worse than one that is
|
||||||
|
/// disabled, because the rider only finds out when he arrives.
|
||||||
|
static ({double lat, double lng})? navigationTarget(
|
||||||
|
Map<String, dynamic> stop, {
|
||||||
|
Set<String> collectedIds = const {},
|
||||||
|
}) {
|
||||||
|
double? read(List<String> keys) {
|
||||||
|
for (final k in keys) {
|
||||||
|
final v = double.tryParse('${stop[k] ?? ''}');
|
||||||
|
if (v != null && v != 0) return v;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigatesToCustomer(stop, collectedIds: collectedIds)) {
|
||||||
|
final lat = read(['droplat', 'DropLat', 'deliverylatitude']);
|
||||||
|
final lng = read(['droplon', 'DropLon', 'deliverylongitude']);
|
||||||
|
if (lat == null || lng == null) return null;
|
||||||
|
return (lat: lat, lng: lng);
|
||||||
|
}
|
||||||
|
|
||||||
|
final lat = read(['pickuplat', 'PickupLat', 'pickuplatitude']);
|
||||||
|
final lng = read([
|
||||||
|
'pickuplon',
|
||||||
|
'pickuplong',
|
||||||
|
'PickupLon',
|
||||||
|
'pickuplongitude',
|
||||||
|
]);
|
||||||
|
if (lat == null || lng == null) return null;
|
||||||
|
return (lat: lat, lng: lng);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What that destination is called, for the button and the sheet.
|
||||||
|
static String navigationLabel(
|
||||||
|
Map<String, dynamic> stop, {
|
||||||
|
Set<String> collectedIds = const {},
|
||||||
|
}) {
|
||||||
|
if (navigatesToCustomer(stop, collectedIds: collectedIds)) {
|
||||||
|
final name = (stop['pickupcustomer'] ?? stop['PickupCustomer'] ?? '')
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
return name.isEmpty ? 'customer' : name;
|
||||||
|
}
|
||||||
|
final kitchen = sourceNameOf(stop);
|
||||||
|
if (kitchen.isNotEmpty) return kitchen;
|
||||||
|
|
||||||
|
// ── The word "pickup" is never a place ──
|
||||||
|
//
|
||||||
|
// This returned the literal `'pickup'`, and on a payload with no source
|
||||||
|
// name — which is what the live backend sends today — that word became
|
||||||
|
// the 24sp headline of the rider's first group. The project's own rule
|
||||||
|
// ("never display 'pickup' when a real name exists") was being met to the
|
||||||
|
// letter and lost in spirit: no name existed, so a leg description was
|
||||||
|
// promoted to a title.
|
||||||
|
//
|
||||||
|
// A rider thinks in PLACES, and the payload still knows one: the pickup
|
||||||
|
// address. Its first non-numeric component is the neighbourhood — the
|
||||||
|
// same reading the timeline's area line uses — and "RS Puram" is
|
||||||
|
// something he can ride to in a way "pickup" is not. Only when the
|
||||||
|
// payload has no address either does the label fall back to a word, and
|
||||||
|
// then it is at least a capitalised noun.
|
||||||
|
for (final key in const ['pickupaddress', 'PickupAddress']) {
|
||||||
|
final raw = (stop[key] ?? '').toString().trim();
|
||||||
|
if (raw.isEmpty) continue;
|
||||||
|
for (final part in raw.split(',')) {
|
||||||
|
final p = part.trim();
|
||||||
|
if (p.isEmpty) continue;
|
||||||
|
// Skip a leading door/plot number — the rider navigates by the
|
||||||
|
// neighbourhood, not by "12/4".
|
||||||
|
if (RegExp(r'^[0-9/\-]+$').hasMatch(p)) continue;
|
||||||
|
// And when the component carries its own house number — "124
|
||||||
|
// Gandhipuram Main Road" — the number is still not the place. Strip
|
||||||
|
// the leading digit token and title the street.
|
||||||
|
return p.replaceFirst(RegExp(r'^[0-9][0-9/\-]*\s+'), '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'Pickup';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops still owing the rider a collection at a given counter.
|
||||||
|
///
|
||||||
|
/// A stop counts as outstanding when it has been accepted and is not yet in
|
||||||
|
/// his hands. Anything he has been told he will not get — not loaded by the
|
||||||
|
/// source, cancelled, rejected — is not outstanding: it is settled, and
|
||||||
|
/// holding the round open for it would strand him at a counter waiting for a
|
||||||
|
/// bag that is not coming.
|
||||||
|
static List<Map<String, dynamic>> outstandingPickups(
|
||||||
|
List<Map<String, dynamic>> stops, {
|
||||||
|
required Set<String> acceptedIds,
|
||||||
|
required Set<String> collectedIds,
|
||||||
|
Set<String> notLoadedIds = const {},
|
||||||
|
Set<String> rejectedIds = const {},
|
||||||
|
}) {
|
||||||
|
final out = <Map<String, dynamic>>[];
|
||||||
|
for (final stop in stops) {
|
||||||
|
final id = idOf(stop);
|
||||||
|
if (id.isEmpty) continue;
|
||||||
|
if (collectedIds.contains(id)) continue;
|
||||||
|
if (notLoadedIds.contains(id)) continue;
|
||||||
|
if (rejectedIds.contains(id)) continue;
|
||||||
|
|
||||||
|
final status = stopStatusOf(stop);
|
||||||
|
if (status.isCancelled || status.isRejected) continue;
|
||||||
|
// Already down the delivery leg — collected on a previous session whose
|
||||||
|
// local set was cleared. Not outstanding.
|
||||||
|
if (status.isDeliveryLeg || status.isPicked) continue;
|
||||||
|
|
||||||
|
// Only work he has taken on holds the round open. An un-accepted stop is
|
||||||
|
// an offer, and a rider must not be blocked from starting his round by
|
||||||
|
// work he never agreed to.
|
||||||
|
if (!acceptedIds.contains(id) && !status.isFinishedPickup) {
|
||||||
|
if (status.isPending) continue;
|
||||||
|
}
|
||||||
|
out.add(stop);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a stop is on the milk-run ladder, from the app's own records.
|
||||||
|
///
|
||||||
|
/// The local sets lead the server here, deliberately: the queue endpoints are
|
||||||
|
/// a poll or more behind the rider and a card that ignores what he just did
|
||||||
|
/// reads as a button that did nothing. The backend status is the tie-breaker
|
||||||
|
/// underneath, not the first word.
|
||||||
|
static StopStatus stageOf(
|
||||||
|
Map<String, dynamic> stop, {
|
||||||
|
required Set<String> acceptedIds,
|
||||||
|
required Set<String> collectedIds,
|
||||||
|
Set<String> outForDeliveryIds = const {},
|
||||||
|
Set<String> deliveredIds = const {},
|
||||||
|
}) {
|
||||||
|
final id = idOf(stop);
|
||||||
|
final reported = stopStatusOf(stop);
|
||||||
|
|
||||||
|
if (deliveredIds.contains(id) || reported.isDelivered) {
|
||||||
|
return StopStatus.delivered;
|
||||||
|
}
|
||||||
|
if (reported == StopStatus.deliveryArrived) {
|
||||||
|
return StopStatus.deliveryArrived;
|
||||||
|
}
|
||||||
|
// ── `Out_for_Delivery` is the consignment's state, not the rider's ──
|
||||||
|
//
|
||||||
|
// `pickup-complete` releases hyperlocal work itself: pickup and delivery
|
||||||
|
// pincodes sharing a 3-digit prefix means the parcel never sees a hub, so
|
||||||
|
// the consignment is stamped `Out_for_Delivery` in the same call that
|
||||||
|
// records the collection. Every DailyGrubs order is hyperlocal, so that is
|
||||||
|
// *always* what the next poll reports after a successful **Picked**.
|
||||||
|
//
|
||||||
|
// Reading it as this rung skipped `picked` entirely: the rider slid to
|
||||||
|
// confirm a hand-over at the counter and the ladder jumped straight to the
|
||||||
|
// delivery-active rung — before he had left the kitchen, let alone started
|
||||||
|
// the round. He never saw the state he had just created.
|
||||||
|
//
|
||||||
|
// So the released consignment resolves to [StopStatus.picked], and the
|
||||||
|
// delivery-active rung is the rider's own: [outForDeliveryIds] is written
|
||||||
|
// when he opens the stop and sets off. Nothing is faked and no status is
|
||||||
|
// invented — the two facts were simply being read as one. `deliveryArrived`
|
||||||
|
// and `delivered` are checked above and still win, so a stop the backend
|
||||||
|
// genuinely reports further along is never dragged back to picked.
|
||||||
|
if (outForDeliveryIds.contains(id)) return StopStatus.outForDelivery;
|
||||||
|
if (collectedIds.contains(id) ||
|
||||||
|
reported.isPicked ||
|
||||||
|
reported == StopStatus.outForDelivery) {
|
||||||
|
return StopStatus.picked;
|
||||||
|
}
|
||||||
|
if (reported == StopStatus.arrived) return StopStatus.arrived;
|
||||||
|
if (acceptedIds.contains(id) || reported == StopStatus.accepted) {
|
||||||
|
return StopStatus.accepted;
|
||||||
|
}
|
||||||
|
return reported;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The button a stop's current stage offers, in the rider's words, or null
|
||||||
|
/// when the stop is waiting on something other than him.
|
||||||
|
static String? nextActionLabel(StopStatus stage) => switch (stage) {
|
||||||
|
StopStatus.accepted => 'Arrived',
|
||||||
|
StopStatus.arrived => 'Picked up',
|
||||||
|
StopStatus.outForDelivery => 'Arrived',
|
||||||
|
StopStatus.deliveryArrived => 'Delivered',
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
182
lib/data/mock_backend.dart
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// THE APP WITH NO BACKEND BEHIND IT
|
||||||
|
///
|
||||||
|
/// One switch that takes the whole app off the network: sign-in, the day's
|
||||||
|
/// work, and every status the rider writes. It exists so the flow can be built,
|
||||||
|
/// demonstrated and judged without `api.doormile.com` answering — on a bench, on
|
||||||
|
/// a plane, or against a backend that does not have the meal contract yet.
|
||||||
|
///
|
||||||
|
/// ── This app has been burned by a mock layer before ──
|
||||||
|
///
|
||||||
|
/// One was ripped out for a good reason: it wrote rows into the same
|
||||||
|
/// SharedPreferences stores the real work uses, so demo stops surfaced on live
|
||||||
|
/// riders' tabs weeks later with nothing left in the repo to explain them.
|
||||||
|
/// `purgeDemoRecords()` still runs at every launch to clean up after it.
|
||||||
|
///
|
||||||
|
/// Four rules keep this one from becoming that:
|
||||||
|
///
|
||||||
|
/// 1. **It cannot ship.** [enabled] is false in a release build unless someone
|
||||||
|
/// types `--dart-define=MOCK_BACKEND=true` on the build command, which is
|
||||||
|
/// not something that happens by accident. Debug and profile builds — the
|
||||||
|
/// ones a developer actually runs — get it by default.
|
||||||
|
/// 2. **It intercepts the transport, not the stores.** Everything is served as
|
||||||
|
/// an *API response*; nothing writes to a store that real work shares. The
|
||||||
|
/// app cannot tell the difference and neither can its data layer.
|
||||||
|
/// 3. **Its ids are self-identifying.** Every record is `MOCK-…`, which is
|
||||||
|
/// exactly what `purgeDemoRecords()` looks for, so anything that does leak
|
||||||
|
/// into a store is removed at the next launch by code that already ships.
|
||||||
|
/// 4. **It says so, loudly.** Every intercepted call is logged with a `[MOCK]`
|
||||||
|
/// prefix, so a confusing session is one glance at the console away from
|
||||||
|
/// being explained.
|
||||||
|
///
|
||||||
|
/// ── What is given up while it is on ──
|
||||||
|
///
|
||||||
|
/// The hub is not told anything. A rider can accept, arrive, pick up and
|
||||||
|
/// deliver, and the console will show none of it — every write is answered with
|
||||||
|
/// a success that was manufactured on the phone. That is the correct trade for
|
||||||
|
/// design and demo work and the wrong one for a real shift, which is what rule 1
|
||||||
|
/// is for.
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// The one switch.
|
||||||
|
///
|
||||||
|
/// Default: **on** in debug and profile, **off** in release. Override either
|
||||||
|
/// way from the build command:
|
||||||
|
///
|
||||||
|
/// flutter run # mock
|
||||||
|
/// flutter run --dart-define=MOCK_BACKEND=false # real API
|
||||||
|
/// flutter build apk --dart-define=MOCK_BACKEND=true # deliberate demo
|
||||||
|
/// ── Opt in, not opt out ──
|
||||||
|
///
|
||||||
|
/// This defaulted to **on** in debug and profile, so every `flutter run` served
|
||||||
|
/// a canned rider, a canned day and a canned login — and the backend was only
|
||||||
|
/// ever exercised by someone who remembered to turn the mock off. Three
|
||||||
|
/// consequences, all of which actually happened:
|
||||||
|
///
|
||||||
|
/// • A rider signing in with his real number became the fixture's rider.
|
||||||
|
/// • A whole class of contract bugs — a field renamed, a number sent where a
|
||||||
|
/// string was required — could not be seen, because nothing left the device.
|
||||||
|
/// • The default development experience diverged from production silently,
|
||||||
|
/// which is the one kind of divergence nobody notices until release.
|
||||||
|
///
|
||||||
|
/// The canned day is still one flag away, and is genuinely useful on a bench or
|
||||||
|
/// a plane. It is just no longer what you get by not deciding:
|
||||||
|
///
|
||||||
|
/// flutter run # the real API
|
||||||
|
/// flutter run --dart-define=MOCK_BACKEND=true # the canned day
|
||||||
|
const bool kMockBackend = bool.fromEnvironment('MOCK_BACKEND');
|
||||||
|
|
||||||
|
/// Canned answers for the routes the rider's day passes through.
|
||||||
|
///
|
||||||
|
/// Everything else gets a generic success, on purpose: the alternative is a
|
||||||
|
/// mock that fails on an endpoint nobody thought about and reads to the person
|
||||||
|
/// using it as a broken app rather than an unmapped route.
|
||||||
|
class MockBackend {
|
||||||
|
MockBackend._();
|
||||||
|
|
||||||
|
/// Whether the canned answers are being served.
|
||||||
|
///
|
||||||
|
/// Settable so a test can exercise the transport itself — the interception
|
||||||
|
/// happens *above* the HTTP client, so with this on there is no request to
|
||||||
|
/// assert against. Nothing in the app assigns it; it follows [kMockBackend].
|
||||||
|
static bool enabled = kMockBackend;
|
||||||
|
|
||||||
|
/// The signed-in rider, when there is no server to ask.
|
||||||
|
///
|
||||||
|
/// `tenantname` is what puts the build on the meal line — see
|
||||||
|
/// [ServiceProfile] — so the mock day and the mock login agree about which
|
||||||
|
/// application the rider is in.
|
||||||
|
static const Map<String, dynamic> rider = {
|
||||||
|
'userid': 90001,
|
||||||
|
'id': 90001,
|
||||||
|
'name': 'Suresh Kumar',
|
||||||
|
'phone': '9876543210',
|
||||||
|
'mobile': '9876543210',
|
||||||
|
'email': 'suresh@doormile.test',
|
||||||
|
'roleid': 5,
|
||||||
|
'configid': 1001,
|
||||||
|
'tenantid': 916,
|
||||||
|
'tenantname': 'DailyGrubs',
|
||||||
|
'status': 'Active',
|
||||||
|
'vehicletype': 'Bike',
|
||||||
|
'vehicleno': 'TN 37 CV 4412',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A token that is obviously not a JWT, so nothing tries to read claims out
|
||||||
|
/// of it and no log line makes it look like a real session.
|
||||||
|
static const String token = 'MOCK-TOKEN-not-a-real-session';
|
||||||
|
|
||||||
|
/// Answers [method] [path], or null when the caller should fall through to
|
||||||
|
/// the network. Null is never returned while [enabled] — see the class note.
|
||||||
|
static Map<String, dynamic>? respond(
|
||||||
|
String method,
|
||||||
|
String path, {
|
||||||
|
Object? body,
|
||||||
|
Map<String, String>? query,
|
||||||
|
}) {
|
||||||
|
if (!enabled) return null;
|
||||||
|
|
||||||
|
final route = path.split('?').first;
|
||||||
|
debugPrint('[MOCK] $method $route');
|
||||||
|
|
||||||
|
// ── Auth ──
|
||||||
|
if (route.endsWith('/login')) {
|
||||||
|
return _ok({'otpsent': true, 'phone': _field(body, 'phone')});
|
||||||
|
}
|
||||||
|
if (route.endsWith('/verify-pin')) {
|
||||||
|
// The shape the real handler returns, verified against a live 200: the
|
||||||
|
// profile lives under `user` / `user.profile`, and there is no `data`
|
||||||
|
// key. Getting this wrong cost a debugging session once already — see the
|
||||||
|
// note in [MilerApi].
|
||||||
|
//
|
||||||
|
// The phone is the one the rider actually typed. It used to be the
|
||||||
|
// fixture's, so signing in with your own number made you Suresh Kumar and
|
||||||
|
// the screen you landed on disagreed with the number on the screen you
|
||||||
|
// came from — which reads as a broken login rather than as a mock.
|
||||||
|
final phone = _field(body, 'phone');
|
||||||
|
final signedIn = <String, dynamic>{
|
||||||
|
...rider,
|
||||||
|
if (phone.isNotEmpty) ...{
|
||||||
|
'phone': phone,
|
||||||
|
'mobile': phone,
|
||||||
|
'contactno': phone,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
'success': true,
|
||||||
|
'message': 'ok',
|
||||||
|
'token': token,
|
||||||
|
'user': {...signedIn, 'profile': signedIn},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (route.endsWith('/profile')) return _ok(rider);
|
||||||
|
|
||||||
|
// ── The day's work ──
|
||||||
|
//
|
||||||
|
// Empty rather than invented: the meal line's stops come from
|
||||||
|
// [MealRunMock], which the provider reads *before* it reaches the network
|
||||||
|
// at all. A parcel booking list has no fixture and inventing one here would
|
||||||
|
// put fictional consignments in front of a parcel rider.
|
||||||
|
if (route.endsWith('/bookings') || route.endsWith('/assignments')) {
|
||||||
|
return _ok(const <dynamic>[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Everything the rider writes ──
|
||||||
|
//
|
||||||
|
// Accept, reject, reached, parcels, payment, pickup-complete, deliver,
|
||||||
|
// skip, duty, breaks, telemetry, device tokens. All answered yes, and all
|
||||||
|
// going nowhere.
|
||||||
|
return _ok(const <String, dynamic>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, dynamic> _ok(dynamic data) => {
|
||||||
|
'success': true,
|
||||||
|
'message': 'ok',
|
||||||
|
'data': data,
|
||||||
|
};
|
||||||
|
|
||||||
|
static String _field(Object? body, String key) =>
|
||||||
|
(body is Map && body[key] != null) ? body[key].toString() : '';
|
||||||
|
}
|
||||||
70
lib/data/mutation_guard.dart
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// ONE PRESS, ONE REQUEST
|
||||||
|
///
|
||||||
|
/// Every business-critical action in this app is a POST that the backend is not
|
||||||
|
/// idempotent about: `accept`, `reject`, `reached`, `parcel`, `payment`,
|
||||||
|
/// `pickup-complete`, `deliver`, `skip`, `duty/start`, `duty/end`,
|
||||||
|
/// `breaks/start`, `breaks/end`, `availability`.
|
||||||
|
///
|
||||||
|
/// A rider presses those on a moving bike, with gloves, on a screen he cannot
|
||||||
|
/// look at for long — so a double tap is not an edge case, it is Tuesday. And
|
||||||
|
/// the failure is not cosmetic: two `duty/start` calls are an error the server
|
||||||
|
/// rejects, two `payment` calls are two payments, and two `accept` calls race
|
||||||
|
/// each other to decide what the list shows next.
|
||||||
|
///
|
||||||
|
/// The screens each solved this, or did not, with their own `_busy` flag —
|
||||||
|
/// which works until the widget rebuilds, the callback is captured twice, or the
|
||||||
|
/// action is reachable from two places (a card and a sheet) that do not share a
|
||||||
|
/// flag. This is one place that solves it once, keyed by the thing being
|
||||||
|
/// mutated rather than by the widget that happens to be showing it.
|
||||||
|
///
|
||||||
|
/// ── What this is not ──
|
||||||
|
///
|
||||||
|
/// Not a queue and not a retry. A second press while the first is in flight is
|
||||||
|
/// **dropped**, not deferred: the rider meant one thing, and running it twice a
|
||||||
|
/// second later is the bug, not the fix. Not a cache either — once the call
|
||||||
|
/// completes the key is free, so a genuine second accept of the same stop after
|
||||||
|
/// a failure still goes through.
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
class MutationGuard {
|
||||||
|
MutationGuard._();
|
||||||
|
|
||||||
|
/// Keys with a request currently in flight.
|
||||||
|
static final Set<String> _inFlight = <String>{};
|
||||||
|
|
||||||
|
/// True while [key] has a mutation running.
|
||||||
|
///
|
||||||
|
/// Read by buttons so they can show the disabled/loading state that makes the
|
||||||
|
/// dropped second press *visible* rather than merely harmless.
|
||||||
|
static bool isBusy(String key) => _inFlight.contains(key);
|
||||||
|
|
||||||
|
/// Runs [action] unless [key] is already running, in which case it returns
|
||||||
|
/// null and does nothing.
|
||||||
|
///
|
||||||
|
/// The key names the **resource and the verb**, not the screen:
|
||||||
|
/// `accept:1042`, `deliver:77`, `duty`. Two widgets showing the same stop
|
||||||
|
/// therefore share one guard, which is the whole point.
|
||||||
|
static Future<T?> run<T>(String key, Future<T> Function() action) async {
|
||||||
|
if (_inFlight.contains(key)) {
|
||||||
|
debugPrint('[GUARD] dropped duplicate "$key" — one is already running');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
_inFlight.add(key);
|
||||||
|
try {
|
||||||
|
return await action();
|
||||||
|
} finally {
|
||||||
|
// `finally`, so a thrown request frees its key. A guard that leaks on
|
||||||
|
// failure is worse than no guard: the rider's retry would be silently
|
||||||
|
// dropped for the rest of the session.
|
||||||
|
_inFlight.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears every key. For tests, and for sign-out — a new session must not
|
||||||
|
/// inherit the last one's in-flight set.
|
||||||
|
static void reset() => _inFlight.clear();
|
||||||
|
}
|
||||||
200
lib/data/order_events.dart
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// WHEN EACH THING HAPPENED TO AN ORDER
|
||||||
|
///
|
||||||
|
/// ── Why this exists ──
|
||||||
|
///
|
||||||
|
/// Activity could show three moments — set off, arrived, finished — because
|
||||||
|
/// those were the only three anything wrote down. Everything else the rider did
|
||||||
|
/// to an order happened, changed a screen, went to the backend and left no
|
||||||
|
/// local trace: he accepted it at 9:10, reached the kitchen at 9:28, took the
|
||||||
|
/// bag at 9:35, and by the time he opened his own history all three were gone.
|
||||||
|
///
|
||||||
|
/// The backend knows some of it and returns none of it: `GET /miler/bookings`
|
||||||
|
/// carries a booking's *current* status and its `createdat`, and there is no
|
||||||
|
/// per-order event feed on the contract. So a rider asking "when did I accept
|
||||||
|
/// that?" had nothing to read, and the honest timeline was three rungs long.
|
||||||
|
///
|
||||||
|
/// This is the ledger those moments go into. Each stamp is written **at the
|
||||||
|
/// moment it is true**, by the code that already knows — the same argument
|
||||||
|
/// `addCompletedBookings` makes for the two clocks it rescues out of
|
||||||
|
/// SharedPreferences.
|
||||||
|
///
|
||||||
|
/// ── What it is not ──
|
||||||
|
///
|
||||||
|
/// Not a source of truth about the order, and not a substitute for one. The
|
||||||
|
/// backend owns the lifecycle; this owns *when the rider did his half of it*,
|
||||||
|
/// for the one screen that asks. Nothing reads it to decide anything — no gate,
|
||||||
|
/// no filter, no status. If the file were deleted the app would behave
|
||||||
|
/// identically and Activity would draw fewer rungs, which is exactly the
|
||||||
|
/// degradation it is built for: **a moment with no stamp is not drawn.**
|
||||||
|
///
|
||||||
|
/// ── Written once ──
|
||||||
|
///
|
||||||
|
/// [stampOrderEvent] never overwrites. A rider who re-accepts a stop he
|
||||||
|
/// un-rejected, or re-opens a map screen, is on the same errand; moving the
|
||||||
|
/// clock forward would quietly erase the first time he did it. The one
|
||||||
|
/// exception is a resumed skip, which is a genuinely new attempt — see
|
||||||
|
/// [clearOrderEvents].
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// The moments an order passes through, in the order they happen.
|
||||||
|
///
|
||||||
|
/// String values, not an index: they are written into JSON that outlives the
|
||||||
|
/// build that wrote it, and renumbering an enum would silently re-label every
|
||||||
|
/// stamp already on the device.
|
||||||
|
class OrderEvent {
|
||||||
|
OrderEvent._();
|
||||||
|
|
||||||
|
/// The booking first appeared in this rider's queue.
|
||||||
|
///
|
||||||
|
/// Not the hub's assignment clock — the contract has none, and the booking's
|
||||||
|
/// `updatedat` moves every time anything touches the row. This is when the
|
||||||
|
/// work *reached him*, stamped by [WorkRepository] on the first fetch that
|
||||||
|
/// carries it, which is the moment it became his as far as his phone is
|
||||||
|
/// concerned.
|
||||||
|
static const String assigned = 'assigned';
|
||||||
|
|
||||||
|
/// The rider took the stop on. `POST /assignments/:id/accept`.
|
||||||
|
static const String accepted = 'accepted';
|
||||||
|
|
||||||
|
/// He reached the counter. `POST /bookings/:id/reached`.
|
||||||
|
static const String arrivedAtPickup = 'arrived_at_pickup';
|
||||||
|
|
||||||
|
/// The bag is in his hands. `POST /bookings/:id/pickup-complete`.
|
||||||
|
static const String pickedUp = 'picked_up';
|
||||||
|
|
||||||
|
/// He set off on the round. `POST /miler/deliveries/start`.
|
||||||
|
static const String outForDelivery = 'out_for_delivery';
|
||||||
|
|
||||||
|
/// Every event, in the order a timeline should read them.
|
||||||
|
static const List<String> inOrder = [
|
||||||
|
assigned,
|
||||||
|
accepted,
|
||||||
|
arrivedAtPickup,
|
||||||
|
pickedUp,
|
||||||
|
outForDelivery,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const String _kKey = 'order_events';
|
||||||
|
|
||||||
|
/// Every order's stamps: `{orderid: {event: iso8601}}`.
|
||||||
|
Future<Map<String, Map<String, String>>> _read(SharedPreferences p) async {
|
||||||
|
try {
|
||||||
|
final raw = p.getString(_kKey);
|
||||||
|
if (raw == null || raw.isEmpty) return {};
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is! Map) return {};
|
||||||
|
return {
|
||||||
|
for (final entry in decoded.entries)
|
||||||
|
entry.key.toString(): {
|
||||||
|
if (entry.value is Map)
|
||||||
|
for (final e in (entry.value as Map).entries)
|
||||||
|
e.key.toString(): e.value.toString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
// A corrupt ledger must never take the app down: this is the one store
|
||||||
|
// nothing depends on, so an unreadable one is an empty one.
|
||||||
|
debugPrint('[EVENTS] unreadable, starting empty: $e');
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records [event] against [orderId], if it has not been recorded already.
|
||||||
|
Future<void> stampOrderEvent(
|
||||||
|
Object orderId,
|
||||||
|
String event, {
|
||||||
|
DateTime? at,
|
||||||
|
}) async {
|
||||||
|
final id = orderId.toString().trim();
|
||||||
|
if (id.isEmpty) return;
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final all = await _read(prefs);
|
||||||
|
final mine = all[id] ?? <String, String>{};
|
||||||
|
// Written once — see the class doc.
|
||||||
|
if (mine.containsKey(event)) return;
|
||||||
|
mine[event] = (at ?? DateTime.now()).toIso8601String();
|
||||||
|
all[id] = mine;
|
||||||
|
await prefs.setString(_kKey, jsonEncode(all));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[EVENTS] could not stamp $event on $id: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stamps one event against several orders at once — a kitchen handover, an
|
||||||
|
/// accept-all, a released round.
|
||||||
|
Future<void> stampOrderEvents(Iterable<Object> orderIds, String event) async {
|
||||||
|
final at = DateTime.now();
|
||||||
|
for (final id in orderIds) {
|
||||||
|
await stampOrderEvent(id, event, at: at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What is known about one order, as `{event: iso8601}`.
|
||||||
|
Future<Map<String, String>> getOrderEvents(Object orderId) async {
|
||||||
|
final id = orderId.toString().trim();
|
||||||
|
if (id.isEmpty) return const {};
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return (await _read(prefs))[id] ?? const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forgets an order's stamps.
|
||||||
|
///
|
||||||
|
/// Called when a skipped stop is resumed: the rider is making a second attempt
|
||||||
|
/// at the same door, and the first attempt's clocks describe a visit that has
|
||||||
|
/// been superseded. Same argument `addCompletedBookings` makes when it clears
|
||||||
|
/// the two prefs keys it has just read.
|
||||||
|
Future<void> clearOrderEvents(Object orderId) async {
|
||||||
|
final id = orderId.toString().trim();
|
||||||
|
if (id.isEmpty) return;
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final all = await _read(prefs);
|
||||||
|
if (all.remove(id) != null) {
|
||||||
|
await prefs.setString(_kKey, jsonEncode(all));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[EVENTS] could not clear $id: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops orders whose last stamp is older than [keepDays].
|
||||||
|
///
|
||||||
|
/// The ledger is per-order and nothing prunes it on read, so without this it
|
||||||
|
/// grows for the life of the install — the same trap `removeCollectedOrderIds`
|
||||||
|
/// exists to avoid. Two days rather than one, because a shift that crosses
|
||||||
|
/// midnight must not lose its own morning.
|
||||||
|
Future<void> pruneOrderEvents({int keepDays = 2}) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final all = await _read(prefs);
|
||||||
|
final cutoff = DateTime.now().subtract(Duration(days: keepDays));
|
||||||
|
|
||||||
|
final kept = <String, Map<String, String>>{};
|
||||||
|
for (final entry in all.entries) {
|
||||||
|
DateTime? newest;
|
||||||
|
for (final iso in entry.value.values) {
|
||||||
|
final t = DateTime.tryParse(iso);
|
||||||
|
if (t != null && (newest == null || t.isAfter(newest))) newest = t;
|
||||||
|
}
|
||||||
|
// An entry with no parseable stamp in it is kept: it is either from a
|
||||||
|
// build that wrote a shape this one does not know, or corrupt, and
|
||||||
|
// deleting somebody's history to tidy up is the worse failure.
|
||||||
|
if (newest == null || newest.isAfter(cutoff))
|
||||||
|
kept[entry.key] = entry.value;
|
||||||
|
}
|
||||||
|
if (kept.length != all.length) {
|
||||||
|
await prefs.setString(_kKey, jsonEncode(kept));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[EVENTS] prune failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
160
lib/data/proof_store.dart
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'package:miler/data/work_scope.dart';
|
||||||
|
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// PROOF OF DELIVERY, KEPT WHERE IT SURVIVES
|
||||||
|
///
|
||||||
|
/// A photo taken at a door is evidence, and evidence that disappears is worse
|
||||||
|
/// than none — it makes the rider believe he has cover he does not have. Two
|
||||||
|
/// things were therefore decided deliberately:
|
||||||
|
///
|
||||||
|
/// **It is copied, not referenced.** `ImagePicker` hands back a file in the
|
||||||
|
/// OS *cache* directory, which Android reclaims whenever it likes and clears
|
||||||
|
/// outright on a storage sweep. Pointing the Activity record at that path
|
||||||
|
/// gives a record whose picture is gone by the end of the week. The file is
|
||||||
|
/// copied into the app's documents directory, which is the app's to keep.
|
||||||
|
///
|
||||||
|
/// **It belongs to a scope.** Filed under the same rider/tenant/line identity
|
||||||
|
/// as every other record ([WorkScope]) so signing out or switching line does
|
||||||
|
/// not leave one rider's doorstep photos where the next one can open them.
|
||||||
|
///
|
||||||
|
/// ── The limitation this cannot solve ──
|
||||||
|
///
|
||||||
|
/// **There is no upload route on the Miler API.** `POST
|
||||||
|
/// /miler/consignments/:id/deliver` takes a `photourl` *string*, and nothing
|
||||||
|
/// in the contract accepts a file — no multipart route, no signed-URL
|
||||||
|
/// endpoint, no attachment on any other call. So the proof is real, durable
|
||||||
|
/// and auditable **on the handset**, and the hub cannot see it until the
|
||||||
|
/// backend grows somewhere to put it. The app deliberately does NOT send the
|
||||||
|
/// local path in `photourl`: a filesystem path from somebody's phone is not a
|
||||||
|
/// URL, and writing one into the delivery record would put a string in the
|
||||||
|
/// hub's database that looks like evidence and resolves to nothing.
|
||||||
|
/// [remoteUrlFor] is where a real URL will come from on the day that route
|
||||||
|
/// exists; until then it is honestly empty.
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
class ProofStore {
|
||||||
|
ProofStore._();
|
||||||
|
|
||||||
|
static const String _indexKey = 'delivery_proof_paths';
|
||||||
|
|
||||||
|
/// Where the copies live, created on demand.
|
||||||
|
static Future<Directory> _dir() async {
|
||||||
|
final base = await getApplicationDocumentsDirectory();
|
||||||
|
final dir = Directory('${base.path}/delivery_proof');
|
||||||
|
if (!await dir.exists()) await dir.create(recursive: true);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String> _key() async =>
|
||||||
|
(await WorkScope.current()).scoped(_indexKey);
|
||||||
|
|
||||||
|
/// Copies [sourcePath] into the app's own storage and records it against
|
||||||
|
/// [orderId]. Returns the durable path, or null when the copy fails.
|
||||||
|
///
|
||||||
|
/// Failure is returned rather than thrown: a rider standing at a door with a
|
||||||
|
/// full disk still has to be able to complete the stop, and the delivery is
|
||||||
|
/// the thing that matters. The caller decides whether to proceed without it.
|
||||||
|
static Future<String?> save(String orderId, String sourcePath) async {
|
||||||
|
if (orderId.trim().isEmpty || sourcePath.trim().isEmpty) return null;
|
||||||
|
try {
|
||||||
|
final src = File(sourcePath);
|
||||||
|
if (!await src.exists()) return null;
|
||||||
|
|
||||||
|
final dir = await _dir();
|
||||||
|
// The order id is the natural name — one proof per stop, and a retake
|
||||||
|
// overwrites rather than accumulating. Sanitised because an id can carry
|
||||||
|
// characters a filename cannot.
|
||||||
|
final safe = orderId.replaceAll(RegExp(r'[^A-Za-z0-9_-]'), '_');
|
||||||
|
final dest = '${dir.path}/$safe.jpg';
|
||||||
|
await src.copy(dest);
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final key = await _key();
|
||||||
|
final index = <String>[...(prefs.getStringList(key) ?? const [])]
|
||||||
|
..removeWhere((e) => e.startsWith('$orderId::'))
|
||||||
|
..add('$orderId::$dest');
|
||||||
|
await prefs.setStringList(key, index);
|
||||||
|
|
||||||
|
debugPrint('[PROOF] $orderId saved to $dest');
|
||||||
|
return dest;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[PROOF] could not save proof for $orderId: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored proof for [orderId], or null when there is none **or the file
|
||||||
|
/// has gone**. A path that no longer resolves is not proof, and returning it
|
||||||
|
/// would draw a broken image in place of evidence.
|
||||||
|
static Future<String?> pathFor(String orderId) async {
|
||||||
|
if (orderId.trim().isEmpty) return null;
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final index = prefs.getStringList(await _key()) ?? const <String>[];
|
||||||
|
for (final entry in index) {
|
||||||
|
final i = entry.indexOf('::');
|
||||||
|
if (i <= 0) continue;
|
||||||
|
if (entry.substring(0, i) != orderId) continue;
|
||||||
|
final path = entry.substring(i + 2);
|
||||||
|
return await File(path).exists() ? path : null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[PROOF] could not read proof for $orderId: $e');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The **remote** reference for [orderId], for `deliver`'s `photourl`.
|
||||||
|
///
|
||||||
|
/// Always empty today: the Miler API has no route that turns a file into a
|
||||||
|
/// URL. Kept as the single seam so that when one exists, the delivery call
|
||||||
|
/// starts carrying a real link without any other code changing — and so
|
||||||
|
/// that nobody is tempted to pass a device path in the meantime.
|
||||||
|
static Future<String> remoteUrlFor(String orderId) async => '';
|
||||||
|
|
||||||
|
/// Forgets proofs whose orders are long finished, so the directory cannot
|
||||||
|
/// grow for the life of the install. Best-effort.
|
||||||
|
static Future<void> prune({int keep = 200}) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final key = await _key();
|
||||||
|
final index = prefs.getStringList(key) ?? const <String>[];
|
||||||
|
if (index.length <= keep) return;
|
||||||
|
|
||||||
|
final drop = index.take(index.length - keep).toList();
|
||||||
|
for (final entry in drop) {
|
||||||
|
final i = entry.indexOf('::');
|
||||||
|
if (i <= 0) continue;
|
||||||
|
final f = File(entry.substring(i + 2));
|
||||||
|
if (await f.exists()) await f.delete();
|
||||||
|
}
|
||||||
|
await prefs.setStringList(key, index.sublist(index.length - keep));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[PROOF] prune failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops every proof in this scope. Called from logout alongside the other
|
||||||
|
/// scoped stores — a doorstep photo is exactly the kind of record that must
|
||||||
|
/// not outlive the session that took it.
|
||||||
|
static Future<void> clearScope() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final key = await _key();
|
||||||
|
for (final entry in prefs.getStringList(key) ?? const <String>[]) {
|
||||||
|
final i = entry.indexOf('::');
|
||||||
|
if (i <= 0) continue;
|
||||||
|
final f = File(entry.substring(i + 2));
|
||||||
|
if (await f.exists()) await f.delete();
|
||||||
|
}
|
||||||
|
await prefs.remove(key);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[PROOF] could not clear scope: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
|
||||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||||
|
|
||||||
@@ -66,10 +67,10 @@ extension RiderTierUi on RiderTier {
|
|||||||
int get threshold => kTierThresholds[this] ?? 0;
|
int get threshold => kTierThresholds[this] ?? 0;
|
||||||
|
|
||||||
IconData get icon => switch (this) {
|
IconData get icon => switch (this) {
|
||||||
RiderTier.blue => Icons.pedal_bike_rounded,
|
RiderTier.blue => LucideIcons.bike,
|
||||||
RiderTier.silver => Icons.military_tech_rounded,
|
RiderTier.silver => LucideIcons.medal,
|
||||||
RiderTier.gold => Icons.workspace_premium_rounded,
|
RiderTier.gold => LucideIcons.award,
|
||||||
RiderTier.platinum => Icons.diamond_rounded,
|
RiderTier.platinum => LucideIcons.gem,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Colour of the badge, not of the page. The card behind it stays brand
|
/// Colour of the badge, not of the page. The card behind it stays brand
|
||||||
|
|||||||
218
lib/data/route_order.dart
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// THE ADMIN'S ROUTE IS THE ROUTE
|
||||||
|
///
|
||||||
|
/// One rule, in one place, for the only question that decides what order a
|
||||||
|
/// rider works his stops in: **has the hub solved this route?**
|
||||||
|
///
|
||||||
|
/// If yes — that order is used, exactly, on both legs. The app does not
|
||||||
|
/// improve it, shorten it, or reorder it by where the rider
|
||||||
|
/// happens to be standing.
|
||||||
|
/// If no — the app falls back, and *says* it is falling back.
|
||||||
|
///
|
||||||
|
/// ── Why this needed taking out of the screens ──
|
||||||
|
///
|
||||||
|
/// It was answered in two places that could not see each other.
|
||||||
|
/// `Trip.sortStops` — the pickup leg — reads `step` and documents it as
|
||||||
|
/// authoritative. The Deliveries tab had its own sort: stops with a step
|
||||||
|
/// first, then everything else **by straight-line distance from the rider's
|
||||||
|
/// current GPS fix**. Since `step` reached the app only through
|
||||||
|
/// `GET /miler/assignments`, and that endpoint is deliberately the *active*
|
||||||
|
/// queue (`Assigned`/`Accepted` only), a booking lost its step at the moment
|
||||||
|
/// it was collected — which is exactly when the delivery leg starts.
|
||||||
|
///
|
||||||
|
/// So every delivery was ordered nearest-first, the hub believed its solved
|
||||||
|
/// sequence was being followed, and nothing on either side said otherwise.
|
||||||
|
/// A rider re-optimising a route the hub has planned is not a small
|
||||||
|
/// difference: it changes promised arrival windows the customer was given.
|
||||||
|
///
|
||||||
|
/// ── The fallback is a fallback ──
|
||||||
|
///
|
||||||
|
/// Proximity ordering is not wrong in itself — with no assigned sequence the
|
||||||
|
/// app has to pick something, and nearest-first is the best guess available.
|
||||||
|
/// What is wrong is presenting a guess as the hub's plan. [RouteOrder.sort]
|
||||||
|
/// therefore returns *which rule it applied*, so a screen can label the queue
|
||||||
|
/// honestly and a test can assert the rule rather than the resulting order.
|
||||||
|
///
|
||||||
|
/// ── One sequenced stop is enough ──
|
||||||
|
///
|
||||||
|
/// If **any** stop in the set carries a sequence, the whole set is treated as
|
||||||
|
/// admin-ordered: the sequenced stops lead in their solved order, and the rest
|
||||||
|
/// follow. Mixing the two — sorting the sequenced ones by step and the others
|
||||||
|
/// by distance — is what the Deliveries tab used to do, and it produces a list
|
||||||
|
/// that is neither the hub's route nor a sane one.
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
enum RouteOrderSource {
|
||||||
|
/// The hub solved it. `step` (or an equivalent) came down populated.
|
||||||
|
adminSequence,
|
||||||
|
|
||||||
|
/// No sequence, but the stops carry booked times — work them in the order
|
||||||
|
/// they were promised for.
|
||||||
|
bookedTime,
|
||||||
|
|
||||||
|
/// No sequence and no times. Whatever order the backend listed them in,
|
||||||
|
/// preserved rather than replaced.
|
||||||
|
backendOrder,
|
||||||
|
|
||||||
|
/// No sequence, and the app chose nearest-first from the rider's position.
|
||||||
|
/// **A guess.** Must be labelled as one wherever it reaches a screen.
|
||||||
|
proximity,
|
||||||
|
}
|
||||||
|
|
||||||
|
extension RouteOrderSourceX on RouteOrderSource {
|
||||||
|
/// True when the order came from the hub and must not be second-guessed.
|
||||||
|
bool get isAdmin => this == RouteOrderSource.adminSequence;
|
||||||
|
|
||||||
|
/// What the rider is told the list is ordered by. Short, because it sits
|
||||||
|
/// under a heading and not in a paragraph.
|
||||||
|
String get label => switch (this) {
|
||||||
|
RouteOrderSource.adminSequence => 'Hub route',
|
||||||
|
RouteOrderSource.bookedTime => 'By booked time',
|
||||||
|
RouteOrderSource.backendOrder => 'As assigned',
|
||||||
|
RouteOrderSource.proximity => 'Nearest first',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The longer form, for a place with room to explain — and specifically to
|
||||||
|
/// keep the app from implying the hub planned an order it did not.
|
||||||
|
String get explanation => switch (this) {
|
||||||
|
RouteOrderSource.adminSequence => 'Ordered by the route your hub assigned.',
|
||||||
|
RouteOrderSource.bookedTime =>
|
||||||
|
'No route assigned — ordered by booked time.',
|
||||||
|
RouteOrderSource.backendOrder =>
|
||||||
|
'No route assigned — shown in the order they came through.',
|
||||||
|
RouteOrderSource.proximity =>
|
||||||
|
'No route assigned — ordered by what is closest to you.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stop's place in the hub's solved route, and the sort that honours it.
|
||||||
|
abstract final class RouteOrder {
|
||||||
|
/// Field names that have carried the solved sequence.
|
||||||
|
///
|
||||||
|
/// `step` is the one the backend writes. The rest are spellings seen in the
|
||||||
|
/// wild or named in the contract for the delivery leg; reading all of them
|
||||||
|
/// costs nothing and means a rename does not silently drop the whole rule
|
||||||
|
/// back to nearest-first — the failure mode that started this.
|
||||||
|
static const List<String> sequenceKeys = [
|
||||||
|
'step',
|
||||||
|
'Step',
|
||||||
|
'deliverystep',
|
||||||
|
'deliveryStep',
|
||||||
|
'routestep',
|
||||||
|
'routeStep',
|
||||||
|
'sequence',
|
||||||
|
'routesequence',
|
||||||
|
'routeSequence',
|
||||||
|
'stopsequence',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// This stop's place in the route, or `0` for *not sequenced*.
|
||||||
|
///
|
||||||
|
/// **Zero is not "first".** The hub numbers from 1, so a 0 means the
|
||||||
|
/// optimizer has not run for this stop, and treating it as position zero
|
||||||
|
/// would put unsequenced work at the head of a solved route.
|
||||||
|
static int sequenceOf(Map<String, dynamic> stop) {
|
||||||
|
for (final key in sequenceKeys) {
|
||||||
|
final raw = stop[key];
|
||||||
|
if (raw == null) continue;
|
||||||
|
final n = raw is num ? raw.toInt() : int.tryParse(raw.toString()) ?? 0;
|
||||||
|
if (n > 0) return n;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the hub has solved an order for at least one of these stops.
|
||||||
|
static bool hasAdminSequence(Iterable<Map<String, dynamic>> stops) =>
|
||||||
|
stops.any((s) => sequenceOf(s) > 0);
|
||||||
|
|
||||||
|
/// Puts [stops] in the order they are to be worked, and says which rule it
|
||||||
|
/// used.
|
||||||
|
///
|
||||||
|
/// [distanceTo] is the escape hatch for the proximity fallback: the caller
|
||||||
|
/// supplies metres from the rider to a stop, because this file is pure and
|
||||||
|
/// has no business knowing about GPS. Omit it and proximity is simply never
|
||||||
|
/// used — which is the correct behaviour with no fix available, not a
|
||||||
|
/// reason to leave the list unsorted.
|
||||||
|
static (List<Map<String, dynamic>>, RouteOrderSource) sort(
|
||||||
|
List<Map<String, dynamic>> stops, {
|
||||||
|
double Function(Map<String, dynamic> stop)? distanceTo,
|
||||||
|
DateTime? Function(Map<String, dynamic> stop)? bookedTimeOf,
|
||||||
|
}) {
|
||||||
|
if (stops.length <= 1) {
|
||||||
|
return (
|
||||||
|
List<Map<String, dynamic>>.from(stops),
|
||||||
|
hasAdminSequence(stops)
|
||||||
|
? RouteOrderSource.adminSequence
|
||||||
|
: RouteOrderSource.backendOrder,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indexed so every comparison can fall back to the order the backend sent,
|
||||||
|
// which makes the sort stable and keeps "unordered" meaning *unchanged*.
|
||||||
|
final indexed = <(int, Map<String, dynamic>)>[
|
||||||
|
for (var i = 0; i < stops.length; i++) (i, stops[i]),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (hasAdminSequence(stops)) {
|
||||||
|
indexed.sort((a, b) {
|
||||||
|
final sa = sequenceOf(a.$2);
|
||||||
|
final sb = sequenceOf(b.$2);
|
||||||
|
if (sa > 0 && sb > 0 && sa != sb) return sa - sb;
|
||||||
|
// Sequenced work leads. An unsequenced stop appended to a solved route
|
||||||
|
// is a stop the hub did not plan for, and it goes at the end of it.
|
||||||
|
if (sa > 0 && sb == 0) return -1;
|
||||||
|
if (sb > 0 && sa == 0) return 1;
|
||||||
|
return a.$1 - b.$1;
|
||||||
|
});
|
||||||
|
return ([for (final e in indexed) e.$2], RouteOrderSource.adminSequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── No admin route. Only now may the app choose. ──
|
||||||
|
final times = <int, DateTime>{};
|
||||||
|
if (bookedTimeOf != null) {
|
||||||
|
for (final e in indexed) {
|
||||||
|
final t = bookedTimeOf(e.$2);
|
||||||
|
if (t != null) times[e.$1] = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (times.length > 1) {
|
||||||
|
indexed.sort((a, b) {
|
||||||
|
final ta = times[a.$1];
|
||||||
|
final tb = times[b.$1];
|
||||||
|
if (ta != null && tb != null && ta != tb) return ta.compareTo(tb);
|
||||||
|
if (ta != null && tb == null) return -1;
|
||||||
|
if (tb != null && ta == null) return 1;
|
||||||
|
return a.$1 - b.$1;
|
||||||
|
});
|
||||||
|
return ([for (final e in indexed) e.$2], RouteOrderSource.bookedTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (distanceTo != null) {
|
||||||
|
final metres = {for (final e in indexed) e.$1: distanceTo(e.$2)};
|
||||||
|
indexed.sort((a, b) {
|
||||||
|
final da = metres[a.$1] ?? double.infinity;
|
||||||
|
final db = metres[b.$1] ?? double.infinity;
|
||||||
|
if (da != db) return da.compareTo(db);
|
||||||
|
return a.$1 - b.$1;
|
||||||
|
});
|
||||||
|
return ([for (final e in indexed) e.$2], RouteOrderSource.proximity);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ([for (final e in indexed) e.$2], RouteOrderSource.backendOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records that a set of stops reached a screen with no assigned order.
|
||||||
|
///
|
||||||
|
/// Not a warning about a bug in this app — it is the hub's optimizer not
|
||||||
|
/// having run. Logged so the gap is visible in a rider's log rather than
|
||||||
|
/// inferred later from a complaint about stop order.
|
||||||
|
static void logUnsequenced(String where, int count) {
|
||||||
|
if (count <= 0) return;
|
||||||
|
debugPrint(
|
||||||
|
'[ROUTE][$where] $count stop(s) with no assigned sequence — '
|
||||||
|
'ordering is the app\'s own fallback, not the hub\'s route',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
lib/data/service_day.dart
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// THE OPERATING DAY
|
||||||
|
///
|
||||||
|
/// Activity is a **shift log**, not an archive. What the rider needs from it is
|
||||||
|
/// "what have I done today" — and today ends when his day ends, not when a
|
||||||
|
/// timer somewhere fires.
|
||||||
|
///
|
||||||
|
/// ── Why this is a filter and not a cleanup job ──
|
||||||
|
///
|
||||||
|
/// The obvious build is a scheduled purge: at midnight, delete yesterday. It
|
||||||
|
/// is also the one that cannot work on a phone. The app is closed at midnight
|
||||||
|
/// far more often than it is open; a timer that must fire at 23:59 to keep the
|
||||||
|
/// screen correct is a timer that will not fire, and the rider opens Activity
|
||||||
|
/// at six the next morning to yesterday's finished work presented as today's.
|
||||||
|
/// Worse, a purge is destructive on a schedule nothing observes — one bad
|
||||||
|
/// timezone assumption and it takes the current shift with it.
|
||||||
|
///
|
||||||
|
/// So nothing is deleted on a clock. Every record carries the service day it
|
||||||
|
/// belongs to, and the screen asks for **today's**. When the date rolls over,
|
||||||
|
/// yesterday stops matching; it does not need to be removed, and it is still
|
||||||
|
/// there for anything that legitimately wants history. Closing the app, force
|
||||||
|
/// quitting it, flying through a timezone or leaving it open across midnight
|
||||||
|
/// all produce the same answer, because the answer is computed at read time.
|
||||||
|
///
|
||||||
|
/// ── Local, deliberately ──
|
||||||
|
///
|
||||||
|
/// A rider's day is the day where he is standing. `DateTime.now()` is already
|
||||||
|
/// local, and both stores stamp their records with the local calendar date at
|
||||||
|
/// the moment of writing — so a record written at 23:50 belongs to that day
|
||||||
|
/// and a record written ten minutes later belongs to the next, which is what
|
||||||
|
/// the rider would say too. Nothing here converts to UTC: doing so would move
|
||||||
|
/// the boundary to 05:30 local in IST and split every evening shift in half.
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
abstract final class ServiceDay {
|
||||||
|
/// `2026-08-21` for [at], in the local calendar. The same shape both stores
|
||||||
|
/// write, so a stamp and a query can be compared as strings.
|
||||||
|
static String stamp(DateTime at) =>
|
||||||
|
'${at.year.toString().padLeft(4, '0')}-'
|
||||||
|
'${at.month.toString().padLeft(2, '0')}-'
|
||||||
|
'${at.day.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
/// The service day the rider is in right now.
|
||||||
|
static String get today => stamp(DateTime.now());
|
||||||
|
|
||||||
|
/// The stamps a record carries, newest-meaning first.
|
||||||
|
static const List<String> dayKeys = [
|
||||||
|
'completedday',
|
||||||
|
'skippedday',
|
||||||
|
'serviceday',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The timestamps to fall back on when a row carries no day stamp — an API
|
||||||
|
/// row, say, which has never been through either local store.
|
||||||
|
static const List<String> timeKeys = [
|
||||||
|
'completedat',
|
||||||
|
'skippedat',
|
||||||
|
'deliveredat',
|
||||||
|
'updatedat',
|
||||||
|
'createdat',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The service day [row] belongs to, or `''` when it carries nothing usable.
|
||||||
|
///
|
||||||
|
/// Empty is a real answer and callers must decide what it means for them —
|
||||||
|
/// see [belongsToToday], which keeps such a row rather than dropping it.
|
||||||
|
static String of(Map<String, dynamic> row) {
|
||||||
|
for (final k in dayKeys) {
|
||||||
|
final v = (row[k] ?? '').toString().trim();
|
||||||
|
if (v.length >= 10) return v.substring(0, 10);
|
||||||
|
}
|
||||||
|
for (final k in timeKeys) {
|
||||||
|
final raw = (row[k] ?? '').toString().trim();
|
||||||
|
if (raw.isEmpty) continue;
|
||||||
|
final t = DateTime.tryParse(raw);
|
||||||
|
if (t != null) return stamp(t.isUtc ? t.toLocal() : t);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether [row] belongs to the service day in progress.
|
||||||
|
///
|
||||||
|
/// ── A row with no date is kept, and kept quietly ──
|
||||||
|
///
|
||||||
|
/// It is on this screen because this session produced it or the API returned
|
||||||
|
/// it for this rider; the missing field is a data-quality problem, not
|
||||||
|
/// evidence that the work happened yesterday. Dropping it would silently
|
||||||
|
/// lose a rider's completed stop, and labelling it — the old `Undated` badge
|
||||||
|
/// — puts an internal defect on a screen he is supposed to read at a glance
|
||||||
|
/// and can do nothing about. So it stays, in today, unmarked.
|
||||||
|
static bool belongsToToday(Map<String, dynamic> row, {String? now}) {
|
||||||
|
final day = of(row);
|
||||||
|
if (day.isEmpty) return true;
|
||||||
|
return day == (now ?? today);
|
||||||
|
}
|
||||||
|
}
|
||||||
700
lib/data/service_profile.dart
Normal file
@@ -0,0 +1,700 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'package:miler/data/api_config.dart';
|
||||||
|
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// WHICH LINE OF WORK THIS RIDER IS ON
|
||||||
|
///
|
||||||
|
/// One company — **Doormile** — running two lines of work out of one rider app.
|
||||||
|
///
|
||||||
|
/// • **Parcel.** First-mile logistics: collect a consignment from a customer,
|
||||||
|
/// carry it to the hub, cash on delivery, proof at every door. The app's
|
||||||
|
/// original business.
|
||||||
|
///
|
||||||
|
/// • **Meals.** Subscription food runs for clients with cloud kitchens: load a
|
||||||
|
/// crate of lunches at the kitchen, drop them to subscribers who have already
|
||||||
|
/// paid by the month, go home. No money at any door, no chain of custody, a
|
||||||
|
/// different shape of day entirely.
|
||||||
|
///
|
||||||
|
/// The two used to be two separate rider apps. They are one app now, and which
|
||||||
|
/// line a rider is on is decided by the **tenant id on his login** — the hub
|
||||||
|
/// assigns it, the app reads it once and never asks again.
|
||||||
|
///
|
||||||
|
/// ── Why the tenant and not the trip ──
|
||||||
|
///
|
||||||
|
/// The earlier design note for mixed routes argued for keying behaviour off the
|
||||||
|
/// *stop*, because one rider can hold a pickup and a delivery in the same hour.
|
||||||
|
/// That argument does not apply here: the two lines have separate riders,
|
||||||
|
/// separate hubs and separate clients. A rider belongs to one of them for the
|
||||||
|
/// life of his account, and `tenantid` is already on the login response — so it
|
||||||
|
/// resolves once, at login, and nothing downstream has to ask again.
|
||||||
|
///
|
||||||
|
/// ── The one rule for using this ──
|
||||||
|
///
|
||||||
|
/// Screens read a **capability**, never a line. `if (profile.collectsCash)`,
|
||||||
|
/// not `if (line == ServiceLine.meals)`. Capabilities are why signing a third
|
||||||
|
/// kind of client later is a new [ServiceProfile] rather than a third UI: the
|
||||||
|
/// day a food client wants cash on delivery, or a parcel client wants crate
|
||||||
|
/// loading, the screens are already asking the right question.
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
enum ServiceLine {
|
||||||
|
/// Parcel logistics. The app's original and largest business.
|
||||||
|
parcel,
|
||||||
|
|
||||||
|
/// **Milk Man delivery** — load a crate at a source (a kitchen), then drop
|
||||||
|
/// to the customers who ordered from it.
|
||||||
|
///
|
||||||
|
/// Named `milkMan` after the operation the hub runs; the code called this
|
||||||
|
/// `meals` while it had one food client, and the shape of the work — collect
|
||||||
|
/// in bulk from one or more sources, then a round of prepaid drops — is the
|
||||||
|
/// same whatever is in the crate.
|
||||||
|
milkMan,
|
||||||
|
|
||||||
|
/// Last-mile delivery: carry consignments out of the hub and hand them to
|
||||||
|
/// customers. The Xpress-rider app's business.
|
||||||
|
///
|
||||||
|
/// ── Why this one is different from the other two ──
|
||||||
|
///
|
||||||
|
/// [parcel] and [meals] are the *same screens* with different capabilities
|
||||||
|
/// switched on. This one is not: it has its own screens, ported verbatim from
|
||||||
|
/// the Xpress-rider app and living under `lib/xpress`. A delivery rider gets
|
||||||
|
/// that app — its home, its deliveries list, its summary, its four-tab bottom
|
||||||
|
/// bar — wearing Doormile's colours.
|
||||||
|
///
|
||||||
|
/// So the capabilities on [ServiceProfile.delivery] are close to decorative:
|
||||||
|
/// almost nothing reads them, because the delivery screens do not ask the
|
||||||
|
/// capability questions the parcel screens ask. What this line is *for* is the
|
||||||
|
/// routing decision in `helpers/rider_shell.dart`, which is the only place
|
||||||
|
/// that has to know.
|
||||||
|
delivery,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The moment a stop leaves Home for the work tab. See [ServiceProfile.handoffAt].
|
||||||
|
enum HandoffPoint {
|
||||||
|
/// As soon as the rider accepts it.
|
||||||
|
accepted,
|
||||||
|
|
||||||
|
/// Only once he is physically carrying it.
|
||||||
|
collected,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What this line of work requires of the rider at the door.
|
||||||
|
@immutable
|
||||||
|
class ServiceProfile {
|
||||||
|
final ServiceLine line;
|
||||||
|
|
||||||
|
/// Shown wherever the rider needs to know which work this is.
|
||||||
|
///
|
||||||
|
/// Both lines are Doormile — the rider's employer does not change with the
|
||||||
|
/// crate he is carrying — so this names the *work*, not a company.
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// ── The one noun the whole app uses for a job ──
|
||||||
|
///
|
||||||
|
/// The two rider apps this one replaces disagreed about what a job is called:
|
||||||
|
/// the parcel app said **booking**, the meal app said **delivery**. Merging
|
||||||
|
/// them meant picking one, and there is no single honest answer — a rider who
|
||||||
|
/// collects parcels for the hub does not "deliver" them, and a rider handing
|
||||||
|
/// somebody lunch has not taken a "booking".
|
||||||
|
///
|
||||||
|
/// So the noun follows the line, and every string that names a job reads it
|
||||||
|
/// from here: the tab in the nav bar, the accepted-count pill, the sentence on
|
||||||
|
/// a taken stop, the empty states. A rider only ever belongs to one line, so
|
||||||
|
/// he only ever sees one word — and the code has one path, not two.
|
||||||
|
///
|
||||||
|
/// Capitalised where a label needs it; see [workTabLabel].
|
||||||
|
final String jobNoun;
|
||||||
|
|
||||||
|
/// Plural of [jobNoun] — English is not regular enough to derive it.
|
||||||
|
final String jobNounPlural;
|
||||||
|
|
||||||
|
/// What the nav bar calls the tab holding accepted work.
|
||||||
|
///
|
||||||
|
/// "Bookings" on a parcel route, "Deliveries" on a meal run. Title case
|
||||||
|
/// because it is a proper destination in the app, not a word in a sentence.
|
||||||
|
String get workTabLabel =>
|
||||||
|
jobNounPlural[0].toUpperCase() + jobNounPlural.substring(1);
|
||||||
|
|
||||||
|
/// The verb for finishing a stop, in the rider's voice: "Picked up" when he
|
||||||
|
/// is taking something away, "Delivered" when he is handing it over.
|
||||||
|
///
|
||||||
|
/// A meal run's stops are all drops, so its rider never sees "Picked up" at a
|
||||||
|
/// customer's door — that word belongs to the kitchen, where he loads.
|
||||||
|
final String completionVerb;
|
||||||
|
|
||||||
|
/// Money changes hands at the stop. False for a subscription service — the
|
||||||
|
/// client is billed monthly, so the rider never sees an amount.
|
||||||
|
///
|
||||||
|
/// Read through [stopCollectionAmount], which returns 0 when this is false,
|
||||||
|
/// so every existing "is anything owed?" branch already does the right thing.
|
||||||
|
final bool collectsCash;
|
||||||
|
|
||||||
|
/// The full proof-of-work page — parcel ticks, weight, condition, OTP,
|
||||||
|
/// photo, review. Right for a parcel with a chain of custody; wrong for a
|
||||||
|
/// lunch box, where it is a two-minute form standing between a rider and a
|
||||||
|
/// doorbell.
|
||||||
|
///
|
||||||
|
/// **Nothing reads this today.** The page is off on both tenants: arriving
|
||||||
|
/// goes straight to the confirmation sheet, which asks the one question a
|
||||||
|
/// stop actually has an answer to — picked up, not picked, or skipped. The
|
||||||
|
/// capability and [StopVerificationPage] both survive because the form is
|
||||||
|
/// what a chain-of-custody client would need on the day one asks for it, and
|
||||||
|
/// this is the switch that would turn it back on for that tenant alone.
|
||||||
|
final bool needsVerification;
|
||||||
|
|
||||||
|
/// A camera step on the final status change.
|
||||||
|
final bool needsProofPhoto;
|
||||||
|
|
||||||
|
/// The rider decides stop by stop. False for a service run: he cannot
|
||||||
|
/// decline one subscriber's lunch, so the whole assignment is accepted at
|
||||||
|
/// once and the per-row Accept/Reject controls come off the card.
|
||||||
|
final bool acceptsPerStop;
|
||||||
|
|
||||||
|
/// Collection is grouped by source (a kitchen), not done per customer.
|
||||||
|
///
|
||||||
|
/// Also what makes Home group its stops under a heading per source and offer
|
||||||
|
/// one bulk collect per group: a rider standing at a counter with a crate is
|
||||||
|
/// doing one thing, not five.
|
||||||
|
final bool sourceIsKitchen;
|
||||||
|
|
||||||
|
/// When a stop stops being Home's problem and becomes the work tab's.
|
||||||
|
///
|
||||||
|
/// A parcel route hands off at **accept**: taking a booking is the decision,
|
||||||
|
/// and everything after it happens at the customer's door.
|
||||||
|
///
|
||||||
|
/// A meal run hands off at **collect**, because accepting changes nothing
|
||||||
|
/// physical — the rider still has to go to a kitchen and be given the food.
|
||||||
|
/// Moving the card at accept would empty Home the moment he arrived for his
|
||||||
|
/// shift and leave him working a list of meals he is not carrying. So the rule
|
||||||
|
/// is: **Home holds an order until it is in his hands.**
|
||||||
|
final HandoffPoint handoffAt;
|
||||||
|
|
||||||
|
/// One name for every stop's work type, or null to let each stop name its own
|
||||||
|
/// (`PICKUP` / `DELIVERY` / `P&D`).
|
||||||
|
///
|
||||||
|
/// A meal run is one job repeated — collect a crate, drop the boxes — and
|
||||||
|
/// labelling half of a rider's day PICKUP and half DELIVERY invites him to
|
||||||
|
/// look for a difference in handling that does not exist. The stop's real
|
||||||
|
/// kind is still tracked underneath, because the collection gate in P3 needs
|
||||||
|
/// it; this is only what the chip says.
|
||||||
|
final String? workTypeLabel;
|
||||||
|
|
||||||
|
/// Loading is done crate-at-a-counter, so it takes one photo for the whole
|
||||||
|
/// load rather than one per order.
|
||||||
|
///
|
||||||
|
/// A rider standing at a kitchen hatch with fifteen boxes cannot photograph
|
||||||
|
/// each one, and the thing worth photographing is the *crate* — what he was
|
||||||
|
/// handed, in one frame, at one time. False on a parcel route, where proof is
|
||||||
|
/// per consignment because each one has its own chain of custody.
|
||||||
|
final bool bulkLoadProof;
|
||||||
|
|
||||||
|
/// The rider asks the customer where this shipment is coming from and going
|
||||||
|
/// to, and the answers are written back to the booking.
|
||||||
|
///
|
||||||
|
/// ── Why this is a rider's job at all ──
|
||||||
|
///
|
||||||
|
/// A logistics booking often arrives half-addressed: the customer raised it
|
||||||
|
/// from a map pin and described the destination as a phone number and a
|
||||||
|
/// landmark. The rider is the first person standing in front of the sender
|
||||||
|
/// who can ask. Those answers decide the consignment's routing and its
|
||||||
|
/// pricing zone, so they have to be captured before the order is initiated.
|
||||||
|
///
|
||||||
|
/// False on a milk run, where both ends were fixed when the customer
|
||||||
|
/// subscribed and there is nothing for the rider to establish.
|
||||||
|
final bool capturesShipmentAddresses;
|
||||||
|
|
||||||
|
/// The fare is computed at the door from the real shipment, and shown to the
|
||||||
|
/// customer before he pays.
|
||||||
|
///
|
||||||
|
/// Follows [capturesShipmentAddresses] — a price needs a from, a to and a
|
||||||
|
/// weight, and a line that does not collect the first two cannot quote.
|
||||||
|
final bool pricesShipment;
|
||||||
|
|
||||||
|
/// Collection ends by converting the booking into a live shipment with a
|
||||||
|
/// tracking number — the moment the order really exists.
|
||||||
|
///
|
||||||
|
/// On a milk run collection is the *middle* of the day, not the end of a
|
||||||
|
/// transaction, so there is nothing to initiate.
|
||||||
|
final bool initiatesShipment;
|
||||||
|
|
||||||
|
/// The rider carries the load on to the customers himself.
|
||||||
|
///
|
||||||
|
/// True for a milk run: collection is followed by a round of drops he makes
|
||||||
|
/// personally. False for logistics, whose collected shipment goes to the hub
|
||||||
|
/// and is delivered by somebody else on another day — which is why that line
|
||||||
|
/// ends at a warehouse and this one ends at the last door.
|
||||||
|
final bool deliversToCustomer;
|
||||||
|
|
||||||
|
/// Delivery does not begin until every assigned pickup is in the rider's
|
||||||
|
/// hands, and it begins on an explicit press.
|
||||||
|
///
|
||||||
|
/// ── Why the whole load, and why a press ──
|
||||||
|
///
|
||||||
|
/// A milk-run rider works two or three sources before he starts driving to
|
||||||
|
/// customers. Letting each collected order drift into the delivery list on
|
||||||
|
/// its own gives him a half-built round: he sets off after kitchen one, and
|
||||||
|
/// kitchen two's five orders appear behind him. So the round is held until
|
||||||
|
/// the load is complete and then released in one gesture, which is also the
|
||||||
|
/// moment the shipments legitimately become "out for delivery" server-side.
|
||||||
|
final bool startsDeliveryAfterFullLoad;
|
||||||
|
|
||||||
|
const ServiceProfile({
|
||||||
|
required this.line,
|
||||||
|
required this.label,
|
||||||
|
required this.jobNoun,
|
||||||
|
required this.jobNounPlural,
|
||||||
|
required this.completionVerb,
|
||||||
|
required this.collectsCash,
|
||||||
|
required this.needsVerification,
|
||||||
|
required this.needsProofPhoto,
|
||||||
|
required this.acceptsPerStop,
|
||||||
|
required this.sourceIsKitchen,
|
||||||
|
required this.handoffAt,
|
||||||
|
this.bulkLoadProof = false,
|
||||||
|
this.capturesShipmentAddresses = false,
|
||||||
|
this.pricesShipment = false,
|
||||||
|
this.initiatesShipment = false,
|
||||||
|
this.deliversToCustomer = false,
|
||||||
|
this.startsDeliveryAfterFullLoad = false,
|
||||||
|
this.workTypeLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// First-mile parcel logistics — the app as it has always been. Also the
|
||||||
|
/// fallback for an unrecognised tenant, see [TenantController.load].
|
||||||
|
static const ServiceProfile parcel = ServiceProfile(
|
||||||
|
line: ServiceLine.parcel,
|
||||||
|
label: 'Doormile',
|
||||||
|
jobNoun: 'booking',
|
||||||
|
jobNounPlural: 'bookings',
|
||||||
|
completionVerb: 'Picked up',
|
||||||
|
collectsCash: true,
|
||||||
|
needsVerification: true,
|
||||||
|
needsProofPhoto: true,
|
||||||
|
acceptsPerStop: true,
|
||||||
|
sourceIsKitchen: false,
|
||||||
|
handoffAt: HandoffPoint.accepted,
|
||||||
|
// The full shipment desk at the customer's door: where is it from, where
|
||||||
|
// is it going, what does it weigh, what does that cost, and take the money
|
||||||
|
// — then turn it into a real consignment bound for the hub.
|
||||||
|
capturesShipmentAddresses: true,
|
||||||
|
pricesShipment: true,
|
||||||
|
initiatesShipment: true,
|
||||||
|
// The collected shipment goes to the hub; somebody else delivers it.
|
||||||
|
deliversToCustomer: false,
|
||||||
|
// Each stop names its own kind: a parcel route genuinely mixes them.
|
||||||
|
workTypeLabel: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// **Milk Man delivery.** Load a crate at one or more sources, then a round
|
||||||
|
/// of prepaid drops. No money, no verification form, no per-stop decision.
|
||||||
|
static const ServiceProfile milkMan = ServiceProfile(
|
||||||
|
line: ServiceLine.milkMan,
|
||||||
|
label: 'Doormile Delivery',
|
||||||
|
jobNoun: 'delivery',
|
||||||
|
jobNounPlural: 'deliveries',
|
||||||
|
completionVerb: 'Delivered',
|
||||||
|
collectsCash: false,
|
||||||
|
needsVerification: false,
|
||||||
|
needsProofPhoto: false,
|
||||||
|
acceptsPerStop: false,
|
||||||
|
sourceIsKitchen: true,
|
||||||
|
handoffAt: HandoffPoint.collected,
|
||||||
|
bulkLoadProof: true,
|
||||||
|
// None of the shipment desk: these orders were booked and paid for before
|
||||||
|
// the rider's shift started.
|
||||||
|
capturesShipmentAddresses: false,
|
||||||
|
pricesShipment: false,
|
||||||
|
initiatesShipment: false,
|
||||||
|
// He collects the load and delivers it himself, once it is all aboard.
|
||||||
|
deliversToCustomer: true,
|
||||||
|
startsDeliveryAfterFullLoad: true,
|
||||||
|
// One word, deliberately. The chip is a fixed slot beside the state tag and
|
||||||
|
// a two-word label is the one that gives way — "MILK RU…" on every card in
|
||||||
|
// the list. `test/service_card_test.dart` holds this.
|
||||||
|
// ── Never the internal name of the line ──
|
||||||
|
//
|
||||||
|
// This was `MILK`, and it was printed on the rider's cards and on his
|
||||||
|
// Activity record as `Type: MILK`. That is the app's own vocabulary for a
|
||||||
|
// *service profile* — an implementation detail he did not choose, cannot
|
||||||
|
// change and gains nothing from. What he is actually doing at every stop
|
||||||
|
// on this line is a delivery, so that is what the mark says.
|
||||||
|
//
|
||||||
|
// The profile itself stays exactly as it was: it is what separates one
|
||||||
|
// rider's records from another's and decides which capabilities exist.
|
||||||
|
// That separation is a data rule, and it belongs in the data, not on a
|
||||||
|
// badge.
|
||||||
|
workTypeLabel: 'DELIVERY',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The old name for [milkMan], from when the line had one food client.
|
||||||
|
///
|
||||||
|
/// Kept so the existing call sites and tests that name it this way keep
|
||||||
|
/// compiling; there is one profile, under two names.
|
||||||
|
static const ServiceProfile meals = milkMan;
|
||||||
|
|
||||||
|
/// Last-mile delivery — the ported Xpress-rider flow.
|
||||||
|
///
|
||||||
|
/// The capability values here describe the work honestly (a delivery rider
|
||||||
|
/// does collect cash on COD, does photograph a doorstep, does take jobs one at
|
||||||
|
/// a time), but almost nothing reads them: this line renders its own screens,
|
||||||
|
/// so it does not go through the capability branches the parcel screens use.
|
||||||
|
/// They matter only if a delivery rider is ever routed into a shared screen.
|
||||||
|
static const ServiceProfile delivery = ServiceProfile(
|
||||||
|
line: ServiceLine.delivery,
|
||||||
|
label: 'Doormile',
|
||||||
|
jobNoun: 'delivery',
|
||||||
|
jobNounPlural: 'deliveries',
|
||||||
|
completionVerb: 'Delivered',
|
||||||
|
collectsCash: true,
|
||||||
|
needsVerification: false,
|
||||||
|
needsProofPhoto: true,
|
||||||
|
acceptsPerStop: true,
|
||||||
|
sourceIsKitchen: false,
|
||||||
|
handoffAt: HandoffPoint.accepted,
|
||||||
|
workTypeLabel: 'DELIVERY',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// True when an accepted stop stays on Home until the rider is carrying it.
|
||||||
|
bool get handsOffAtCollection => handoffAt == HandoffPoint.collected;
|
||||||
|
|
||||||
|
/// True when every stop wears the same work-type mark — see [workTypeLabel].
|
||||||
|
bool get usesSingleWorkType => workTypeLabel != null;
|
||||||
|
|
||||||
|
/// True on the Milk Man line — collect at a source, then deliver.
|
||||||
|
bool get isMilkMan => line == ServiceLine.milkMan;
|
||||||
|
|
||||||
|
/// The old name for [isMilkMan].
|
||||||
|
bool get isMeals => isMilkMan;
|
||||||
|
|
||||||
|
/// ── Whether the backend can serve this line's work at all ──
|
||||||
|
///
|
||||||
|
/// True on every line, and that is a correction, not a shortcut. This read
|
||||||
|
/// `!sourceIsKitchen`, on the reasoning that the `/miler/*` contract serves
|
||||||
|
/// bookings and consignments while kitchens, crates and subscribers are not
|
||||||
|
/// concepts it has — so a milk-man rider could have no endpoint to ask.
|
||||||
|
///
|
||||||
|
/// **The hub does not dispatch that way.** The console's dispatch board reads
|
||||||
|
/// the milk round from `GET /admin/bookings` and puts a rider on a stop with
|
||||||
|
/// `POST /hub/bookings/:id/auto-assign`; the rider-facing read of those very
|
||||||
|
/// rows is `GET /miler/bookings`. A milk round *is* bookings today. The
|
||||||
|
/// kitchen vocabulary describes how this app **renders** the work — load at a
|
||||||
|
/// source, hand off at collection, one MILK chip per card — not where the
|
||||||
|
/// work comes from.
|
||||||
|
///
|
||||||
|
/// While the two were conflated, a rider on the milk-man line could not be
|
||||||
|
/// given work at all: [WorkRepository] returns `LoadUnavailable` on a false
|
||||||
|
/// answer here and never issues the request, so Home, Bookings and Activity
|
||||||
|
/// were all blank however many bookings the hub had assigned him. That is how
|
||||||
|
/// it was found — Rajan A (userid 38, tenant 13) was assigned a booking in the
|
||||||
|
/// console and the app showed him nothing, with no failure anywhere to
|
||||||
|
/// explain it.
|
||||||
|
///
|
||||||
|
/// The flag stays because the state it feeds is worth keeping: a line the
|
||||||
|
/// backend genuinely cannot answer for should render "your hub has not
|
||||||
|
/// enabled this yet" rather than an empty list that reads as a quiet day. It
|
||||||
|
/// is now something a future line sets deliberately, not something a rider
|
||||||
|
/// inherits from how his round is shaped.
|
||||||
|
///
|
||||||
|
/// It must never be satisfied by [MealRunMock]. That fixture is an opt-in
|
||||||
|
/// development tool behind `--dart-define=MOCK_BACKEND=true`; wiring it in
|
||||||
|
/// here would put fabricated business data on a production path.
|
||||||
|
bool get hasBookingsEndpoint => true;
|
||||||
|
|
||||||
|
/// True when the rider's day ends back at the depot.
|
||||||
|
///
|
||||||
|
/// A logistics rider collects shipments at customers' doors and carries them
|
||||||
|
/// to the hub, so the last leg of his route is a building. A **milk-man**
|
||||||
|
/// round is the other way up: he loads at the kitchen at the start and his
|
||||||
|
/// last address is a customer's door, so there is nothing to return and
|
||||||
|
/// nowhere to return it to — his day ends when the round does.
|
||||||
|
///
|
||||||
|
/// Derived from [deliversToCustomer] rather than stored, because they are the
|
||||||
|
/// same fact: a line that hands goods to the customer has already delivered
|
||||||
|
/// its load by the time it finishes.
|
||||||
|
bool get endsAtHub => !deliversToCustomer;
|
||||||
|
|
||||||
|
/// True on the Logistics line — the shipment desk at the customer's door.
|
||||||
|
bool get isLogistics => line == ServiceLine.parcel;
|
||||||
|
|
||||||
|
/// The old name for [isLogistics].
|
||||||
|
bool get isParcel => isLogistics;
|
||||||
|
|
||||||
|
/// True when this rider gets the ported Xpress-rider screens rather than the
|
||||||
|
/// parcel ones. Read by `helpers/rider_shell.dart` and by nothing else — see
|
||||||
|
/// the note on [ServiceLine.delivery].
|
||||||
|
bool get isDelivery => line == ServiceLine.delivery;
|
||||||
|
|
||||||
|
// ── Reading the active profile ──
|
||||||
|
//
|
||||||
|
// A plain static, deliberately, and not a GetX lookup.
|
||||||
|
//
|
||||||
|
// [stopCollectionAmount] is a pure map helper called from model code and from
|
||||||
|
// tests with no widget tree at all. Routing it through `Get.put` made it
|
||||||
|
// initialise a real `WidgetsFlutterBinding` as a side effect of asking "is
|
||||||
|
// any money owed here?" — which is enough to stop an unrelated widget test in
|
||||||
|
// another file from starting at all. Data code must not be able to boot the
|
||||||
|
// framework.
|
||||||
|
//
|
||||||
|
// [TenantController] still owns loading and still publishes an `Rx` for
|
||||||
|
// screens that want to rebuild; this is the synchronous read everything else
|
||||||
|
// uses.
|
||||||
|
|
||||||
|
static ServiceProfile _active = parcel;
|
||||||
|
|
||||||
|
/// The signed-in rider's profile.
|
||||||
|
///
|
||||||
|
/// Parcel until [TenantController.load] says otherwise — see that method for
|
||||||
|
/// why the fallback runs in that direction.
|
||||||
|
static ServiceProfile get active => _active;
|
||||||
|
|
||||||
|
/// Sets the active profile. Called by [TenantController.load]; also the seam
|
||||||
|
/// tests use to put the app on one line or the other.
|
||||||
|
static void setActive(ServiceProfile profile) => _active = profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the rider's line of work from what login persisted.
|
||||||
|
///
|
||||||
|
/// Order: **the rider's own tenant** (name, then id) → build override →
|
||||||
|
/// logistics.
|
||||||
|
///
|
||||||
|
/// ── Why the server now wins over the build flag ──
|
||||||
|
///
|
||||||
|
/// This used to read the `TENANT` dart-define first, because `verify-pin`
|
||||||
|
/// returned no tenant at all and the flag was the only signal there was. That
|
||||||
|
/// is no longer true: the login response carries `tenantid` and `tenantname`,
|
||||||
|
/// so the rostered answer exists and the app can simply ask.
|
||||||
|
///
|
||||||
|
/// Which way round these two go is a real decision, not a detail. "Which work
|
||||||
|
/// am I doing today?" must be answered by the hub that rostered the rider, not
|
||||||
|
/// by whoever compiled the APK — one build serves every rider, and a flag that
|
||||||
|
/// outranks the account means one wrong build puts every rider on the wrong
|
||||||
|
/// flow. So the flag is now what it should always have been: a fallback for
|
||||||
|
/// builds signed in against a backend that cannot answer, and a way to demo a
|
||||||
|
/// line without a matching account.
|
||||||
|
///
|
||||||
|
/// A free function rather than a controller method so it can be tested without
|
||||||
|
/// a GetX container — see the note on [ServiceProfile.active].
|
||||||
|
Future<ServiceProfile> resolveServiceProfile() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
// ── What the rider's account says ──
|
||||||
|
//
|
||||||
|
// Name first: it is the field a human can check against the admin console,
|
||||||
|
// and it means a new tenant does not need an app release. An unrecognised
|
||||||
|
// name is not an answer, so it falls through to the id.
|
||||||
|
final name = (prefs.getString(TenantController.kTenantName) ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (name.isNotEmpty) {
|
||||||
|
final byName = TenantController.profileForName(name);
|
||||||
|
if (byName != null) return byName;
|
||||||
|
}
|
||||||
|
|
||||||
|
final id = prefs.getInt(TenantController.kTenantId) ?? 0;
|
||||||
|
if (id != 0) {
|
||||||
|
final byId = TenantController.profileForId(id);
|
||||||
|
if (byId != null) return byId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The tenant the session's own token claims ──
|
||||||
|
//
|
||||||
|
// Read after the persisted pair and before the build flag, and it exists for
|
||||||
|
// two cases the pair does not cover.
|
||||||
|
//
|
||||||
|
// The first is a backend whose `verify-pin` does not return the tenant in its
|
||||||
|
// body. It signs one into every token regardless, so the answer is in the
|
||||||
|
// app's hands either way — this is the same fact from the same source.
|
||||||
|
//
|
||||||
|
// The second is the rider who is **already signed in**. His prefs were
|
||||||
|
// written by an older build that stored tenant 0, and nothing would correct
|
||||||
|
// that until he happened to log out; reading his live session instead means
|
||||||
|
// the next launch resolves him properly with no action from him at all.
|
||||||
|
//
|
||||||
|
// Not a security decision — see [ApiConfig.tenantIdFromToken].
|
||||||
|
final claimed = ApiConfig.tenantIdFromToken(prefs.getString('authtoken'));
|
||||||
|
if (claimed != 0) {
|
||||||
|
final byClaim = TenantController.profileForId(claimed);
|
||||||
|
if (byClaim != null) return byClaim;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The build's declared line, as a fallback ──
|
||||||
|
//
|
||||||
|
// Reached only when the account said nothing this app recognises. Unset
|
||||||
|
// means logistics, so an ordinary build is unchanged.
|
||||||
|
if (TenantController.buildOverride.isNotEmpty) {
|
||||||
|
final override = TenantController.buildOverride.trim().toLowerCase();
|
||||||
|
final byOverride = TenantController.profileForName(override);
|
||||||
|
if (byOverride != null) return byOverride;
|
||||||
|
// The ported Xpress line is reachable ONLY from the build flag — never
|
||||||
|
// from a tenant name or id off the wire. It is a reference implementation
|
||||||
|
// that lives beside this app, not one of the two operations the hub runs,
|
||||||
|
// and a tenant that happened to be called "delivery" must not tip a live
|
||||||
|
// rider into a different application. See `helpers/rider_shell.dart`.
|
||||||
|
if (TenantController.deliveryTenantNames.contains(override)) {
|
||||||
|
return ServiceProfile.delivery;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ServiceProfile.parcel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the signed-in rider's line of work, once, and hands out its profile.
|
||||||
|
///
|
||||||
|
/// Follows [DutyController]: a permanent GetX singleton with a `.to` accessor
|
||||||
|
/// and a `load()` that reads persisted prefs, so any screen can read the
|
||||||
|
/// profile synchronously without an await in `build`.
|
||||||
|
class TenantController extends GetxController {
|
||||||
|
/// Parcel until proven otherwise — see [load] for why that direction.
|
||||||
|
final Rx<ServiceProfile> profile = ServiceProfile.parcel.obs;
|
||||||
|
|
||||||
|
/// Persisted by the login response. See `auth_provider.dart`.
|
||||||
|
static const String kTenantId = 'tenantid';
|
||||||
|
static const String kTenantName = 'tenantname';
|
||||||
|
|
||||||
|
/// Tenant ids whose riders work the **Milk Man** line.
|
||||||
|
///
|
||||||
|
/// **This is the switch the hub actually operates.** A rider is put on the
|
||||||
|
/// milk-run flow by being given one of these tenant ids at login and nothing
|
||||||
|
/// else — no separate app, no separate build, no flag on his phone.
|
||||||
|
///
|
||||||
|
/// **13** is the tenant the Milk Man riders are on — the account Rajan A
|
||||||
|
/// (userid 38) signs in with, confirmed from the `tenantid` claim on a live
|
||||||
|
/// login against `api.doormile.com`.
|
||||||
|
///
|
||||||
|
/// ── Why an id is pinned here at all ──
|
||||||
|
///
|
||||||
|
/// The name is the better switch and stays the first thing checked. But the
|
||||||
|
/// deployed backend does not return `tenantname` yet (the handler that does
|
||||||
|
/// is written and not released), so today the id is the only signal a real
|
||||||
|
/// rider carries. When the deploy lands, the name will match first and this
|
||||||
|
/// becomes a belt-and-braces second answer rather than the load-bearing one.
|
||||||
|
///
|
||||||
|
/// **If tenant 13 is not the milk-run client**, this line is the whole fix:
|
||||||
|
/// remove the 13 and a rider on it goes back to logistics.
|
||||||
|
static const Set<int> milkManTenantIds = <int>{13};
|
||||||
|
|
||||||
|
/// The old name for [milkManTenantIds].
|
||||||
|
static const Set<int> mealTenantIds = milkManTenantIds;
|
||||||
|
|
||||||
|
/// Name/code match, case-insensitive, against `tenantname` on the login.
|
||||||
|
///
|
||||||
|
/// Cheaper than a release for every new tenant id, and it is the field a
|
||||||
|
/// human can actually verify against the admin console.
|
||||||
|
static const Set<String> milkManTenantNames = <String>{
|
||||||
|
'milkman',
|
||||||
|
'milk man',
|
||||||
|
'milk-man',
|
||||||
|
'milkrun',
|
||||||
|
'milk run',
|
||||||
|
'meals',
|
||||||
|
'dailygrubs',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The old name for [milkManTenantNames].
|
||||||
|
static const Set<String> mealTenantNames = milkManTenantNames;
|
||||||
|
|
||||||
|
/// Tenant ids whose riders work the **Logistics** line.
|
||||||
|
///
|
||||||
|
/// Logistics is also the fallback, so this list exists to make a tenant
|
||||||
|
/// *explicitly* logistics rather than merely unrecognised — which matters
|
||||||
|
/// when a name would otherwise be ambiguous.
|
||||||
|
static const Set<int> logisticsTenantIds = <int>{};
|
||||||
|
|
||||||
|
/// The names that mean the Logistics line.
|
||||||
|
static const Set<String> logisticsTenantNames = <String>{
|
||||||
|
'doormile',
|
||||||
|
'logistics',
|
||||||
|
'parcel',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The old name for [logisticsTenantNames].
|
||||||
|
static const Set<String> parcelTenantNames = logisticsTenantNames;
|
||||||
|
|
||||||
|
/// The profile a tenant *name* means, or null when this app does not know
|
||||||
|
/// the name. Case-insensitive; callers pass an already-lowercased string.
|
||||||
|
///
|
||||||
|
/// One lookup used by both the account path and the build override, so the
|
||||||
|
/// two can never disagree about what a name means.
|
||||||
|
static ServiceProfile? profileForName(String name) {
|
||||||
|
if (milkManTenantNames.contains(name)) return ServiceProfile.milkMan;
|
||||||
|
if (logisticsTenantNames.contains(name)) return ServiceProfile.parcel;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The profile a tenant *id* means, or null when this app does not know it.
|
||||||
|
static ServiceProfile? profileForId(int id) {
|
||||||
|
if (milkManTenantIds.contains(id)) return ServiceProfile.milkMan;
|
||||||
|
if (logisticsTenantIds.contains(id)) return ServiceProfile.parcel;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tenant ids that would mean the ported Xpress-rider screens under
|
||||||
|
/// `lib/xpress`.
|
||||||
|
///
|
||||||
|
/// **Permanently empty, deliberately.** That subtree is a *reference*
|
||||||
|
/// implementation kept beside this app — it is not one of the two operations
|
||||||
|
/// the hub runs, and the Milk Man flow is now implemented natively in Miler
|
||||||
|
/// rather than by routing riders into it.
|
||||||
|
///
|
||||||
|
/// Nothing off the wire may reach it: a tenant that happened to be named
|
||||||
|
/// "delivery" must not tip a live rider into a different application with a
|
||||||
|
/// different bottom bar and a different set of endpoints. The only way in is
|
||||||
|
/// the [buildOverride] flag, for looking at it. See `resolveServiceProfile`.
|
||||||
|
static const Set<int> deliveryTenantIds = <int>{};
|
||||||
|
|
||||||
|
/// Build-override names that open the ported Xpress screens. Never matched
|
||||||
|
/// against a tenant off the login — see [deliveryTenantIds].
|
||||||
|
static const Set<String> deliveryTenantNames = <String>{
|
||||||
|
'delivery',
|
||||||
|
'xpress',
|
||||||
|
'express',
|
||||||
|
'doormile xpress',
|
||||||
|
'doormilexpress',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Account partitions that mean the ported delivery line. Retained for the
|
||||||
|
/// build-override path only; nothing off the wire is matched against it.
|
||||||
|
static const Set<int> deliveryConfigIds = <int>{6};
|
||||||
|
|
||||||
|
/// Which line this build declares, **when the rider's account does not say**.
|
||||||
|
///
|
||||||
|
/// flutter run --dart-define=TENANT=milkman
|
||||||
|
/// flutter run --dart-define=TENANT=logistics
|
||||||
|
/// flutter run --dart-define=TENANT=delivery # the Xpress reference
|
||||||
|
///
|
||||||
|
/// ── This is a testing aid, not the production switch ──
|
||||||
|
///
|
||||||
|
/// A production rider's line comes from his own tenant, which `verify-pin`
|
||||||
|
/// now returns. This flag is read only when the account carries a tenant this
|
||||||
|
/// app does not recognise, so it cannot override a rostered rider — see the
|
||||||
|
/// ordering note on [resolveServiceProfile].
|
||||||
|
///
|
||||||
|
/// Two flags, and they answer different questions:
|
||||||
|
///
|
||||||
|
/// • `TENANT` picks the **profile**, i.e. which screens the rider gets.
|
||||||
|
/// • `TENANT_ID` is the **number sent to the API** — see [MilerApi.tenantId].
|
||||||
|
///
|
||||||
|
/// Unset means logistics, so an ordinary build is unchanged.
|
||||||
|
static const String buildOverride = String.fromEnvironment('TENANT');
|
||||||
|
|
||||||
|
static TenantController get to => Get.isRegistered<TenantController>()
|
||||||
|
? Get.find<TenantController>()
|
||||||
|
: Get.put(TenantController(), permanent: true);
|
||||||
|
|
||||||
|
/// Resolves the tenant and publishes it, both to [ServiceProfile.active] for
|
||||||
|
/// synchronous reads and to [profile] for screens that rebuild on it.
|
||||||
|
///
|
||||||
|
/// The fallback direction is the point: an unconfigured or unrecognised
|
||||||
|
/// tenant lands on **logistics**. The worst case that way is a milk-run rider
|
||||||
|
/// seeing a payment prompt he can dismiss; the other direction takes the
|
||||||
|
/// cash-collection screen away from a live logistics rider at a door.
|
||||||
|
Future<ServiceProfile> load() async {
|
||||||
|
final resolved = await resolveServiceProfile();
|
||||||
|
ServiceProfile.setActive(resolved);
|
||||||
|
profile.value = resolved;
|
||||||
|
debugPrint('[TENANT] resolved ${resolved.label}');
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
203
lib/data/stop_area.dart
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// WHERE A STOP IS, IN ONE WORD
|
||||||
|
///
|
||||||
|
/// A rider scanning twenty stops is deciding between *areas* — is Joe on the
|
||||||
|
/// way to Priya, or the opposite side of the city. He is not reading twenty
|
||||||
|
/// street strings, and he certainly is not reading twenty street strings cut
|
||||||
|
/// off mid-word: `SEQTEST Gandhipur…` answers nothing that a blank line would
|
||||||
|
/// not have answered, and costs a line to do it.
|
||||||
|
///
|
||||||
|
/// So the collapsed queue shows the locality and the full address lives where
|
||||||
|
/// the rare question is asked — the detail sheet, and the map at the door.
|
||||||
|
///
|
||||||
|
/// ── Why this is one function and not two ──
|
||||||
|
///
|
||||||
|
/// Home and Deliveries both needed it and both had their own. The rule is
|
||||||
|
/// subtle enough to get wrong in exactly the same way twice: the first version
|
||||||
|
/// skipped a component only when it was *entirely* digits, so
|
||||||
|
/// `12 SNS Colony, Peelamedu, Coimbatore 641004` returned `12 SNS Colony` — a
|
||||||
|
/// street with a door number on it, presented as an area. Two copies of that
|
||||||
|
/// bug is two places to find it.
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// The locality component of a stop's address, or `''` when it has none.
|
||||||
|
///
|
||||||
|
/// Prefers the drop, because on a delivery leg the area that matters is where
|
||||||
|
/// the bag is going. Falls back to the pickup for a stop that carries no drop,
|
||||||
|
/// and returns nothing rather than inventing a placeholder.
|
||||||
|
String areaOf(Map<String, dynamic> stop, {bool preferDrop = true}) {
|
||||||
|
final keys = preferDrop
|
||||||
|
? const [
|
||||||
|
'dropaddress',
|
||||||
|
'DropAddress',
|
||||||
|
'deliveryaddress',
|
||||||
|
'pickupaddress',
|
||||||
|
'PickupAddress',
|
||||||
|
]
|
||||||
|
: const [
|
||||||
|
'pickupaddress',
|
||||||
|
'PickupAddress',
|
||||||
|
'dropaddress',
|
||||||
|
'DropAddress',
|
||||||
|
'deliveryaddress',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (final key in keys) {
|
||||||
|
final raw = (stop[key] ?? '').toString().trim();
|
||||||
|
if (raw.isEmpty) continue;
|
||||||
|
|
||||||
|
final parts = raw
|
||||||
|
.split(',')
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.where((p) => p.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// ── An area is a NAME, not a description ──
|
||||||
|
//
|
||||||
|
// Three versions of this rule, each corrected by a real address:
|
||||||
|
//
|
||||||
|
// 1. "Skip a part only when it is entirely digits" printed `12 SNS
|
||||||
|
// Colony` — a door number presented as an area.
|
||||||
|
// 2. "Skip a part containing any digit" fixed that, but then
|
||||||
|
// `12 Peelamedu, Coimbatore` lost `12 Peelamedu` wholesale and answered
|
||||||
|
// with the *city* — and every stop on a Coimbatore route is in
|
||||||
|
// Coimbatore, so the queue said nothing at all. Seen on a device.
|
||||||
|
// 3. Now the digits are stripped from *inside* the part instead of taking
|
||||||
|
// the part with them: `12 Peelamedu` yields the name `Peelamedu`.
|
||||||
|
//
|
||||||
|
// What separates a locality from a landmark is length in words — names
|
||||||
|
// are short (Gandhipuram, Peelamedu), landmarks and streets are phrases
|
||||||
|
// (`Coimbatore Gandhipuram Mofussil Bus Stand`, verified live). And the
|
||||||
|
// parts are scanned in order because an Indian address runs specific →
|
||||||
|
// general: the earliest name is the most local one, which is exactly what
|
||||||
|
// beats `Coimbatore` when both are one-word names.
|
||||||
|
//
|
||||||
|
// The word left when a number is removed is sometimes administrative
|
||||||
|
// noise, not a place — `Ward 54` is not the ward's name — so those
|
||||||
|
// residues are discarded by a small stop list.
|
||||||
|
// `Ward 54` is not a place called Ward, and `4 Cross` — Indian street
|
||||||
|
// numbering — is not a place called Cross. Both from live addresses.
|
||||||
|
const noise = {
|
||||||
|
'ward', 'zone', 'no', 'door', 'flat', 'floor', 'plot', 'block', //
|
||||||
|
'cross', 'main', 'street', 'road',
|
||||||
|
};
|
||||||
|
String? nameOf(String part) {
|
||||||
|
final words = [
|
||||||
|
for (final w in part.split(RegExp(r'\s+')))
|
||||||
|
if (!RegExp(r'[0-9]').hasMatch(w)) w,
|
||||||
|
];
|
||||||
|
if (words.isEmpty) return null;
|
||||||
|
// `RS Puram RS Puram` — the geocoder stammer [compactAddress] already
|
||||||
|
// collapses — reaches here too, seen live as `Gandhipuram Gandhipuram`
|
||||||
|
// on the queue. A name whose two halves are the same name is one name.
|
||||||
|
if (words.length.isEven && words.length >= 2) {
|
||||||
|
final half = words.length ~/ 2;
|
||||||
|
final a = words.sublist(0, half).join(' ');
|
||||||
|
final b = words.sublist(half).join(' ');
|
||||||
|
if (a.toLowerCase() == b.toLowerCase()) {
|
||||||
|
words.removeRange(half, words.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final name = words.join(' ');
|
||||||
|
if (words.length == 1 &&
|
||||||
|
(name.length < 3 || noise.contains(name.toLowerCase()))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The city rung is named, because position cannot find it ──
|
||||||
|
//
|
||||||
|
// "Drop the last part" was tried and is structurally undecidable: an
|
||||||
|
// address that ends at the city (`2 Saibaba Colony, Coimbatore`) and one
|
||||||
|
// that ends at the locality (`12, Cross Cut Road, Gandhipuram`) look
|
||||||
|
// identical from the end. Both are live addresses, and each broke the
|
||||||
|
// heuristic the other needed.
|
||||||
|
//
|
||||||
|
// So the rung is knowledge, like the noise words above: the region this
|
||||||
|
// tenant operates in, plus the state/country tails geocoders append. A
|
||||||
|
// city name distinguishes nothing on a route that is entirely inside it,
|
||||||
|
// so it ranks last — but it still stands when it is all the address has,
|
||||||
|
// because an honest rung beats a blank.
|
||||||
|
bool isCity(String n) => const {
|
||||||
|
'coimbatore', 'coimbatore north', 'coimbatore south', //
|
||||||
|
'tamil nadu', 'india', 'chennai', 'tiruppur', 'erode', 'salem',
|
||||||
|
'madurai', 'pollachi', 'mettupalayam',
|
||||||
|
}.contains(n.toLowerCase());
|
||||||
|
|
||||||
|
// A two-word name ending in a thoroughfare word is a street, not a
|
||||||
|
// locality — `Mettupalayam Road` must lose to the `RS Puram` behind it,
|
||||||
|
// exactly as the longer landmark phrases already do.
|
||||||
|
bool isStreet(String n) => const {
|
||||||
|
'road', 'street', 'salai', 'lane', 'veedhi', 'highway', //
|
||||||
|
}.contains(n.split(' ').last.toLowerCase());
|
||||||
|
|
||||||
|
final names = [
|
||||||
|
for (final p in parts)
|
||||||
|
if (nameOf(p) case final n?) n,
|
||||||
|
];
|
||||||
|
final local = [
|
||||||
|
for (final n in names)
|
||||||
|
if (!isCity(n)) n,
|
||||||
|
];
|
||||||
|
|
||||||
|
// In rank order: the first one-word name — a locality is a name and
|
||||||
|
// names are short; then the first short non-street name (RS Puram,
|
||||||
|
// Saibaba Colony); then any phrase left; and only then the city.
|
||||||
|
for (final n in local) {
|
||||||
|
if (!n.contains(' ')) return n;
|
||||||
|
}
|
||||||
|
for (final n in local) {
|
||||||
|
if (n.split(' ').length <= 2 && !isStreet(n)) return n;
|
||||||
|
}
|
||||||
|
if (local.isNotEmpty) return local.first;
|
||||||
|
if (names.isNotEmpty) return names.first;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A full address, minus the boilerplate a geocoder appends.
|
||||||
|
///
|
||||||
|
/// The record page prints the address in full — it is the one screen whose
|
||||||
|
/// job is the specifics — and on live data that full address arrived as
|
||||||
|
/// `RS Puram RS Puram, Ward 24, West Zone, Perur, Coimbatore South,
|
||||||
|
/// Coimbatore, Tamil Nadu, 641002, India`, wrapped to two lines and cut with
|
||||||
|
/// an ellipsis. The trimmed tail was the useful half; the kept head began by
|
||||||
|
/// stammering.
|
||||||
|
///
|
||||||
|
/// This strips only what carries no information at a door in this region:
|
||||||
|
/// the state and country rungs, a part that is nothing but a pincode, an
|
||||||
|
/// immediate word-for-word repeat inside a part, and a part that repeats the
|
||||||
|
/// one before it. Everything that names a place stays, in order.
|
||||||
|
String compactAddress(String raw) {
|
||||||
|
final parts = raw
|
||||||
|
.split(',')
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.where((p) => p.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
const tail = {'tamil nadu', 'india'};
|
||||||
|
|
||||||
|
String dedupWords(String part) {
|
||||||
|
final w = part.split(RegExp(r'\s+'));
|
||||||
|
if (w.length.isEven && w.length >= 2) {
|
||||||
|
final half = w.length ~/ 2;
|
||||||
|
final a = w.sublist(0, half).join(' ');
|
||||||
|
final b = w.sublist(half).join(' ');
|
||||||
|
if (a.toLowerCase() == b.toLowerCase()) return a;
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
|
||||||
|
final kept = <String>[];
|
||||||
|
for (final p in parts) {
|
||||||
|
final cleaned = dedupWords(p);
|
||||||
|
final lower = cleaned.toLowerCase();
|
||||||
|
if (tail.contains(lower)) continue;
|
||||||
|
if (RegExp(r'^[0-9]{6}$').hasMatch(cleaned)) continue;
|
||||||
|
if (kept.isNotEmpty && kept.last.toLowerCase() == lower) continue;
|
||||||
|
kept.add(cleaned);
|
||||||
|
}
|
||||||
|
return kept.join(', ');
|
||||||
|
}
|
||||||
89
lib/data/stop_contact.dart
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// WHO THE RIDER IS RINGING
|
||||||
|
///
|
||||||
|
/// One number on a screen is not one fact. `Call` beside a kitchen's name and
|
||||||
|
/// `Call` beside a customer's name are different promises, and a rider at a
|
||||||
|
/// door who reaches the kitchen instead has lost the two minutes the control
|
||||||
|
/// existed to save him.
|
||||||
|
///
|
||||||
|
/// ── What the payload actually carries ──
|
||||||
|
///
|
||||||
|
/// Verified against production 21 Aug 2026: `GET /miler/bookings` returns
|
||||||
|
/// exactly **one** phone field, `customerphone`, which the adapter stores as
|
||||||
|
/// `pickupcontactno` — a key named after the leg it was first read on rather
|
||||||
|
/// than after whose number it is. There is no separate drop or receiver
|
||||||
|
/// contact anywhere in the contract.
|
||||||
|
///
|
||||||
|
/// So on today's data there is only one number to dial and it belongs to the
|
||||||
|
/// booking's customer: the sender on a logistics collection, the subscriber on
|
||||||
|
/// a meal delivery. Dialling it is correct on both legs. What was **not**
|
||||||
|
/// correct is the app announcing it as "Call customer" while the rider is
|
||||||
|
/// standing at a kitchen counter.
|
||||||
|
///
|
||||||
|
/// This file therefore does two things: it prefers a genuine drop contact the
|
||||||
|
/// moment the backend ships one — the precedence is written now so that day
|
||||||
|
/// needs no archaeology — and it names who is being called, per leg, so the
|
||||||
|
/// label can never drift from the number again.
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// A number to dial, and who answers it.
|
||||||
|
class StopContact {
|
||||||
|
const StopContact(this.number, this.who);
|
||||||
|
|
||||||
|
/// Empty when the stop carries no usable number. Callers omit the control
|
||||||
|
/// rather than showing one that cannot dial.
|
||||||
|
final String number;
|
||||||
|
|
||||||
|
/// `the kitchen` / `the customer` — used to build the accessible label and
|
||||||
|
/// any visible caption, so both are produced from one decision.
|
||||||
|
final String who;
|
||||||
|
|
||||||
|
bool get isEmpty => number.isEmpty;
|
||||||
|
bool get isNotEmpty => number.isNotEmpty;
|
||||||
|
|
||||||
|
/// `Call the customer`.
|
||||||
|
String get action => 'Call $who';
|
||||||
|
|
||||||
|
/// Resolves the contact for the leg being worked.
|
||||||
|
///
|
||||||
|
/// [delivery] is the leg, not the line: a milk run's collection at a kitchen
|
||||||
|
/// is a pickup leg even though the line delivers to customers. Ask
|
||||||
|
/// `MilkRun.navigatesToCustomer` for it rather than deriving it here — one
|
||||||
|
/// answer to "which leg is this", used by the map, the sheet and this.
|
||||||
|
static StopContact forLeg(
|
||||||
|
Map<String, dynamic> stop, {
|
||||||
|
required bool delivery,
|
||||||
|
}) {
|
||||||
|
String read(List<String> keys) {
|
||||||
|
for (final k in keys) {
|
||||||
|
final v = (stop[k] ?? '').toString().trim();
|
||||||
|
if (v.isNotEmpty && v != 'null' && v != '0') return v;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Written for the contract that exists *and* the one that is coming: a
|
||||||
|
// drop contact wins on a delivery leg the moment one is present, and until
|
||||||
|
// then the single number the payload carries is used on both.
|
||||||
|
const dropKeys = [
|
||||||
|
'dropcontactno',
|
||||||
|
'DropContactNo',
|
||||||
|
'deliverycontactno',
|
||||||
|
'receiverphone',
|
||||||
|
'dropphone',
|
||||||
|
];
|
||||||
|
const pickupKeys = [
|
||||||
|
'pickupcontactno',
|
||||||
|
'PickupContactNo',
|
||||||
|
'contactno',
|
||||||
|
'customerphone',
|
||||||
|
];
|
||||||
|
|
||||||
|
final number = delivery
|
||||||
|
? read([...dropKeys, ...pickupKeys])
|
||||||
|
: read([...pickupKeys, ...dropKeys]);
|
||||||
|
|
||||||
|
return StopContact(number, delivery ? 'the customer' : 'the pickup');
|
||||||
|
}
|
||||||
|
}
|
||||||
168
lib/data/work_domain.dart
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import 'package:miler/Models/stop_status.dart';
|
||||||
|
import 'package:miler/data/milk_run.dart';
|
||||||
|
import 'package:miler/data/service_profile.dart';
|
||||||
|
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// WHICH SCREEN OWNS AN ORDER
|
||||||
|
///
|
||||||
|
/// One rule, in one place, for the question both work screens have to answer
|
||||||
|
/// about every row of the shared day: **is this Home's or is it Deliveries'?**
|
||||||
|
///
|
||||||
|
/// ── Why it is here and not on the screens ──
|
||||||
|
///
|
||||||
|
/// It used to be answered twice. Home decided what to draw from
|
||||||
|
/// [stopStateOf] plus its own local stores; the Deliveries tab decided what to
|
||||||
|
/// list from a `where` clause inside its fetch, with a second `where` for the
|
||||||
|
/// statuses that clause was supposed to re-admit. Two expressions of one fact,
|
||||||
|
/// edited on different days, and they disagreed exactly where it hurts most:
|
||||||
|
/// **an accepted booking could be claimed by both.** It stayed on Home to be
|
||||||
|
/// collected, and it appeared on Deliveries as live work with a strip offering
|
||||||
|
/// to continue it — which opened a map, an I'VE ARRIVED and a confirmation
|
||||||
|
/// sheet for a collection the rider had not made yet.
|
||||||
|
///
|
||||||
|
/// The fix is not a third filter. It is that the classification is one function
|
||||||
|
/// on the shared data layer, above both screens, so they cannot hold different
|
||||||
|
/// opinions about where an order belongs.
|
||||||
|
///
|
||||||
|
/// ── The boundary ──
|
||||||
|
///
|
||||||
|
/// PENDING ─ accept ─▶ ACCEPTED ─ navigate ─▶ ARRIVED ─ pick up ─▶ PICKED
|
||||||
|
/// └──────────────── HOME owns all of this ──────────────┘ │
|
||||||
|
/// ▼
|
||||||
|
/// DELIVERIES owns from here on
|
||||||
|
///
|
||||||
|
/// **Picked up is the boundary**, and only the backend can move it. Accepting
|
||||||
|
/// an assignment is a decision about work still to be done; it is not evidence
|
||||||
|
/// that anything has been collected. Nothing in this file infers a completed
|
||||||
|
/// pickup from an acceptance.
|
||||||
|
///
|
||||||
|
/// ── The one knob ──
|
||||||
|
///
|
||||||
|
/// Where the boundary sits is a property of the rider's line, declared once as
|
||||||
|
/// [ServiceProfile.handoffAt], and read here rather than re-derived:
|
||||||
|
///
|
||||||
|
/// • **A kitchen line** ([HandoffPoint.collected]) hands over at collection.
|
||||||
|
/// The rider makes one trip to one counter for a stack of bags, so every
|
||||||
|
/// rung up to picked-up is Home's, in bulk, and Deliveries holds only what
|
||||||
|
/// is in his hands.
|
||||||
|
/// • **Logistics** ([HandoffPoint.accepted]) hands over at acceptance. There is
|
||||||
|
/// no counter and nothing to batch: he drives to one customer, raises the
|
||||||
|
/// shipment at the door and the collection *is* the job — so an accepted
|
||||||
|
/// booking is already the work tab's, which is where that flow lives.
|
||||||
|
///
|
||||||
|
/// Same rule, one declared knob. Not a line-name check, and not a per-screen
|
||||||
|
/// conditional. See [ServiceProfile.handoffAt].
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
enum WorkDomain {
|
||||||
|
/// Home's. Pending, accepted, on the way, arrived — everything before the
|
||||||
|
/// backend has confirmed the load is aboard.
|
||||||
|
pickup,
|
||||||
|
|
||||||
|
/// The Deliveries tab's. Past the hand-over point for this line.
|
||||||
|
delivery,
|
||||||
|
|
||||||
|
/// Neither work screen's. Finished or withdrawn — Activity's record.
|
||||||
|
closed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hand-over point between [WorkDomain.pickup] and [WorkDomain.delivery].
|
||||||
|
abstract final class WorkBoundary {
|
||||||
|
/// True once the **backend** says the collection is done.
|
||||||
|
///
|
||||||
|
/// Two sources, both authoritative, neither of them "the rider accepted it":
|
||||||
|
///
|
||||||
|
/// • The status on the row — `Picked_Up` / `Converted_To_Consignment`, or
|
||||||
|
/// any of the delivery rungs past them.
|
||||||
|
/// • The collected record, written by Home only after the pickup-complete
|
||||||
|
/// call has come back successful. It exists because the queue is up to one
|
||||||
|
/// poll behind: without it a stop the rider has just handed over jumps back
|
||||||
|
/// to Home for a few seconds, which reads as the confirm having failed.
|
||||||
|
///
|
||||||
|
/// It deliberately does **not** consult the accepted store. That set says a
|
||||||
|
/// decision was made, not that goods changed hands.
|
||||||
|
static bool pickupComplete(
|
||||||
|
Map<String, dynamic> stop, {
|
||||||
|
Set<String> collectedIds = const <String>{},
|
||||||
|
}) {
|
||||||
|
final status = stopStatusOf(stop);
|
||||||
|
if (status.isPicked || status.isDeliveryLeg) return true;
|
||||||
|
return collectedIds.contains(MilkRun.idOf(stop));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when this order is finished or withdrawn, whatever screen it was on.
|
||||||
|
///
|
||||||
|
/// [StopStatus.picked] is terminal on a line that ends at the hub and is the
|
||||||
|
/// middle of the morning on one that does not — so it is asked of the line,
|
||||||
|
/// through the same knob everything else here reads. See
|
||||||
|
/// [ServiceProfile.endsAtHub].
|
||||||
|
static bool isClosed(Map<String, dynamic> stop) {
|
||||||
|
final status = stopStatusOf(stop);
|
||||||
|
if (status == StopStatus.delivered || status.isCancelled) return true;
|
||||||
|
return status.isPicked && ServiceProfile.active.endsAtHub;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which screen owns this order.
|
||||||
|
///
|
||||||
|
/// [rejectedIds] and a server-side rejection do **not** close an order here:
|
||||||
|
/// a declined stop stays in the pickup domain because Home is where the rider
|
||||||
|
/// can change his mind about it. What it must never be is delivery work.
|
||||||
|
static WorkDomain domainOf(
|
||||||
|
Map<String, dynamic> stop, {
|
||||||
|
Set<String> collectedIds = const <String>{},
|
||||||
|
Set<String> acceptedIds = const <String>{},
|
||||||
|
}) {
|
||||||
|
if (isClosed(stop)) return WorkDomain.closed;
|
||||||
|
|
||||||
|
final handedOver = switch (ServiceProfile.active.handoffAt) {
|
||||||
|
HandoffPoint.collected => pickupComplete(
|
||||||
|
stop,
|
||||||
|
collectedIds: collectedIds,
|
||||||
|
),
|
||||||
|
// On logistics the work tab takes it at acceptance — from either source,
|
||||||
|
// because the local store leads the queue by a poll.
|
||||||
|
HandoffPoint.accepted =>
|
||||||
|
acceptedIds.contains(MilkRun.idOf(stop)) ||
|
||||||
|
stopStatusOf(stop) == StopStatus.accepted ||
|
||||||
|
stopStatusOf(stop).isActive ||
|
||||||
|
stopStatusOf(stop) == StopStatus.arrived,
|
||||||
|
};
|
||||||
|
|
||||||
|
return handedOver ? WorkDomain.delivery : WorkDomain.pickup;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the Deliveries tab is allowed to hold, out of the shared day.
|
||||||
|
///
|
||||||
|
/// The tab's list, the count on Home's pill and anything else that asks "how
|
||||||
|
/// much is waiting on the other tab?" all read this, so the two screens
|
||||||
|
/// cannot report different numbers for the same day.
|
||||||
|
static List<Map<String, dynamic>> deliveryQueue(
|
||||||
|
Iterable<Map<String, dynamic>> day, {
|
||||||
|
Set<String> collectedIds = const <String>{},
|
||||||
|
Set<String> acceptedIds = const <String>{},
|
||||||
|
}) => [
|
||||||
|
for (final stop in day)
|
||||||
|
if (domainOf(
|
||||||
|
stop,
|
||||||
|
collectedIds: collectedIds,
|
||||||
|
acceptedIds: acceptedIds,
|
||||||
|
) ==
|
||||||
|
WorkDomain.delivery)
|
||||||
|
stop,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Everything Home is still responsible for, out of the shared day.
|
||||||
|
static List<Map<String, dynamic>> pickupQueue(
|
||||||
|
Iterable<Map<String, dynamic>> day, {
|
||||||
|
Set<String> collectedIds = const <String>{},
|
||||||
|
Set<String> acceptedIds = const <String>{},
|
||||||
|
}) => [
|
||||||
|
for (final stop in day)
|
||||||
|
if (domainOf(
|
||||||
|
stop,
|
||||||
|
collectedIds: collectedIds,
|
||||||
|
acceptedIds: acceptedIds,
|
||||||
|
) ==
|
||||||
|
WorkDomain.pickup)
|
||||||
|
stop,
|
||||||
|
];
|
||||||
|
}
|
||||||
321
lib/data/work_repository.dart
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'package:miler/Models/stop_status.dart';
|
||||||
|
import 'package:miler/data/api_config.dart';
|
||||||
|
import 'package:miler/data/route_order.dart';
|
||||||
|
import 'package:miler/data/order_events.dart';
|
||||||
|
import 'package:miler/data/assignment_lookup.dart';
|
||||||
|
import 'package:miler/data/load_state.dart';
|
||||||
|
import 'package:miler/data/meal_run_mock.dart';
|
||||||
|
import 'package:miler/data/miler_api.dart';
|
||||||
|
import 'package:miler/data/service_profile.dart';
|
||||||
|
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// ONE DAY'S WORK, FETCHED ONCE
|
||||||
|
///
|
||||||
|
/// Home, Deliveries and Activity all answer questions about the same set of
|
||||||
|
/// bookings, and each of them fetched it independently: three timers, three
|
||||||
|
/// copies of the list, three ideas of what had been accepted. Switching tabs
|
||||||
|
/// re-fetched. A rebuild re-fetched. Accepting a stop on Home updated Home's
|
||||||
|
/// copy and left the other two showing yesterday's answer until their own poll
|
||||||
|
/// came round.
|
||||||
|
///
|
||||||
|
/// This is the one copy. The screens read [state] and listen to [changes]; none
|
||||||
|
/// of them fetches.
|
||||||
|
///
|
||||||
|
/// ── Three things it does that a plain `Future` does not ──
|
||||||
|
///
|
||||||
|
/// * **Collapses concurrent callers.** Three screens mounting in the same frame
|
||||||
|
/// produce one request, and all three get its result. The second and third
|
||||||
|
/// callers await the *same* future rather than starting their own.
|
||||||
|
/// * **Refuses stale writes.** Every load carries a sequence number, and a
|
||||||
|
/// response whose sequence is behind the newest one is dropped. A slow
|
||||||
|
/// refresh that lands after a fast one can no longer overwrite it — the
|
||||||
|
/// classic "pull to refresh, then the old response arrives and the list goes
|
||||||
|
/// backwards" bug.
|
||||||
|
/// * **Distinguishes the three empties.** Nothing today, could not ask, and no
|
||||||
|
/// endpoint for this line are different answers — see [LoadState].
|
||||||
|
///
|
||||||
|
/// ── What it deliberately does not do ──
|
||||||
|
///
|
||||||
|
/// No polling of its own, no retry loop, no cache written to disk. The screens
|
||||||
|
/// own when to ask; this owns making sure asking twice costs one request.
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
class WorkRepository {
|
||||||
|
WorkRepository._();
|
||||||
|
|
||||||
|
/// The app's instance. A plain static rather than a DI registration, matching
|
||||||
|
/// how [ServiceProfile] and the stores in this layer are reached — data code
|
||||||
|
/// here must not need a widget tree to exist.
|
||||||
|
static final WorkRepository instance = WorkRepository._();
|
||||||
|
|
||||||
|
final _controller =
|
||||||
|
StreamController<LoadState<List<Map<String, dynamic>>>>.broadcast();
|
||||||
|
|
||||||
|
LoadState<List<Map<String, dynamic>>> _state =
|
||||||
|
const LoadLoading<List<Map<String, dynamic>>>();
|
||||||
|
|
||||||
|
/// The current answer. Safe to read during `build`.
|
||||||
|
LoadState<List<Map<String, dynamic>>> get state => _state;
|
||||||
|
|
||||||
|
/// Every change, for screens that want to rebuild without polling.
|
||||||
|
Stream<LoadState<List<Map<String, dynamic>>>> get changes =>
|
||||||
|
_controller.stream;
|
||||||
|
|
||||||
|
/// The request in flight, if any. A second caller awaits this rather than
|
||||||
|
/// starting a second request.
|
||||||
|
Future<LoadState<List<Map<String, dynamic>>>>? _inFlight;
|
||||||
|
|
||||||
|
/// Monotonic, so a late response can be recognised as late.
|
||||||
|
int _sequence = 0;
|
||||||
|
|
||||||
|
/// How long a result stays fresh enough to hand to a new caller without
|
||||||
|
/// asking again. Covers the case this exists for: three screens mounting
|
||||||
|
/// within a frame of each other, and a tab switch a second later.
|
||||||
|
static const Duration freshFor = Duration(seconds: 10);
|
||||||
|
DateTime? _loadedAt;
|
||||||
|
|
||||||
|
bool get _isFresh {
|
||||||
|
final at = _loadedAt;
|
||||||
|
return at != null && DateTime.now().difference(at) < freshFor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads the day, or hands back what is already loading or already fresh.
|
||||||
|
///
|
||||||
|
/// [force] skips the freshness check — pull-to-refresh — but still collapses
|
||||||
|
/// into an in-flight request rather than racing it.
|
||||||
|
Future<LoadState<List<Map<String, dynamic>>>> load({bool force = false}) {
|
||||||
|
// ── Collapse, unless the rider asked ──
|
||||||
|
//
|
||||||
|
// An *incidental* load — a screen mounting, a tab switch, a rebuild —
|
||||||
|
// joins whatever is already running. That is the whole reason this exists,
|
||||||
|
// and it is what stops three screens producing three requests.
|
||||||
|
//
|
||||||
|
// A **forced** load does not, because a pull-to-refresh that quietly hands
|
||||||
|
// back the result of a request started before the rider's gesture is not a
|
||||||
|
// refresh. It starts its own, and the sequence guard in [_publish] drops
|
||||||
|
// whichever response lands out of order.
|
||||||
|
final existing = _inFlight;
|
||||||
|
if (existing != null && !force) return existing;
|
||||||
|
|
||||||
|
if (!force && _isFresh && _state is LoadData) {
|
||||||
|
return Future.value(_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
final future = _fetch();
|
||||||
|
_inFlight = future;
|
||||||
|
return future.whenComplete(() {
|
||||||
|
if (identical(_inFlight, future)) _inFlight = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the current answer stale and re-reads it.
|
||||||
|
///
|
||||||
|
/// Called after every mutation the server has confirmed, so the screens take
|
||||||
|
/// their new state from the API rather than from the button that was pressed.
|
||||||
|
Future<LoadState<List<Map<String, dynamic>>>> invalidate() {
|
||||||
|
_loadedAt = null;
|
||||||
|
return load(force: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<LoadState<List<Map<String, dynamic>>>> _fetch() async {
|
||||||
|
final seq = ++_sequence;
|
||||||
|
|
||||||
|
// Keep whatever is on screen visible while the refresh runs. A pull to
|
||||||
|
// refresh must not blank the list it is refreshing.
|
||||||
|
final current = _state;
|
||||||
|
_publish(
|
||||||
|
current is LoadData<List<Map<String, dynamic>>>
|
||||||
|
? LoadData(current.value, refreshing: true)
|
||||||
|
: const LoadLoading<List<Map<String, dynamic>>>(),
|
||||||
|
seq,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The opt-in development fixture, checked first and named as such. See
|
||||||
|
// [MealRunMock]; it is unreachable unless somebody passed MOCK_BACKEND.
|
||||||
|
if (MealRunMock.active) {
|
||||||
|
final day = MealRunMock.stops().cast<Map<String, dynamic>>();
|
||||||
|
debugPrint('[WORK] OPT-IN FIXTURE, not the API: ${day.length} stops');
|
||||||
|
return _publish(
|
||||||
|
day.isEmpty
|
||||||
|
? const LoadEmpty<List<Map<String, dynamic>>>()
|
||||||
|
: LoadData(day),
|
||||||
|
seq,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No fixture and no endpoint. Distinct from an empty day, because neither
|
||||||
|
// waiting nor retrying will change it.
|
||||||
|
if (!ServiceProfile.active.hasBookingsEndpoint) {
|
||||||
|
return _publish(
|
||||||
|
LoadUnavailable<List<Map<String, dynamic>>>(
|
||||||
|
'${ServiceProfile.active.label} work is not served by this backend '
|
||||||
|
'yet.',
|
||||||
|
),
|
||||||
|
seq,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final res = await MilerApi.bookings();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return _publish(
|
||||||
|
LoadFailure<List<Map<String, dynamic>>>(
|
||||||
|
_classify(res),
|
||||||
|
message: res.message,
|
||||||
|
),
|
||||||
|
seq,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final mapped = ApiConfig.pickupsFromBookings(res.list);
|
||||||
|
|
||||||
|
// ── The hub's solved order, stamped on here and nowhere else ──
|
||||||
|
//
|
||||||
|
// `step` now ships on the booking row itself (verified live 21 Aug 2026),
|
||||||
|
// so the common path is that this finds nothing to do — which is the
|
||||||
|
// intended end state. It stays because it is the delivery leg's safety
|
||||||
|
// net: the assignment row is the field's original home, and a booking that
|
||||||
|
// arrives without one but has a live assignment carrying it must not lose
|
||||||
|
// the hub's order. **A `step` already on the payload always wins.**
|
||||||
|
//
|
||||||
|
// Merged at the repository rather than on a screen, because all three
|
||||||
|
// screens order by it and a stop that is third on Home must not be second
|
||||||
|
// on Deliveries.
|
||||||
|
//
|
||||||
|
// Best-effort, and deliberately so: the flow contract says an optimizer
|
||||||
|
// outage leaves work *assigned but unordered*, never undone. A failure here
|
||||||
|
// leaves every stop as it arrived, and [RouteOrder] then falls back — and
|
||||||
|
// says that it has, rather than passing its own guess off as the route.
|
||||||
|
await _stampSequence(mapped);
|
||||||
|
if (mapped.isEmpty && res.list.isNotEmpty) {
|
||||||
|
// Rows arrived and none survived translation — a field-name mismatch,
|
||||||
|
// not an empty day. Said out loud rather than rendered as "no work".
|
||||||
|
ApiConfig.logGap(
|
||||||
|
'pickupFromBooking',
|
||||||
|
'${res.list.length} bookings returned but none mapped — check the '
|
||||||
|
'field names against a real payload.',
|
||||||
|
);
|
||||||
|
return _publish(
|
||||||
|
const LoadFailure<List<Map<String, dynamic>>>(
|
||||||
|
LoadFailureKind.server,
|
||||||
|
message: 'The hub sent work this app could not read.',
|
||||||
|
),
|
||||||
|
seq,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── When the work reached this rider ──
|
||||||
|
//
|
||||||
|
// The contract has no assignment timestamp: `GET /miler/assignments`
|
||||||
|
// returns the rows and no clock on them, and the booking's own `updatedat`
|
||||||
|
// moves every time anything touches it — the console warns against reading
|
||||||
|
// it as an assignment time for exactly that reason.
|
||||||
|
//
|
||||||
|
// What the app can say truthfully is when a booking *first appeared in this
|
||||||
|
// rider's queue*, which is the moment it became his. Stamped here because
|
||||||
|
// this is the one place every screen's day comes through, and stamped once:
|
||||||
|
// [stampOrderEvent] never overwrites, so a poll a second later cannot move
|
||||||
|
// it. Read only by the Activity timeline; nothing decides anything on it.
|
||||||
|
//
|
||||||
|
// **Only work still in front of him.** Stamping every row the fetch carries
|
||||||
|
// put an `Assigned 3:34 PM` on a stop that had been delivered at 11:57 that
|
||||||
|
// morning: the ledger did not exist when the work arrived, so the first
|
||||||
|
// sighting was the app being opened in the afternoon. A clock that lands
|
||||||
|
// after the completion it is supposed to precede is worse than no clock —
|
||||||
|
// it is the timeline contradicting itself in the rider's face. Anything the
|
||||||
|
// backend already reports as past his hands is skipped, and stays skipped
|
||||||
|
// forever because [stampOrderEvent] never overwrites.
|
||||||
|
unawaited(
|
||||||
|
stampOrderEvents(
|
||||||
|
mapped
|
||||||
|
.where((s) {
|
||||||
|
final st = stopStatusOf(s);
|
||||||
|
return !st.isWorkComplete &&
|
||||||
|
!st.isCancelled &&
|
||||||
|
!st.isRejected &&
|
||||||
|
!st.isSkipped &&
|
||||||
|
!st.isPicked &&
|
||||||
|
!st.isDeliveryLeg;
|
||||||
|
})
|
||||||
|
.map((s) => (s['orderid'] ?? '').toString())
|
||||||
|
.where((id) => id.isNotEmpty),
|
||||||
|
OrderEvent.assigned,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
_loadedAt = DateTime.now();
|
||||||
|
return _publish(
|
||||||
|
mapped.isEmpty
|
||||||
|
? const LoadEmpty<List<Map<String, dynamic>>>()
|
||||||
|
: LoadData(mapped),
|
||||||
|
seq,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the assignment sequence onto the day's stops, in place.
|
||||||
|
Future<void> _stampSequence(List<Map<String, dynamic>> day) async {
|
||||||
|
if (day.isEmpty) return;
|
||||||
|
try {
|
||||||
|
final steps = await AssignmentLookup.steps();
|
||||||
|
if (steps.isEmpty) return;
|
||||||
|
var stamped = 0;
|
||||||
|
for (final stop in day) {
|
||||||
|
// Read through [RouteOrder] so "already sequenced" means the same
|
||||||
|
// thing here as it does at every screen that orders by it.
|
||||||
|
if (RouteOrder.sequenceOf(stop) > 0) continue;
|
||||||
|
final key = (stop['bookingid'] ?? stop['orderheaderid'] ?? '')
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
final step = steps[key];
|
||||||
|
if (step == null) continue;
|
||||||
|
stop['step'] = step;
|
||||||
|
stamped++;
|
||||||
|
}
|
||||||
|
debugPrint('[WORK] sequenced $stamped/${day.length} stops from the hub');
|
||||||
|
final unsequenced = day
|
||||||
|
.where((s) => RouteOrder.sequenceOf(s) == 0)
|
||||||
|
.length;
|
||||||
|
RouteOrder.logUnsequenced('work-repository', unsequenced);
|
||||||
|
} catch (e) {
|
||||||
|
// Never fails the day's work: an unordered route is workable, a missing
|
||||||
|
// one is not.
|
||||||
|
debugPrint('[WORK] could not read the assignment sequence: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns a refused call into the thing the rider does about it.
|
||||||
|
static LoadFailureKind _classify(ApiResult res) => switch (res.status) {
|
||||||
|
0 => LoadFailureKind.offline,
|
||||||
|
401 || 403 => LoadFailureKind.unauthorized,
|
||||||
|
429 => LoadFailureKind.rateLimited,
|
||||||
|
_ => LoadFailureKind.server,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Publishes [next] unless a newer load has already started.
|
||||||
|
LoadState<List<Map<String, dynamic>>> _publish(
|
||||||
|
LoadState<List<Map<String, dynamic>>> next,
|
||||||
|
int seq,
|
||||||
|
) {
|
||||||
|
if (seq < _sequence) {
|
||||||
|
// A slower earlier request finishing after a newer one. Dropping it is
|
||||||
|
// the whole reason the sequence exists.
|
||||||
|
debugPrint('[WORK] dropped stale response #$seq (newest is $_sequence)');
|
||||||
|
return _state;
|
||||||
|
}
|
||||||
|
_state = next;
|
||||||
|
if (!_controller.isClosed) _controller.add(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test seam. Returns the repository to the state a fresh launch has.
|
||||||
|
@visibleForTesting
|
||||||
|
void resetForTest() {
|
||||||
|
_state = const LoadLoading<List<Map<String, dynamic>>>();
|
||||||
|
_inFlight = null;
|
||||||
|
_sequence = 0;
|
||||||
|
_loadedAt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
202
lib/data/work_scope.dart
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'package:miler/data/api_config.dart';
|
||||||
|
import 'package:miler/data/service_profile.dart';
|
||||||
|
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// WHO A RECORD BELONGS TO
|
||||||
|
///
|
||||||
|
/// Miler runs two operations off one app and one login — a milk-man round and
|
||||||
|
/// a logistics day — and it stores finished work, skipped work, carried bags
|
||||||
|
/// and released orders in SharedPreferences. Every one of those keys was
|
||||||
|
/// **global**: `completed_bookings`, `skipped_bookings`,
|
||||||
|
/// `collected_order_ids`, `out_for_delivery_order_ids`. One phone, one key,
|
||||||
|
/// whoever wrote last.
|
||||||
|
///
|
||||||
|
/// That is three leaks in one:
|
||||||
|
///
|
||||||
|
/// • **Rider → rider.** Log out, log in as somebody else, and yesterday's
|
||||||
|
/// completed stops are sitting in the new rider's Activity.
|
||||||
|
/// • **Line → line.** A tenant switch moves the app from a round to a
|
||||||
|
/// logistics day; the milk-run's delivered lunches stayed behind in the
|
||||||
|
/// logistics history.
|
||||||
|
/// • **Tenant → tenant.** Same shape, one level up.
|
||||||
|
///
|
||||||
|
/// ── Why identity and not a label ──
|
||||||
|
///
|
||||||
|
/// The tempting fix is to filter Activity on something visible — a kitchen
|
||||||
|
/// name, the word "Milk", the tab's title. All of those are *display strings*:
|
||||||
|
/// they are localisable, they are chosen by hub staff, and two tenants can
|
||||||
|
/// legitimately use the same one. Ownership has to come from identity the
|
||||||
|
/// session actually proves:
|
||||||
|
///
|
||||||
|
/// **rider** `userid` — from the login response, held in prefs
|
||||||
|
/// **tenant** `tenantid` — from the JWT claim, signed by the server
|
||||||
|
/// **line** [ServiceLine] — the operation the profile resolves to
|
||||||
|
///
|
||||||
|
/// A [WorkScope] is those three together, and it is the only thing allowed to
|
||||||
|
/// decide which records a screen may see.
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
@immutable
|
||||||
|
class WorkScope {
|
||||||
|
final int userId;
|
||||||
|
final int tenantId;
|
||||||
|
final ServiceLine line;
|
||||||
|
|
||||||
|
const WorkScope({
|
||||||
|
required this.userId,
|
||||||
|
required this.tenantId,
|
||||||
|
required this.line,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The scope of the session running right now.
|
||||||
|
///
|
||||||
|
/// Reads the rider from prefs and the tenant from the token's claim — the
|
||||||
|
/// same two sources the rest of the app authenticates with — and takes the
|
||||||
|
/// line from the resolved profile. Never throws: an unreadable session
|
||||||
|
/// yields the [anonymous] scope, whose records are visible to nobody but
|
||||||
|
/// itself.
|
||||||
|
static Future<WorkScope> current() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final raw = prefs.get('userid');
|
||||||
|
final userId = raw is int
|
||||||
|
? raw
|
||||||
|
: int.tryParse(raw?.toString() ?? '') ?? 0;
|
||||||
|
final tenantId = await ApiConfig.storedTenantId();
|
||||||
|
return WorkScope(
|
||||||
|
userId: userId,
|
||||||
|
tenantId: tenantId,
|
||||||
|
line: ServiceProfile.active.line,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[SCOPE] could not resolve the session scope: $e');
|
||||||
|
return WorkScope(
|
||||||
|
userId: 0,
|
||||||
|
tenantId: 0,
|
||||||
|
line: ServiceProfile.active.line,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A signed-out or unreadable session. Deliberately still a real scope
|
||||||
|
/// rather than null: code paths that run before login write to their own
|
||||||
|
/// drawer instead of into the last rider's.
|
||||||
|
static WorkScope get anonymous =>
|
||||||
|
WorkScope(userId: 0, tenantId: 0, line: ServiceProfile.active.line);
|
||||||
|
|
||||||
|
/// The suffix that turns a store key into this scope's key.
|
||||||
|
///
|
||||||
|
/// `completed_bookings` → `completed_bookings::u38.t13.milkMan`
|
||||||
|
///
|
||||||
|
/// All three parts are in it because all three can change independently: a
|
||||||
|
/// rider can move tenant, a tenant can run either line, and one device can
|
||||||
|
/// see several riders.
|
||||||
|
String get key => 'u$userId.t$tenantId.${line.name}';
|
||||||
|
|
||||||
|
/// Scopes a legacy global key.
|
||||||
|
String scoped(String baseKey) => '$baseKey::$key';
|
||||||
|
|
||||||
|
/// Does [record] provably belong to this scope?
|
||||||
|
///
|
||||||
|
/// Used on rows that were written before scoping existed, and as a
|
||||||
|
/// belt-and-braces check on rows read back from a scoped key. A row proves
|
||||||
|
/// ownership by carrying the identity itself — `mileruserid` / `userid` and
|
||||||
|
/// `tenantid` are what the API stamps on assignment and consignment rows.
|
||||||
|
///
|
||||||
|
/// **Absence is not proof.** A row with no identity on it returns false: it
|
||||||
|
/// might be this rider's and it might be the last one's, and the only safe
|
||||||
|
/// reading of "might" is no.
|
||||||
|
bool owns(Map<String, dynamic> record) {
|
||||||
|
int intOf(List<String> keys) {
|
||||||
|
for (final k in keys) {
|
||||||
|
final v = record[k];
|
||||||
|
if (v == null) continue;
|
||||||
|
final n = v is int ? v : int.tryParse(v.toString());
|
||||||
|
if (n != null && n != 0) return n;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
final rowUser = intOf(const [
|
||||||
|
'mileruserid',
|
||||||
|
'MilerUserId',
|
||||||
|
'assignedmileruserid',
|
||||||
|
'userid',
|
||||||
|
'scopeuserid',
|
||||||
|
]);
|
||||||
|
final rowTenant = intOf(const ['tenantid', 'TenantId', 'scopetenantid']);
|
||||||
|
final rowLine = (record['scopeline'] ?? '').toString();
|
||||||
|
|
||||||
|
if (rowUser == 0 && rowTenant == 0 && rowLine.isEmpty) return false;
|
||||||
|
if (rowUser != 0 && rowUser != userId) return false;
|
||||||
|
if (rowTenant != 0 && rowTenant != tenantId) return false;
|
||||||
|
if (rowLine.isNotEmpty && rowLine != line.name) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does [record] prove it belongs to **another** scope?
|
||||||
|
///
|
||||||
|
/// The mirror of [owns], and deliberately not its negation — they answer
|
||||||
|
/// different questions and treat silence differently:
|
||||||
|
///
|
||||||
|
/// [owns] "prove this is mine" — no identity ⇒ **false** (drop).
|
||||||
|
/// Used on legacy rows, where attributing the unattributable
|
||||||
|
/// is the leak itself.
|
||||||
|
/// [excludes] "prove this is someone
|
||||||
|
/// else's" — no identity ⇒ **false** (keep).
|
||||||
|
/// Used on rows the API just returned for the authenticated
|
||||||
|
/// session, which are already the rider's by construction and
|
||||||
|
/// mostly carry no identity of their own. Dropping those on
|
||||||
|
/// silence would empty the tab.
|
||||||
|
bool excludes(Map<String, dynamic> record) {
|
||||||
|
int intOf(List<String> keys) {
|
||||||
|
for (final k in keys) {
|
||||||
|
final v = record[k];
|
||||||
|
if (v == null) continue;
|
||||||
|
final n = v is int ? v : int.tryParse(v.toString());
|
||||||
|
if (n != null && n != 0) return n;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
final rowUser = intOf(const [
|
||||||
|
'mileruserid',
|
||||||
|
'MilerUserId',
|
||||||
|
'assignedmileruserid',
|
||||||
|
'scopeuserid',
|
||||||
|
]);
|
||||||
|
final rowTenant = intOf(const ['tenantid', 'TenantId', 'scopetenantid']);
|
||||||
|
final rowLine = (record['scopeline'] ?? '').toString();
|
||||||
|
|
||||||
|
if (rowUser != 0 && userId != 0 && rowUser != userId) return true;
|
||||||
|
if (rowTenant != 0 && tenantId != 0 && rowTenant != tenantId) return true;
|
||||||
|
if (rowLine.isNotEmpty && rowLine != line.name) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stamps [record] so a later read can prove ownership without a session.
|
||||||
|
///
|
||||||
|
/// Written at the moment a record is stored, which is the one moment the
|
||||||
|
/// scope is known for certain.
|
||||||
|
Map<String, dynamic> stamp(Map<String, dynamic> record) => {
|
||||||
|
...record,
|
||||||
|
'scopeuserid': userId,
|
||||||
|
'scopetenantid': tenantId,
|
||||||
|
'scopeline': line.name,
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is WorkScope &&
|
||||||
|
other.userId == userId &&
|
||||||
|
other.tenantId == tenantId &&
|
||||||
|
other.line == line;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(userId, tenantId, line);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'WorkScope($key)';
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import 'package:miler/controllers/auth.dart';
|
|||||||
import 'package:miler/views/introscreens/introscreen.dart';
|
import 'package:miler/views/introscreens/introscreen.dart';
|
||||||
import 'package:miler/views/onboardscreens/Mpin.dart';
|
import 'package:miler/views/onboardscreens/Mpin.dart';
|
||||||
import 'package:miler/views/onboardscreens/Sign_in.dart';
|
import 'package:miler/views/onboardscreens/Sign_in.dart';
|
||||||
import 'package:miler/widget/Bottom_page.dart';
|
import 'package:miler/helpers/rider_shell.dart';
|
||||||
|
|
||||||
/// Decides which screen the app opens on, and nothing else.
|
/// Decides which screen the app opens on, and nothing else.
|
||||||
///
|
///
|
||||||
@@ -109,7 +109,11 @@ class _AppBootstrapState extends State<AppBootstrap> {
|
|||||||
// pass a slider that put him back on the clock. Duty is a switch in Home's
|
// pass a slider that put him back on the clock. Duty is a switch in Home's
|
||||||
// app bar, on the screen where the work is.
|
// app bar, on the screen where the work is.
|
||||||
if (!isLoggedOut && savedUserId != null && savedUserId > 0) {
|
if (!isLoggedOut && savedUserId != null && savedUserId > 0) {
|
||||||
Get.offAll(() => const BottomPage());
|
// Which of the two dashboards this rider works in — parcel or the ported
|
||||||
|
// delivery flow — decided by his tenant. `main()` awaits
|
||||||
|
// `TenantController.load()` before the first frame, so the answer is
|
||||||
|
// already on hand here. See [riderShell].
|
||||||
|
Get.offAll(() => riderShell());
|
||||||
} else {
|
} else {
|
||||||
Get.offAll(() => hasSeenIntro ? const SignIn() : Introscreen());
|
Get.offAll(() => hasSeenIntro ? const SignIn() : Introscreen());
|
||||||
}
|
}
|
||||||
|
|||||||
88
lib/helpers/rider_shell.dart
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:miler/data/service_profile.dart';
|
||||||
|
import 'package:miler/widget/Bottom_page.dart' as parcel;
|
||||||
|
import 'package:miler/xpress/delivery_bootstrap.dart';
|
||||||
|
import 'package:miler/xpress/widget/Bottom_page.dart' as delivery;
|
||||||
|
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// WHICH APP THIS RIDER GETS
|
||||||
|
///
|
||||||
|
/// One APK, two applications. A rider signs in and lands in whichever of them
|
||||||
|
/// his tenant says he works for:
|
||||||
|
///
|
||||||
|
/// • **parcel / meals** → the Miler dashboard: floating frosted tab bar, Home ·
|
||||||
|
/// Bookings · Activity · Account, the pickup flow with its stop verification
|
||||||
|
/// and cash collection.
|
||||||
|
///
|
||||||
|
/// • **delivery** → the ported Xpress-rider dashboard under `lib/xpress`:
|
||||||
|
/// classic bottom bar, HOME · DELIVERIES · SUMMARY · PROFILE, the last-mile
|
||||||
|
/// delivery flow, in Doormile red.
|
||||||
|
///
|
||||||
|
/// ── Why the switch is here and not inside the shell ──
|
||||||
|
///
|
||||||
|
/// The obvious alternative is one `BottomPage` that picks its own tabs. It does
|
||||||
|
/// not work: the two shells disagree about how many tabs there are, what a tab
|
||||||
|
/// is called, how the bar is drawn, and — the part that actually decides it —
|
||||||
|
/// which page widgets exist. The delivery pages are a verbatim port that reads
|
||||||
|
/// its own controllers, its own models and its own colour class. Fusing the two
|
||||||
|
/// shells would mean editing the ported flow, which is the one thing the port
|
||||||
|
/// was meant to avoid.
|
||||||
|
///
|
||||||
|
/// So the shells stay whole and separate, and exactly one function chooses
|
||||||
|
/// between them.
|
||||||
|
///
|
||||||
|
/// ── Why it is a function and not a route ──
|
||||||
|
///
|
||||||
|
/// Every caller already says `Get.offAll(() => const BottomPage())`. Making this
|
||||||
|
/// a widget-returning function means those sites become
|
||||||
|
/// `Get.offAll(() => riderShell())` and nothing else about routing changes —
|
||||||
|
/// no named routes, no redirect middleware, no second navigator.
|
||||||
|
///
|
||||||
|
/// ── When it is safe to call ──
|
||||||
|
///
|
||||||
|
/// After `TenantController.load()` has run, which `main()` awaits before the
|
||||||
|
/// first frame. [ServiceProfile.active] is a plain synchronous read, so this
|
||||||
|
/// needs no context, no await and no rebuild.
|
||||||
|
///
|
||||||
|
/// ── The fallback ──
|
||||||
|
///
|
||||||
|
/// Anything that is not explicitly the delivery line gets the parcel shell,
|
||||||
|
/// matching the direction [resolveServiceProfile] already falls in. The worst
|
||||||
|
/// case that way is a delivery rider seeing the parcel dashboard, which is
|
||||||
|
/// wrong but navigable; the other direction would drop a live parcel rider into
|
||||||
|
/// an app with no pickup flow at all.
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
Widget riderShell({int initialIndex = 0, Widget? overridePage}) {
|
||||||
|
if (ServiceProfile.active.isDelivery) {
|
||||||
|
// ── Why this is here and not only in `main()` ──
|
||||||
|
//
|
||||||
|
// `main()` boots the delivery line's controllers when the app *starts* on a
|
||||||
|
// delivery tenant. But a rider can also arrive here without a restart: sign
|
||||||
|
// out of a parcel account, sign in to a delivery one, and `Mpin` routes
|
||||||
|
// straight to this function inside a process whose container only ever held
|
||||||
|
// the parcel controllers.
|
||||||
|
//
|
||||||
|
// The delivery home page resolves `DeliveryController` in a field
|
||||||
|
// initialiser, so that path would throw while building rather than degrade.
|
||||||
|
// [registerDeliveryLine] is `isRegistered`-guarded, so calling it on every
|
||||||
|
// shell build costs four map lookups and closes the hole.
|
||||||
|
//
|
||||||
|
// Deliberately the **synchronous** half of the bootstrap, not the `async`
|
||||||
|
// one. An un-awaited async registration returns at its first `await`, the
|
||||||
|
// framework builds this shell in the gap, and the page throws on a
|
||||||
|
// controller that is about to exist — see the note on [registerDeliveryLine].
|
||||||
|
// The IO half is `main()`'s to await; nothing on screen needs it to have
|
||||||
|
// finished, because the screens re-read that state anyway.
|
||||||
|
registerDeliveryLine();
|
||||||
|
|
||||||
|
return delivery.BottomPage(
|
||||||
|
initialIndex: initialIndex,
|
||||||
|
overridePage: overridePage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parcel.BottomPage(
|
||||||
|
initialIndex: initialIndex,
|
||||||
|
overridePage: overridePage,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async' show unawaited;
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:miler/helpers/http_overrides.dart';
|
import 'package:miler/helpers/http_overrides.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -20,6 +21,9 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'package:miler/background/backgroundservice.dart';
|
import 'package:miler/background/backgroundservice.dart';
|
||||||
import 'package:miler/helpers/shift_end_alarm.dart';
|
import 'package:miler/helpers/shift_end_alarm.dart';
|
||||||
|
import 'package:miler/data/accepted_store.dart';
|
||||||
|
import 'package:miler/data/service_profile.dart';
|
||||||
|
import 'package:miler/xpress/delivery_bootstrap.dart';
|
||||||
import 'package:miler/views/helpers/constants/app_theme.dart';
|
import 'package:miler/views/helpers/constants/app_theme.dart';
|
||||||
import 'package:miler/views/helpers/widgets/page_transitions.dart';
|
import 'package:miler/views/helpers/widgets/page_transitions.dart';
|
||||||
|
|
||||||
@@ -73,6 +77,9 @@ Future<bool> recheckVersion() async {
|
|||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
// One-time drain of the pre-scoping global stores. See [migrateLegacyStores]
|
||||||
|
// for why unattributable rows are dropped rather than adopted.
|
||||||
|
unawaited(migrateLegacyStores());
|
||||||
HttpOverrides.global = MyHttpOverrides();
|
HttpOverrides.global = MyHttpOverrides();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -83,10 +90,39 @@ Future<void> main() async {
|
|||||||
// Firebase may not be configured for this platform (e.g. iOS simulator without GoogleService-Info.plist)
|
// Firebase may not be configured for this platform (e.g. iOS simulator without GoogleService-Info.plist)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Which business this rider works for, resolved once from the login response
|
||||||
|
// — see [TenantController]. Before the first frame, because it decides
|
||||||
|
// whether Home draws a payment-carrying parcel route or a meal run — and,
|
||||||
|
// now, whether the rider gets the parcel dashboard at all or the ported
|
||||||
|
// Xpress-rider delivery one under `lib/xpress`.
|
||||||
|
//
|
||||||
|
// Moved above the controller registrations below, which it now selects.
|
||||||
|
final profile = await TenantController.to.load();
|
||||||
|
|
||||||
|
// ── One line's controllers, not both ──
|
||||||
|
//
|
||||||
|
// The delivery line has its own `RiderLogController`, `LogController` and
|
||||||
|
// `ProfileController` — same names, different classes, and GetX keys on the
|
||||||
|
// type. Registering both sets would put one rider on two duty-logging loops
|
||||||
|
// writing to two different backends. See [bootstrapDeliveryLine].
|
||||||
|
if (profile.isDelivery) {
|
||||||
|
await bootstrapDeliveryLine();
|
||||||
|
_applySystemChrome();
|
||||||
|
runApp(const _RootApp());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Loaded at boot, not on first visit to Account: the Home app bar draws the
|
// Loaded at boot, not on first visit to Account: the Home app bar draws the
|
||||||
// rider's photo, so the controller has to be populated before the first
|
// rider's photo, so the controller has to be populated before the first
|
||||||
// frame or the avatar flashes its letter fallback on every launch.
|
// frame or the avatar flashes its letter fallback on every launch.
|
||||||
await Get.put(ProfileController(), permanent: true).loadFromPrefs();
|
await Get.put(ProfileController(), permanent: true).loadFromPrefs();
|
||||||
|
|
||||||
|
// Anything the retired demo layer left on this device. Deleting the mock
|
||||||
|
// generator from the source could not reach rows it had already written to
|
||||||
|
// SharedPreferences, and those kept surfacing on Bookings and Activity with
|
||||||
|
// nothing in the repo to explain them. See [purgeDemoRecords].
|
||||||
|
await purgeDemoRecords();
|
||||||
|
|
||||||
Get.put(RiderLogController(), permanent: true);
|
Get.put(RiderLogController(), permanent: true);
|
||||||
Get.put(PickupController(), permanent: true);
|
Get.put(PickupController(), permanent: true);
|
||||||
final logController = Get.put(LogController(), permanent: true);
|
final logController = Get.put(LogController(), permanent: true);
|
||||||
|
|||||||
@@ -62,11 +62,7 @@ Future<Map<String, dynamic>?> _startDuty(Map data) async {
|
|||||||
return {
|
return {
|
||||||
'status': true,
|
'status': true,
|
||||||
'code': 200,
|
'code': 200,
|
||||||
'details': {
|
'details': {'logid': id, 'login': res.map['loginat'], 'onduty': 1},
|
||||||
'logid': id,
|
|
||||||
'login': res.map['loginat'],
|
|
||||||
'onduty': 1,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// "Already on duty" is a 400 and is not a failure — it is the app and the
|
// "Already on duty" is a 400 and is not a failure — it is the app and the
|
||||||
@@ -133,7 +129,9 @@ Future<Map<String, dynamic>?> _heartbeat(Map data) async {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.ok ? ApiConfig.okEnvelope() : {'status': false, 'code': res.status};
|
return res.ok
|
||||||
|
? ApiConfig.okEnvelope()
|
||||||
|
: {'status': false, 'code': res.status};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when the payload means "go off duty".
|
/// True when the payload means "go off duty".
|
||||||
|
|||||||
@@ -3,10 +3,87 @@ import 'package:http/http.dart' as http;
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:miler/Models/login/login.dart';
|
import 'package:miler/Models/login/login.dart';
|
||||||
import 'package:miler/data/api_config.dart';
|
import 'package:miler/data/api_config.dart';
|
||||||
|
import 'package:miler/data/mock_backend.dart';
|
||||||
import 'package:miler/data/miler_api.dart';
|
import 'package:miler/data/miler_api.dart';
|
||||||
|
import 'package:miler/data/service_profile.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
class AuthProvider {
|
class AuthProvider {
|
||||||
|
/// Null rather than 0 for anything unparseable, so a missing tenant falls
|
||||||
|
/// through to the build's value instead of masquerading as tenant zero.
|
||||||
|
static int? _toInt(dynamic v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v is int) return v;
|
||||||
|
if (v is num) return v.toInt();
|
||||||
|
return int.tryParse(v.toString().trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Null for zero, so an absent tenant falls through the `??` chain instead of
|
||||||
|
/// stopping it as tenant zero — which is not a tenant, but is truthy enough
|
||||||
|
/// to look like one.
|
||||||
|
static int? _nonZero(int v) => v == 0 ? null : v;
|
||||||
|
|
||||||
|
/// What to tell the rider when `verify-pin` did not succeed.
|
||||||
|
///
|
||||||
|
/// The server's own sentence when it sent one — `incorrect PIN` is precise
|
||||||
|
/// and actionable. Otherwise the status code and a short slice of the body,
|
||||||
|
/// because "something between this phone and the server said no" is a real
|
||||||
|
/// answer and "your PIN is wrong" is a false one.
|
||||||
|
static String _failureText(http.Response res, Map<String, dynamic> decoded) {
|
||||||
|
final said = (decoded['message'] ?? decoded['error'] ?? '')
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
if (said.isNotEmpty) return said;
|
||||||
|
|
||||||
|
final body = res.body.trim();
|
||||||
|
final preview = body.length > 120 ? '${body.substring(0, 120)}…' : body;
|
||||||
|
if (preview.isEmpty) {
|
||||||
|
return 'The server returned HTTP ${res.statusCode} with no message.';
|
||||||
|
}
|
||||||
|
return 'The server returned HTTP ${res.statusCode}: $preview';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every spelling and nesting this contract has used for the bearer token.
|
||||||
|
///
|
||||||
|
/// Order is deliberate: the envelope first, because that is where the handler
|
||||||
|
/// puts it today, then the two payload maps for deployments that nest it.
|
||||||
|
static const List<String> _tokenKeys = [
|
||||||
|
'token',
|
||||||
|
'access_token',
|
||||||
|
'accessToken',
|
||||||
|
'authtoken',
|
||||||
|
'authToken',
|
||||||
|
'jwt',
|
||||||
|
'idToken',
|
||||||
|
'id_token',
|
||||||
|
'bearer',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// First non-empty token found across the response's envelope, its `data`
|
||||||
|
/// child, and the two payload maps — or `''`. Never throws on a shape it does
|
||||||
|
/// not recognise.
|
||||||
|
@visibleForTesting
|
||||||
|
static String tokenFromResponse(Map? envelope, Map? user, Map? profile) =>
|
||||||
|
_tokenIn(envelope, user, profile);
|
||||||
|
|
||||||
|
static String _tokenIn(Map? envelope, Map? user, Map? profile) {
|
||||||
|
final candidates = <Map>[
|
||||||
|
if (envelope != null) envelope,
|
||||||
|
if (envelope != null && envelope['data'] is Map) envelope['data'] as Map,
|
||||||
|
if (user != null) user,
|
||||||
|
if (profile != null) profile,
|
||||||
|
];
|
||||||
|
for (final map in candidates) {
|
||||||
|
for (final key in _tokenKeys) {
|
||||||
|
final v = map[key];
|
||||||
|
if (v == null) continue;
|
||||||
|
final s = v.toString().trim();
|
||||||
|
if (s.isNotEmpty && s.toLowerCase() != 'null') return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
Future<http.Response> login({
|
Future<http.Response> login({
|
||||||
required String contactNo,
|
required String contactNo,
|
||||||
required String deviceType,
|
required String deviceType,
|
||||||
@@ -60,12 +137,45 @@ class AuthProvider {
|
|||||||
// and sending it explicitly is the only way the app and the console agree
|
// and sending it explicitly is the only way the app and the console agree
|
||||||
// about which partition a rider belongs to.
|
// about which partition a rider belongs to.
|
||||||
'configid': MilerApi.configId,
|
'configid': MilerApi.configId,
|
||||||
|
// Which operating company this build signs riders in for. Omitted
|
||||||
|
// entirely when the build declares none — see [MilerApi.tenantId].
|
||||||
|
if (MilerApi.hasTenantId) 'tenantid': MilerApi.tenantId,
|
||||||
if (fcmToken != null && fcmToken.isNotEmpty) 'device_token': fcmToken,
|
if (fcmToken != null && fcmToken.isNotEmpty) 'device_token': fcmToken,
|
||||||
};
|
};
|
||||||
debugPrint('[AUTH][LOGIN][NEW] URL: $uri');
|
debugPrint('[AUTH][LOGIN][NEW] URL: $uri');
|
||||||
debugPrint('[AUTH][LOGIN][NEW] Body: ${json.encode(body)}');
|
debugPrint('[AUTH][LOGIN][NEW] Body: ${json.encode(body)}');
|
||||||
|
|
||||||
final res = await http.post(
|
// ── The mock is a *response*, not a shortcut ──
|
||||||
|
//
|
||||||
|
// This used to `return` the mocked payload directly, and everything that
|
||||||
|
// makes a payload usable happens BELOW: the bearer token is stored there,
|
||||||
|
// and the response is mapped into the `{status, details:{…}}` envelope that
|
||||||
|
// `Login.fromJson` reads. Returning early skipped both.
|
||||||
|
//
|
||||||
|
// So on the mock path `status` was never set — and an absent `status`
|
||||||
|
// parses as **false** — and no token was ever stored. The sign-in saw a
|
||||||
|
// rejected login with no HTTP status behind it and told the rider
|
||||||
|
// "Login failed", for every phone number and every MPIN. `MOCK_BACKEND`
|
||||||
|
// defaults to on in debug, which is how the app is run, so that was the
|
||||||
|
// state of sign-in for anyone not building release.
|
||||||
|
//
|
||||||
|
// The whole point of rule 2 in [MockBackend] is that it intercepts the
|
||||||
|
// *transport* and nothing downstream can tell the difference. An early
|
||||||
|
// return is not an intercepted transport; it is a second implementation of
|
||||||
|
// this function that nobody was testing. One `res`, one path.
|
||||||
|
//
|
||||||
|
// Signing in with nothing to sign in to. This is the one call that cannot
|
||||||
|
// be intercepted in [MilerApi] — auth posts its own request, because it
|
||||||
|
// runs before there is a token for the shared transport to attach.
|
||||||
|
final mocked = MockBackend.respond('POST', '/miler/verify-pin', body: body);
|
||||||
|
|
||||||
|
final http.Response res = mocked != null
|
||||||
|
? http.Response(
|
||||||
|
json.encode(mocked),
|
||||||
|
200,
|
||||||
|
headers: const {'content-type': 'application/json'},
|
||||||
|
)
|
||||||
|
: await http.post(
|
||||||
uri,
|
uri,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -98,12 +208,37 @@ class AuthProvider {
|
|||||||
final Map profile = (user['profile'] is Map)
|
final Map profile = (user['profile'] is Map)
|
||||||
? user['profile'] as Map
|
? user['profile'] as Map
|
||||||
: <String, dynamic>{};
|
: <String, dynamic>{};
|
||||||
// Prefer profile field, then top-level user field.
|
// Prefer profile field, then top-level user field, then the envelope.
|
||||||
dynamic pick(String k) => profile[k] ?? user[k];
|
//
|
||||||
|
// The envelope is in the chain for the tenant pair: `verify-pin` returns
|
||||||
|
// `tenantid`/`tenantname` on `user`, but a handler that later moves them
|
||||||
|
// to the top level should not silently drop the rider back to the default
|
||||||
|
// line. Three places to look costs nothing and removes a whole class of
|
||||||
|
// "why is this rider on the wrong flow" report.
|
||||||
|
dynamic pick(String k) => profile[k] ?? user[k] ?? decoded[k];
|
||||||
|
|
||||||
// Persist bearer token for all subsequent authenticated calls.
|
// ── Find the bearer token wherever this deployment puts it ──
|
||||||
final String token = (decoded['token'] ?? '').toString();
|
//
|
||||||
if (token.isNotEmpty) await ApiConfig.setToken(token);
|
// This read `decoded['token']` and nothing else, and the sign-in then used
|
||||||
|
// "is there a token in prefs?" as its test for *whether the PIN was right*.
|
||||||
|
// So a backend that nests the token one level down — `data.token`,
|
||||||
|
// `user.token`, `access_token`, any of the spellings this contract has
|
||||||
|
// worn — produced a verify-pin that **succeeded** and a sign-in that
|
||||||
|
// reported **"Login failed"**. For every rider, on every attempt, with the
|
||||||
|
// server agreeing the PIN was correct.
|
||||||
|
//
|
||||||
|
// Looking in nine places costs nothing and removes the whole class of
|
||||||
|
// failure. [_tokenIn] returns '' when there is genuinely no token, which is
|
||||||
|
// a different fact from a rejected PIN and is now reported as one.
|
||||||
|
final String token = _tokenIn(decoded, user, profile);
|
||||||
|
if (token.isNotEmpty) {
|
||||||
|
await ApiConfig.setToken(token);
|
||||||
|
} else if (ok) {
|
||||||
|
debugPrint(
|
||||||
|
'[AUTH][LOGIN][NEW] server said success but carried no token; '
|
||||||
|
'top-level keys: ${decoded.keys.toList()}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// displayname -> first/last name split (best effort).
|
// displayname -> first/last name split (best effort).
|
||||||
final String displayName = (pick('displayname') ?? user['authname'] ?? '')
|
final String displayName = (pick('displayname') ?? user['authname'] ?? '')
|
||||||
@@ -129,8 +264,24 @@ class AuthProvider {
|
|||||||
// Map the new payload into the legacy `details` shape loginParsed reads.
|
// Map the new payload into the legacy `details` shape loginParsed reads.
|
||||||
final legacy = <String, dynamic>{
|
final legacy = <String, dynamic>{
|
||||||
'status': ok,
|
'status': ok,
|
||||||
'code': ok ? 200 : (decoded['code'] ?? 400),
|
// Whether THIS call produced a session, as distinct from whether the
|
||||||
'message': decoded['message']?.toString() ?? '',
|
// credentials were accepted. The sign-in needs to tell those apart to
|
||||||
|
// say anything useful.
|
||||||
|
'tokenstored': token.isNotEmpty,
|
||||||
|
// ── The transport failure used to vanish here ──
|
||||||
|
//
|
||||||
|
// This was `decoded['code'] ?? 400`. A proxy 502, a captive portal's
|
||||||
|
// redirect page, a gateway timeout — anything whose body is not the JSON
|
||||||
|
// this handler speaks — decoded to `{}`, so the code became a hard-coded
|
||||||
|
// 400 and the message became `''`. The sign-in then reported all of it
|
||||||
|
// as **"Login failed"**, which the rider reads as "my PIN is wrong".
|
||||||
|
//
|
||||||
|
// The real status now survives, and so does a slice of whatever came
|
||||||
|
// back, so a failing handset can say what it is actually being told
|
||||||
|
// instead of only that it is unhappy.
|
||||||
|
'code': ok ? 200 : res.statusCode,
|
||||||
|
'httpstatus': res.statusCode,
|
||||||
|
'message': ok ? '' : _failureText(res, decoded),
|
||||||
'details': <String, dynamic>{
|
'details': <String, dynamic>{
|
||||||
'userid': userId,
|
'userid': userId,
|
||||||
'riderid': userId,
|
'riderid': userId,
|
||||||
@@ -152,7 +303,29 @@ class AuthProvider {
|
|||||||
'logid': 0,
|
'logid': 0,
|
||||||
'partnerid': 0,
|
'partnerid': 0,
|
||||||
'configid': 0,
|
'configid': 0,
|
||||||
'tenantid': 0,
|
// ── Which line of work this rider is on ──
|
||||||
|
//
|
||||||
|
// The server's answer first, the build's declared tenant only when
|
||||||
|
// there is no answer to have. `verify-pin` returns both halves now, so
|
||||||
|
// this is a live path rather than a seam: a rostered rider's account
|
||||||
|
// decides his flow, and a build flag can no longer override it.
|
||||||
|
//
|
||||||
|
// This is the switch that decides whether the rider gets the Logistics
|
||||||
|
// flow or the Milk Man flow — see [ServiceProfile]. "Which work am I
|
||||||
|
// doing today?" must be answered by the hub that rostered him, not by
|
||||||
|
// whoever compiled the APK.
|
||||||
|
// The token's claim sits between the body and the build flag: it is the
|
||||||
|
// same fact from the same source, signed by the server, and it is
|
||||||
|
// present on every deployment — including ones whose `verify-pin` does
|
||||||
|
// not put the tenant in the body. That was the live case: a login
|
||||||
|
// carrying tenant 13 in its token, a body with no tenant at all, and
|
||||||
|
// every rider falling through to the default line.
|
||||||
|
'tenantid':
|
||||||
|
_toInt(pick('tenantid')) ??
|
||||||
|
_nonZero(ApiConfig.tenantIdFromToken(token)) ??
|
||||||
|
MilerApi.tenantId,
|
||||||
|
'tenantname': (pick('tenantname') ?? pick('tenantcode') ?? '')
|
||||||
|
.toString(),
|
||||||
'pickupradius': 100,
|
'pickupradius': 100,
|
||||||
'starttime': '',
|
'starttime': '',
|
||||||
'endtime': '',
|
'endtime': '',
|
||||||
@@ -177,10 +350,30 @@ class AuthProvider {
|
|||||||
/// they were given). 404 means the number isn't registered.
|
/// they were given). 404 means the number isn't registered.
|
||||||
///
|
///
|
||||||
/// Returns true only for an existing, active miler account.
|
/// Returns true only for an existing, active miler account.
|
||||||
Future<bool> milerAccountExists(String contactNo) async {
|
/// Whether [contactNo] is an active miler account: `true`, `false`, or
|
||||||
|
/// **null when the question could not be asked**.
|
||||||
|
///
|
||||||
|
/// ── "No" and "I don't know" are not the same answer ──
|
||||||
|
///
|
||||||
|
/// This returned a plain `bool` and answered `false` for a timeout, a DNS
|
||||||
|
/// failure, a 502, and a body it could not parse. `precheckPhone` reads a
|
||||||
|
/// `false` as *this number has no account* and routes the rider into the
|
||||||
|
/// OTP → Create-MPIN flow — which cannot set a PIN (see [updatePin]) but
|
||||||
|
/// tells him it did. So one flaky request took a rider with a perfectly good
|
||||||
|
/// account and walked him into a dead end that locks him out and looks like
|
||||||
|
/// his fault.
|
||||||
|
///
|
||||||
|
/// Null now means unknown, and the caller sends unknown to the MPIN screen —
|
||||||
|
/// the destination that is right for every rider who already has an account,
|
||||||
|
/// and the one screen that can report what actually went wrong.
|
||||||
|
Future<bool?> milerAccountExists(String contactNo) async {
|
||||||
try {
|
try {
|
||||||
final uri = Uri.parse(ApiConfig.url('/miler/login'));
|
final uri = Uri.parse(ApiConfig.url('/miler/login'));
|
||||||
|
// Every phone number has an account when there is no directory to ask.
|
||||||
|
if (MockBackend.enabled) {
|
||||||
|
debugPrint('[MOCK] POST /miler/login -> account exists');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
final res = await http
|
final res = await http
|
||||||
.post(
|
.post(
|
||||||
uri,
|
uri,
|
||||||
@@ -191,18 +384,26 @@ class AuthProvider {
|
|||||||
body: json.encode({
|
body: json.encode({
|
||||||
'phone': contactNo,
|
'phone': contactNo,
|
||||||
'configid': MilerApi.configId,
|
'configid': MilerApi.configId,
|
||||||
|
if (MilerApi.hasTenantId) 'tenantid': MilerApi.tenantId,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.timeout(const Duration(seconds: 15));
|
.timeout(const Duration(seconds: 15));
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[AUTH][PRECHECK] $contactNo -> ${res.statusCode} ${res.body}',
|
'[AUTH][PRECHECK] $contactNo -> ${res.statusCode} ${res.body}',
|
||||||
);
|
);
|
||||||
if (res.statusCode < 200 || res.statusCode >= 300) return false;
|
// 404 is a real "no such account"; 5xx and anything else is the server
|
||||||
|
// failing to answer, which is not evidence about the rider.
|
||||||
|
if (res.statusCode == 404) return false;
|
||||||
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
if (res.statusCode == 401 || res.statusCode == 403) return false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
final decoded = json.decode(res.body);
|
final decoded = json.decode(res.body);
|
||||||
return decoded is Map && decoded['success'] == true;
|
if (decoded is! Map) return null;
|
||||||
|
return decoded['success'] == true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[AUTH][PRECHECK] error: $e');
|
debugPrint('[AUTH][PRECHECK] could not reach the directory: $e');
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +474,17 @@ class AuthProvider {
|
|||||||
: <String, dynamic>{};
|
: <String, dynamic>{};
|
||||||
debugPrint('[AUTH] Raw Login JSON: $jsonMap');
|
debugPrint('[AUTH] Raw Login JSON: $jsonMap');
|
||||||
|
|
||||||
if (jsonMap.containsKey('details')) {
|
// ── Only a successful login may rewrite who the rider is ──
|
||||||
|
//
|
||||||
|
// This ran on `containsKey('details')` alone, and the failure envelope
|
||||||
|
// carries a `details` map too — full of the zeros and empty strings used as
|
||||||
|
// safe defaults. So a rejected PIN, a 502, or a `refreshSession` that fired
|
||||||
|
// without one wrote `userid=0`, `tenantid=0` and a blank name straight over
|
||||||
|
// a perfectly good signed-in session. The rider was then on the fallback
|
||||||
|
// line with no identity, from a call that had failed.
|
||||||
|
final bool succeeded = jsonMap['status'] == true;
|
||||||
|
|
||||||
|
if (succeeded && jsonMap.containsKey('details')) {
|
||||||
final details = jsonMap['details'];
|
final details = jsonMap['details'];
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -293,6 +504,31 @@ class AuthProvider {
|
|||||||
await prefs.setInt('tenantid', details['tenantid'] ?? 0);
|
await prefs.setInt('tenantid', details['tenantid'] ?? 0);
|
||||||
await prefs.setInt('applocationid', details['applocationid'] ?? 0);
|
await prefs.setInt('applocationid', details['applocationid'] ?? 0);
|
||||||
|
|
||||||
|
// ── Which business this rider works for ──
|
||||||
|
//
|
||||||
|
// `tenantid` was already persisted here and sent back on rider logs; it
|
||||||
|
// is now also what picks the rider's whole flow — parcel logistics or a
|
||||||
|
// subscription meal run. See [TenantController].
|
||||||
|
//
|
||||||
|
// The name is stored alongside it when the backend sends one, because a
|
||||||
|
// name can be matched without shipping a release for every new tenant id
|
||||||
|
// and it is the field a human can check against the admin console.
|
||||||
|
final String tenantName =
|
||||||
|
(details['tenantname'] ??
|
||||||
|
details['tenantcode'] ??
|
||||||
|
details['tenant'] ??
|
||||||
|
'')
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
if (tenantName.isNotEmpty) {
|
||||||
|
await prefs.setString(TenantController.kTenantName, tenantName);
|
||||||
|
} else {
|
||||||
|
// A rider signing in to a different account must not inherit the last
|
||||||
|
// one's tenant name.
|
||||||
|
await prefs.remove(TenantController.kTenantName);
|
||||||
|
}
|
||||||
|
await TenantController.to.load();
|
||||||
|
|
||||||
final String fcm = (details['userfcmtoken'] ?? '').toString();
|
final String fcm = (details['userfcmtoken'] ?? '').toString();
|
||||||
if (fcm.isNotEmpty) {
|
if (fcm.isNotEmpty) {
|
||||||
await prefs.setString('userfcmtoken', fcm);
|
await prefs.setString('userfcmtoken', fcm);
|
||||||
@@ -388,13 +624,36 @@ class AuthProvider {
|
|||||||
// and the PIN the account was issued with remains the one that works.
|
// and the PIN the account was issued with remains the one that works.
|
||||||
// Making it fail instead would strand a rider on a screen with no way
|
// Making it fail instead would strand a rider on a screen with no way
|
||||||
// forward, which is worse and no more honest.
|
// forward, which is worse and no more honest.
|
||||||
|
// ── It used to answer 200 ──
|
||||||
|
//
|
||||||
|
// "Making it fail instead would strand a rider on a screen with no way
|
||||||
|
// forward, which is worse and no more honest." Half of that was right and
|
||||||
|
// the conclusion was wrong. What the manufactured 200 actually did:
|
||||||
|
//
|
||||||
|
// 1. Create-MPIN told the rider his new MPIN was saved.
|
||||||
|
// 2. The app wrote it to `dbPin` locally.
|
||||||
|
// 3. The server never heard about it.
|
||||||
|
// 4. Every login from then on returned `incorrect PIN`, forever, with no
|
||||||
|
// way for the rider to tell that the PIN he was typing had never
|
||||||
|
// existed anywhere but his own handset.
|
||||||
|
//
|
||||||
|
// Being stranded on a screen that tells you who can help is not worse than
|
||||||
|
// that. It is the only version of this that a rider can act on.
|
||||||
ApiConfig.logGap(
|
ApiConfig.logGap(
|
||||||
'updatePin',
|
'updatePin',
|
||||||
'No rider-facing set-PIN route; reset-pin is admin-only by design.',
|
'No rider-facing set-PIN route; reset-pin is admin-only by design. '
|
||||||
|
'Refusing rather than reporting a write that did not happen.',
|
||||||
);
|
);
|
||||||
return http.Response(
|
return http.Response(
|
||||||
json.encode(ApiConfig.okEnvelope('pin is managed by ops')),
|
json.encode(<String, dynamic>{
|
||||||
200,
|
'status': false,
|
||||||
|
'code': 403,
|
||||||
|
'message':
|
||||||
|
'Your MPIN is issued by your hub and cannot be changed from the '
|
||||||
|
'app. Ask your supervisor to reset it, then sign in with the MPIN '
|
||||||
|
'they give you.',
|
||||||
|
}),
|
||||||
|
403,
|
||||||
headers: {'content-type': 'application/json'},
|
headers: {'content-type': 'application/json'},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -368,7 +368,7 @@ class NotificationServce {
|
|||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w600,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
|
||||||
|
|
||||||
import 'package:miler/data/api_config.dart';
|
import 'package:miler/data/api_config.dart';
|
||||||
|
import 'package:miler/data/work_repository.dart';
|
||||||
|
import 'package:miler/data/load_state.dart';
|
||||||
import 'package:miler/data/miler_api.dart';
|
import 'package:miler/data/miler_api.dart';
|
||||||
|
|
||||||
/// Reads the rider's bookings.
|
/// Reads the rider's bookings.
|
||||||
@@ -26,25 +26,56 @@ class PickupProvider {
|
|||||||
/// The mapping lives in [ApiConfig.pickupFromBooking] rather than here: it is
|
/// The mapping lives in [ApiConfig.pickupFromBooking] rather than here: it is
|
||||||
/// the one place that knows how a v1 booking becomes a stop, and the same
|
/// the one place that knows how a v1 booking becomes a stop, and the same
|
||||||
/// translation is needed by anything else that receives a booking.
|
/// translation is needed by anything else that receives a booking.
|
||||||
|
/// ── One request, however many screens ask ──
|
||||||
|
///
|
||||||
|
/// Home, Bookings, Activity and the post-delivery screen all call this, and
|
||||||
|
/// each of them used to produce its own `GET /miler/bookings` — on mount, on
|
||||||
|
/// tab switch, and on every poll. Four timers, four copies of the day, and
|
||||||
|
/// four ideas of what had been accepted, reconciled only by whichever
|
||||||
|
/// happened to refresh last.
|
||||||
|
///
|
||||||
|
/// They all go through [WorkRepository] now, which collapses concurrent
|
||||||
|
/// callers into one request, keeps one copy, and drops a response that lands
|
||||||
|
/// out of order. Nothing at the call sites changed: this still returns a list
|
||||||
|
/// or throws, which is the contract the screens were written against.
|
||||||
|
///
|
||||||
|
/// The richer answer — loading, empty, offline, unavailable — is on the
|
||||||
|
/// repository for screens that want to render it properly rather than
|
||||||
|
/// flattening it into an exception. See [LoadState].
|
||||||
Future<List<dynamic>> _bookings({String? status}) async {
|
Future<List<dynamic>> _bookings({String? status}) async {
|
||||||
|
// A filtered read is a different question and is not the one the shared
|
||||||
|
// copy answers. Nothing calls this with a status today; if something does,
|
||||||
|
// it gets its own request rather than silently receiving the whole day.
|
||||||
|
if (status != null && status.isNotEmpty) {
|
||||||
final res = await MilerApi.bookings(status: status);
|
final res = await MilerApi.bookings(status: status);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw Exception('Failed (${res.status})${res.message.isEmpty ? '' : ': ${res.message}'}');
|
throw Exception(
|
||||||
}
|
'Failed (${res.status})'
|
||||||
|
'${res.message.isEmpty ? '' : ': ${res.message}'}',
|
||||||
final mapped = ApiConfig.pickupsFromBookings(res.list);
|
|
||||||
debugPrint('[BOOKINGS] ${res.list.length} rows → ${mapped.length} stops');
|
|
||||||
if (mapped.isEmpty && res.list.isNotEmpty) {
|
|
||||||
// The rows arrived and none of them survived translation, which is a
|
|
||||||
// field-name mismatch rather than an empty day. Worth saying out loud —
|
|
||||||
// it is the difference between "no work" and "the adapter is wrong".
|
|
||||||
ApiConfig.logGap(
|
|
||||||
'pickupFromBooking',
|
|
||||||
'${res.list.length} bookings returned but none mapped — check the '
|
|
||||||
'field names against a real payload.',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return mapped;
|
return ApiConfig.pickupsFromBookings(res.list);
|
||||||
|
}
|
||||||
|
|
||||||
|
final state = await WorkRepository.instance.load();
|
||||||
|
return switch (state) {
|
||||||
|
LoadData<List<Map<String, dynamic>>>(:final value) => value,
|
||||||
|
// A real empty day. The screens render their own empty state from this.
|
||||||
|
LoadEmpty<List<Map<String, dynamic>>>() => const <dynamic>[],
|
||||||
|
// No endpoint for this line of work — neither waiting nor retrying helps,
|
||||||
|
// so it must not arrive as an empty day. See [LineNotServedException].
|
||||||
|
LoadUnavailable<List<Map<String, dynamic>>>() =>
|
||||||
|
throw const LineNotServedException(),
|
||||||
|
LoadFailure<List<Map<String, dynamic>>>(:final kind, :final message) =>
|
||||||
|
throw Exception(
|
||||||
|
message.isEmpty ? 'Could not load your work ($kind)' : message,
|
||||||
|
),
|
||||||
|
// Only reachable if the repository handed back its pre-load state, which
|
||||||
|
// it does not. Treated as a failure rather than as an empty day.
|
||||||
|
LoadLoading<List<Map<String, dynamic>>>() => throw Exception(
|
||||||
|
'Still loading',
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything the hub has put in front of this rider today.
|
/// Everything the hub has put in front of this rider today.
|
||||||
@@ -57,3 +88,18 @@ class PickupProvider {
|
|||||||
/// The stops he has accepted.
|
/// The stops he has accepted.
|
||||||
Future<List<dynamic>> getPickupQueuesPicked() => _bookings();
|
Future<List<dynamic>> getPickupQueuesPicked() => _bookings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The rider's line of work has no backend to ask.
|
||||||
|
///
|
||||||
|
/// Distinct from an empty day and from a failed request, because the rider's
|
||||||
|
/// answer is different in each case: wait, retry, or "your hub has not switched
|
||||||
|
/// this on yet". Screens catch this to render the unavailable state rather than
|
||||||
|
/// the empty one.
|
||||||
|
class LineNotServedException implements Exception {
|
||||||
|
const LineNotServedException();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'This line of work is not served by the backend yet — there is no '
|
||||||
|
'bookings endpoint for it. See ServiceProfile.hasBookingsEndpoint.';
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import 'package:flutter/foundation.dart';
|
|||||||
|
|
||||||
import 'package:miler/data/api_config.dart';
|
import 'package:miler/data/api_config.dart';
|
||||||
import 'package:miler/data/assignment_lookup.dart';
|
import 'package:miler/data/assignment_lookup.dart';
|
||||||
|
import 'package:miler/data/lifecycle.dart';
|
||||||
|
import 'package:miler/data/consignment_state.dart';
|
||||||
import 'package:miler/data/miler_api.dart';
|
import 'package:miler/data/miler_api.dart';
|
||||||
|
import 'package:miler/data/accepted_store.dart';
|
||||||
|
|
||||||
/// ─────────────────────────────────────────────────────────────────────────
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
/// THE PER-STOP WRITE PATH
|
/// THE PER-STOP WRITE PATH
|
||||||
@@ -52,11 +55,14 @@ class CreatePickupLogProvider {
|
|||||||
Future<Map<String, dynamic>?> createPickupLog(
|
Future<Map<String, dynamic>?> createPickupLog(
|
||||||
Map<String, dynamic> data,
|
Map<String, dynamic> data,
|
||||||
) async {
|
) async {
|
||||||
final consignmentId = data['consignmentid'] ?? data['orderheaderid'] ??
|
final consignmentId =
|
||||||
data['pickupid'];
|
data['consignmentid'] ?? data['orderheaderid'] ?? data['pickupid'];
|
||||||
final lat = double.tryParse('${data['latitude'] ?? data['riderslat'] ?? ''}');
|
final lat = double.tryParse(
|
||||||
final lon =
|
'${data['latitude'] ?? data['riderslat'] ?? ''}',
|
||||||
double.tryParse('${data['longitude'] ?? data['riderslon'] ?? ''}');
|
);
|
||||||
|
final lon = double.tryParse(
|
||||||
|
'${data['longitude'] ?? data['riderslon'] ?? ''}',
|
||||||
|
);
|
||||||
|
|
||||||
if (consignmentId == null || lat == null || lon == null) {
|
if (consignmentId == null || lat == null || lon == null) {
|
||||||
// Not enough to place the breadcrumb. Silently skipping is right — this
|
// Not enough to place the breadcrumb. Silently skipping is right — this
|
||||||
@@ -104,7 +110,16 @@ class UpdatePickupProvider {
|
|||||||
|
|
||||||
Map<String, dynamic> envelope(ApiResult r) => r.ok
|
Map<String, dynamic> envelope(ApiResult r) => r.ok
|
||||||
? ApiConfig.toLegacyEnvelope(r.raw ?? {'success': true})
|
? ApiConfig.toLegacyEnvelope(r.raw ?? {'success': true})
|
||||||
: {'status': false, 'code': r.status, 'message': r.message};
|
: {
|
||||||
|
'status': false,
|
||||||
|
'code': r.status,
|
||||||
|
'message': r.message,
|
||||||
|
// The backend's stable failure name, alongside the HTTP status
|
||||||
|
// this envelope has always called `code`. Callers that need to
|
||||||
|
// branch on *why* a write was refused read this one — message text
|
||||||
|
// is prose, and prose gets reworded.
|
||||||
|
'errorcode': r.code,
|
||||||
|
};
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'accepted':
|
case 'accepted':
|
||||||
@@ -133,21 +148,150 @@ class UpdatePickupProvider {
|
|||||||
return envelope(r);
|
return envelope(r);
|
||||||
|
|
||||||
case 'arrived':
|
case 'arrived':
|
||||||
return envelope(await MilerApi.reached(id, lat: lat, lon: lon));
|
// ── A 200 is not proof the hub recorded an arrival ──
|
||||||
|
//
|
||||||
|
// Verified against production 21 Aug 2026: `reached` returns
|
||||||
|
// `{"success":true,"data":{"bookingid":78,"status":"Miler_Assigned"}}`
|
||||||
|
// and the booking's status is unchanged afterwards. The endpoint is a
|
||||||
|
// no-op that reports success, so `Arrived_At_Pickup` never reaches the
|
||||||
|
// console — and the app, which read only `ok`, drew ARRIVED anyway.
|
||||||
|
//
|
||||||
|
// The transition is now read for what it proves. The envelope carries
|
||||||
|
// the verdict so the caller can advance the rider without the app
|
||||||
|
// claiming an agreement that does not exist. See [MilerLifecycle].
|
||||||
|
final reached = await MilerApi.reached(id, lat: lat, lon: lon);
|
||||||
|
final arrival = MilerLifecycle.reached(reached);
|
||||||
|
MilerLifecycle.report('reached', arrival);
|
||||||
|
if (arrival.isUnconfirmed) {
|
||||||
|
ApiConfig.logGap(
|
||||||
|
'reached',
|
||||||
|
'booking $id: the call succeeded and the booking status did not '
|
||||||
|
'become Arrived_At_Pickup — ${arrival.evidence}. The hub '
|
||||||
|
'cannot show this rider as arrived. Backend deployment '
|
||||||
|
'mismatch; see MILER_API_REQUIREMENTS.md request 15.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...envelope(reached),
|
||||||
|
'confirmed': arrival.isConfirmed,
|
||||||
|
'serverstatus': arrival.bookingStatus.name,
|
||||||
|
'evidence': arrival.evidence,
|
||||||
|
};
|
||||||
|
|
||||||
case 'Picked up':
|
case 'Picked up':
|
||||||
case 'picked':
|
case 'picked':
|
||||||
return envelope(await MilerApi.pickupComplete(id, lat: lat, lon: lon));
|
// ── The pivot returns the thing the delivery leg runs on ──
|
||||||
|
//
|
||||||
|
// `pickup-complete` is what *creates* the consignment, and `deliver`
|
||||||
|
// and `skip` are consignment routes. The response was being wrapped and
|
||||||
|
// discarded, and the row the rider works from on Deliveries is a
|
||||||
|
// snapshot taken before this call — so nothing downstream knew the
|
||||||
|
// consignment id, and pressing **Delivered** at the door was refused by
|
||||||
|
// the app itself. Recorded here, at the only moment it is guaranteed to
|
||||||
|
// be in hand. See [rememberConsignmentId].
|
||||||
|
final picked = await MilerApi.pickupComplete(id, lat: lat, lon: lon);
|
||||||
|
// ── Which lifecycle the server is running, read from the server ──
|
||||||
|
//
|
||||||
|
// `Collected_By_Miler` + `next_action: start_delivery` is the new
|
||||||
|
// two-step flow; a bare `Out_for_Delivery` is compatibility mode, in
|
||||||
|
// which the pivot releases the consignment itself. Both are real, both
|
||||||
|
// ship today depending on a server-side flag, and the app must never
|
||||||
|
// mirror that flag — it reads which one happened.
|
||||||
|
final pivot = MilerLifecycle.pickupComplete(picked);
|
||||||
|
MilerLifecycle.report('pickup-complete', pivot);
|
||||||
|
if (pivot.isCompatibilityMode) {
|
||||||
|
ApiConfig.logGap(
|
||||||
|
'pickup-complete',
|
||||||
|
'booking $id went straight to Out_for_Delivery — the backend is '
|
||||||
|
'running compatibility mode (MILER_COLLECTED_STATE_ENABLED '
|
||||||
|
'off). The consignment IS out for delivery, so the console '
|
||||||
|
'showing Active is correct and the app must agree with it.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (picked.ok) {
|
||||||
|
final newId = _consignmentIdFrom(picked);
|
||||||
|
if (newId.isNotEmpty) {
|
||||||
|
final orderKey = (data['orderid'] ?? id ?? '').toString().trim();
|
||||||
|
await rememberConsignmentId(orderKey, newId);
|
||||||
|
// ── What the pivot now says about what happens next ──
|
||||||
|
//
|
||||||
|
// Since 21 Aug 2026 the response also carries `status` and
|
||||||
|
// `next_action`: a hyperlocal booking lands on
|
||||||
|
// `Collected_By_Miler` with `start_delivery`, and a hub-routed one
|
||||||
|
// on `Created` with `inward_at_hub`. Logged rather than acted on —
|
||||||
|
// the release is the rider's press, and the states it produces are
|
||||||
|
// read back from the consignment itself, never remembered from
|
||||||
|
// here. See `MyPickups.startRound`.
|
||||||
|
debugPrint(
|
||||||
|
'[PICKUP] booking $orderKey → consignment $newId '
|
||||||
|
'(${_field(picked, 'status')}, next: '
|
||||||
|
'${_field(picked, 'next_action')})',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ApiConfig.logGap(
|
||||||
|
'pickup-complete',
|
||||||
|
'no consignment id in the response for booking $id — the '
|
||||||
|
'delivery leg will have to wait for the queue to carry it.',
|
||||||
|
);
|
||||||
|
// The one thing that makes this diagnosable next time. Debug-only:
|
||||||
|
// a response body is not something to write into a release log.
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[PICKUP] pickup-complete body: ${picked.raw}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...envelope(picked),
|
||||||
|
'confirmed': pivot.isConfirmed,
|
||||||
|
// What the stop actually became, in the consignment's own words.
|
||||||
|
// The caller stamps this rather than assuming 'picked' — the whole
|
||||||
|
// point of the exercise is that the rider's rung and the office's
|
||||||
|
// rung are the same rung.
|
||||||
|
'consignmentstatus':
|
||||||
|
pivot.consignmentState == ConsignmentState.unknown
|
||||||
|
? ''
|
||||||
|
: pivot.consignmentState.name,
|
||||||
|
'nextaction': pivot.nextAction,
|
||||||
|
'compatibilitymode': pivot.isCompatibilityMode,
|
||||||
|
'evidence': pivot.evidence,
|
||||||
|
};
|
||||||
|
|
||||||
case 'delivered':
|
case 'delivered':
|
||||||
case 'Delivered':
|
case 'Delivered':
|
||||||
|
// ── This route keys on the CONSIGNMENT, not the booking ──
|
||||||
|
//
|
||||||
|
// Same trap as accept/reject and `bookingassignmentid`: two ids from
|
||||||
|
// two sequences, and passing the wrong one gets a 404 while the rider
|
||||||
|
// is shown success. This branch was passing `id`, which is the booking
|
||||||
|
// — so every delivery it sent was addressed to whatever consignment
|
||||||
|
// happened to share that number.
|
||||||
|
//
|
||||||
|
// The consignment exists only after `pickup-complete` has converted the
|
||||||
|
// booking; `MilerGetMyBookings` returns it and the stop adapter carries
|
||||||
|
// it through. Without one there is nothing to deliver *yet*, and saying
|
||||||
|
// so is better than guessing an id.
|
||||||
|
final consignmentId =
|
||||||
|
(data['consignmentid'] ?? data['consignmentId'] ?? '')
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
if (consignmentId.isEmpty) {
|
||||||
|
ApiConfig.logGap(
|
||||||
|
'update:delivered',
|
||||||
|
'Booking $id has no consignmentid — it has not been collected yet, '
|
||||||
|
'so there is nothing to deliver.',
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
'status': false,
|
||||||
|
'message': 'This order has not been picked up yet.',
|
||||||
|
};
|
||||||
|
}
|
||||||
return envelope(
|
return envelope(
|
||||||
await MilerApi.deliver(
|
await MilerApi.deliver(
|
||||||
id,
|
consignmentId,
|
||||||
deliveredToName: (data['pickupcustomer'] ?? '').toString(),
|
deliveredToName: (data['pickupcustomer'] ?? '').toString(),
|
||||||
// Only sent when there is one. The tenant flag decides whether it
|
// Optional: nothing generates a delivery OTP, so the handler
|
||||||
// is required, and an empty string would fail a tenant that has it
|
// records whether one was presented rather than checking it. A
|
||||||
// on while being pointless for one that does not.
|
// milk-run drop has none and must not invent one.
|
||||||
otp: (data['otp'] ?? '').toString(),
|
otp: (data['otp'] ?? '').toString(),
|
||||||
photoUrl: (data['dropimage'] ?? '').toString(),
|
photoUrl: (data['dropimage'] ?? '').toString(),
|
||||||
receiverSignatureUrl: (data['signature'] ?? '').toString(),
|
receiverSignatureUrl: (data['signature'] ?? '').toString(),
|
||||||
@@ -185,11 +329,107 @@ class UpdatePickupProvider {
|
|||||||
return envelope(await MilerApi.setAvailability('On_Pickup'));
|
return envelope(await MilerApi.setAvailability('On_Pickup'));
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
// ── An unmapped status is a failure, not a success ──
|
||||||
|
//
|
||||||
|
// This returned `okEnvelope()`: nothing left the phone, and the caller
|
||||||
|
// was told the write had landed. The rider watched the stop advance,
|
||||||
|
// the local stores recorded it, and the hub was never told — which is
|
||||||
|
// the single most expensive shape of bug this app can have, because
|
||||||
|
// every screen downstream then trusts a transition that does not exist
|
||||||
|
// server-side.
|
||||||
|
//
|
||||||
|
// It also defeats the typed-status handling upstream: a status this
|
||||||
|
// build does not recognise must never resolve to a settled state, and
|
||||||
|
// silently succeeding here is exactly that, one layer lower.
|
||||||
ApiConfig.logGap('update:unknown', 'Unmapped orderstatus="$status".');
|
ApiConfig.logGap('update:unknown', 'Unmapped orderstatus="$status".');
|
||||||
return ApiConfig.okEnvelope();
|
return {
|
||||||
|
'status': false,
|
||||||
|
'message': 'This app cannot record "$status" yet — nothing was sent.',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The consignment id out of a `pickup-complete` response, whatever shape it
|
||||||
|
/// arrives in.
|
||||||
|
///
|
||||||
|
/// Tolerant on purpose. The contract now names the field — `consignment_id`,
|
||||||
|
/// alongside `status` and `next_action` — but this ran for a long time
|
||||||
|
/// against a route whose response body was written down nowhere, riders are
|
||||||
|
/// on builds that met both, and the cost of a miss is somebody at a door
|
||||||
|
/// being told there is nothing to deliver. The search stays.
|
||||||
|
/// One shallow field off a mutation response, for logging.
|
||||||
|
///
|
||||||
|
/// Deliberately not recursive and deliberately not stored: these values are
|
||||||
|
/// a description of a moment that has already passed by the time anything
|
||||||
|
/// would read them back.
|
||||||
|
String _field(ApiResult res, String key) {
|
||||||
|
final v = res.data[key];
|
||||||
|
if (v == null || v is Map || v is List) return '?';
|
||||||
|
final str = v.toString().trim();
|
||||||
|
return str.isEmpty ? '?' : str;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _consignmentIdFrom(ApiResult res) {
|
||||||
|
// Searched rather than looked up, at every depth. The response body for
|
||||||
|
// this route is written down nowhere — not in the API reference, not in the
|
||||||
|
// flow doc — so a fixed list of key names is a guess, and the cost of
|
||||||
|
// guessing wrong is a rider standing at a door being told there is nothing
|
||||||
|
// to deliver. Anything whose key names a consignment and whose value is a
|
||||||
|
// plain scalar counts.
|
||||||
|
String found = '';
|
||||||
|
|
||||||
|
void walk(dynamic node, int depth) {
|
||||||
|
if (found.isNotEmpty || depth > 4 || node == null) return;
|
||||||
|
if (node is List) {
|
||||||
|
for (final item in node) {
|
||||||
|
walk(item, depth + 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node is! Map) return;
|
||||||
|
|
||||||
|
// Exact names first, so a nested `id` never wins over a real one.
|
||||||
|
for (final key in const [
|
||||||
|
'consignmentid',
|
||||||
|
'consignmentId',
|
||||||
|
'consignment_id',
|
||||||
|
'consignmentno',
|
||||||
|
]) {
|
||||||
|
final v = node[key];
|
||||||
|
if (v != null && v is! Map && v is! List) {
|
||||||
|
final str = v.toString().trim();
|
||||||
|
if (str.isNotEmpty && str != '0' && str != 'null') {
|
||||||
|
found = str;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then a `consignment` object, whose own id is the one wanted.
|
||||||
|
final nested = node['consignment'];
|
||||||
|
if (nested is Map) {
|
||||||
|
for (final key in const ['consignmentid', 'consignmentId', 'id']) {
|
||||||
|
final v = nested[key];
|
||||||
|
if (v != null && v is! Map && v is! List) {
|
||||||
|
final str = v.toString().trim();
|
||||||
|
if (str.isNotEmpty && str != '0') {
|
||||||
|
found = str;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final v in node.values) {
|
||||||
|
walk(v, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(res.data, 0);
|
||||||
|
if (found.isEmpty) walk(res.raw, 0);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
/// **Step 2** — what the rider actually took, measured at the door.
|
/// **Step 2** — what the rider actually took, measured at the door.
|
||||||
///
|
///
|
||||||
/// Reads the verification screen's payload directly rather than taking a
|
/// Reads the verification screen's payload directly rather than taking a
|
||||||
@@ -224,9 +464,7 @@ class UpdatePickupProvider {
|
|||||||
// and it keeps the chargeable total correct even though the per-parcel
|
// and it keeps the chargeable total correct even though the per-parcel
|
||||||
// figures are an even split rather than a measurement.
|
// figures are an even split rather than a measurement.
|
||||||
final double each = totalWeight / count;
|
final double each = totalWeight / count;
|
||||||
final parcels = [
|
final parcels = [for (var i = 0; i < count; i++) ParcelEntry(weight: each)];
|
||||||
for (var i = 0; i < count; i++) ParcelEntry(weight: each),
|
|
||||||
];
|
|
||||||
|
|
||||||
final res = await MilerApi.submitParcels(bookingId, parcels);
|
final res = await MilerApi.submitParcels(bookingId, parcels);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -238,6 +476,85 @@ class UpdatePickupProvider {
|
|||||||
: {'status': false, 'code': res.status, 'message': res.message};
|
: {'status': false, 'code': res.status, 'message': res.message};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Step 1b** — the FROM and TO the rider confirmed with the customer.
|
||||||
|
///
|
||||||
|
/// Reads the shipment-capture screen's payload, same as [submitParcels] reads
|
||||||
|
/// the verification screen's, so there is one record of the door rather than
|
||||||
|
/// two argument lists to keep in step.
|
||||||
|
///
|
||||||
|
/// Sent before the parcel and payment steps because `pickup-complete` builds
|
||||||
|
/// the consignment — its routing and its pricing zone — from these values,
|
||||||
|
/// and the handler refuses them once that conversion has happened.
|
||||||
|
Future<Map<String, dynamic>?> submitAddresses(
|
||||||
|
Object bookingId,
|
||||||
|
Map<String, dynamic> capture,
|
||||||
|
) async {
|
||||||
|
final Map? from = capture['from'] is Map ? capture['from'] as Map : null;
|
||||||
|
final Map? to = capture['to'] is Map ? capture['to'] as Map : null;
|
||||||
|
if (from == null && to == null) {
|
||||||
|
return ApiConfig.okEnvelope('no addresses captured');
|
||||||
|
}
|
||||||
|
|
||||||
|
String str(Map? m, String k) => (m?[k] ?? '').toString().trim();
|
||||||
|
double? coord(Map? m, String k) => double.tryParse('${m?[k] ?? ''}');
|
||||||
|
|
||||||
|
final res = await MilerApi.updateBookingAddresses(
|
||||||
|
bookingId,
|
||||||
|
pickupAddress: str(from, 'address'),
|
||||||
|
pickupPincode: str(from, 'pincode'),
|
||||||
|
pickupLat: coord(from, 'lat'),
|
||||||
|
pickupLon: coord(from, 'lon'),
|
||||||
|
deliveryAddress: str(to, 'address'),
|
||||||
|
deliveryPincode: str(to, 'pincode'),
|
||||||
|
deliveryLat: coord(to, 'lat'),
|
||||||
|
deliveryLon: coord(to, 'lon'),
|
||||||
|
deliveryCity: str(to, 'city'),
|
||||||
|
);
|
||||||
|
debugPrint(
|
||||||
|
'[ADDRESS] booking $bookingId '
|
||||||
|
'→ ${res.ok ? 'ok' : 'FAILED ${res.status} ${res.message}'}',
|
||||||
|
);
|
||||||
|
return res.ok
|
||||||
|
? ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true})
|
||||||
|
: {'status': false, 'code': res.status, 'message': res.message};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Step 2b** — what this shipment costs, from the pricing table.
|
||||||
|
///
|
||||||
|
/// Returns null when the call itself failed, and a [ShipmentQuote] with
|
||||||
|
/// `found: false` when the table simply has no rule for this combination.
|
||||||
|
/// The two are different things to tell a rider — "try again" versus "call
|
||||||
|
/// the hub, this cannot be priced here" — so they stay distinguishable, and
|
||||||
|
/// neither one produces a number.
|
||||||
|
Future<ShipmentQuote?> fetchQuote({
|
||||||
|
required double weight,
|
||||||
|
required String serviceType,
|
||||||
|
String? pickupPincode,
|
||||||
|
String? deliveryPincode,
|
||||||
|
String? category,
|
||||||
|
}) async {
|
||||||
|
final res = await MilerApi.checkPrice(
|
||||||
|
weight: weight,
|
||||||
|
serviceType: serviceType,
|
||||||
|
pickupPincode: pickupPincode,
|
||||||
|
deliveryPincode: deliveryPincode,
|
||||||
|
category: category,
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
debugPrint('[PRICE] FAILED ${res.status} ${res.message}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final quote = ShipmentQuote.fromData(
|
||||||
|
res.map,
|
||||||
|
preferredCategory: category ?? '',
|
||||||
|
);
|
||||||
|
debugPrint(
|
||||||
|
'[PRICE] ${quote.zone}/${quote.serviceType} ${quote.weight}kg → '
|
||||||
|
'${quote.found ? '${quote.currency} ${quote.payable}' : quote.message}',
|
||||||
|
);
|
||||||
|
return quote;
|
||||||
|
}
|
||||||
|
|
||||||
/// **Step 3** — the money, from the collect-payment screen's payload.
|
/// **Step 3** — the money, from the collect-payment screen's payload.
|
||||||
///
|
///
|
||||||
/// Returns a success envelope without calling anything when nothing was
|
/// Returns a success envelope without calling anything when nothing was
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
class SummaryProvider {
|
class SummaryProvider {
|
||||||
final String baseUrl = ApiConstants.summaryApiLive;
|
final String baseUrl = ApiConstants.summaryApiLive;
|
||||||
|
|
||||||
Future<PickupStats?> fetchSummaryStats(int userId) =>
|
Future<PickupStats?> fetchSummaryStats(int userId) => _fetchSummaryStatsNew();
|
||||||
_fetchSummaryStatsNew();
|
|
||||||
|
|
||||||
/// NEW API: GET /miler/earnings?period=daily|weekly|monthly
|
/// NEW API: GET /miler/earnings?period=daily|weekly|monthly
|
||||||
/// -> data:{ completed_stops, total_kms, total_earnings, total_bonus }.
|
/// -> data:{ completed_stops, total_kms, total_earnings, total_bonus }.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Future<({int w, int h})> callout(String text, double dpr) async {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12 * dpr,
|
fontSize: 12 * dpr,
|
||||||
height: 1.25,
|
height: 1.25,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w600,
|
||||||
color: ColorConstants.slateText,
|
color: ColorConstants.slateText,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,37 +1,92 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// Identifiers for this handset: one stable device id, one push token.
|
||||||
|
///
|
||||||
|
/// ── Neither of these may ever fail a sign-in ──
|
||||||
|
///
|
||||||
|
/// [ensureDeviceId] used to `throw` on every unhappy path — on an empty
|
||||||
|
/// hardware id, on a `PlatformException`, and on anything else it caught. It is
|
||||||
|
/// awaited on the line *before* the PIN check posts, inside the sign-in's outer
|
||||||
|
/// `try`, so a throw here never reached the network at all: the rider got
|
||||||
|
/// "Could not reach the server", pressed Retry, and the MPIN screen painted
|
||||||
|
/// **"Incorrect MPIN"** at him. For every phone number, on every attempt.
|
||||||
|
///
|
||||||
|
/// Two things made that certain rather than unlucky:
|
||||||
|
///
|
||||||
|
/// • It asked for `androidInfo` **unconditionally**. On iOS that call throws,
|
||||||
|
/// so no iPhone could ever complete a PIN login.
|
||||||
|
/// • The value is not even sent. `POST /miler/verify-pin` takes `phone`,
|
||||||
|
/// `pin`, `configid` and `device_token` — `deviceId` is dropped on the floor
|
||||||
|
/// inside `AuthProvider.login`. The sign-in was being aborted by a field the
|
||||||
|
/// sign-in does not use.
|
||||||
|
///
|
||||||
|
/// So: this file hands back a best-effort answer and **never throws**. A caller
|
||||||
|
/// that wants to know whether the answer is real can compare against
|
||||||
|
/// [unknownDeviceId]; nothing currently needs to.
|
||||||
class DeviceUtils {
|
class DeviceUtils {
|
||||||
static const String _deviceIdKey = 'deviceId';
|
static const String _deviceIdKey = 'deviceId';
|
||||||
static const String _fcmTokenKey = 'fcmToken';
|
static const String _fcmTokenKey = 'fcmToken';
|
||||||
|
|
||||||
|
/// Prefix on ids this class generated rather than read off the hardware.
|
||||||
|
static const String unknownDeviceId = 'generated-';
|
||||||
|
|
||||||
|
/// A stable id for this install, persisted on first use.
|
||||||
|
///
|
||||||
|
/// Hardware id where the platform has one — Android's `id`, iOS's
|
||||||
|
/// `identifierForVendor` — and a persisted random id everywhere else, which
|
||||||
|
/// is what the backend actually needs: something that does not change
|
||||||
|
/// between launches. Never throws.
|
||||||
static Future<String> ensureDeviceId(SharedPreferences prefs) async {
|
static Future<String> ensureDeviceId(SharedPreferences prefs) async {
|
||||||
final String? existing = prefs.getString(_deviceIdKey);
|
final String? existing = prefs.getString(_deviceIdKey);
|
||||||
if (existing != null && existing.isNotEmpty) {
|
if (existing != null && existing.isNotEmpty) return existing;
|
||||||
if (kDebugMode) debugPrint('[DEVICE] Using cached device ID: $existing');
|
|
||||||
return existing;
|
String resolved = '';
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
final deviceInfo = DeviceInfoPlugin();
|
final info = DeviceInfoPlugin();
|
||||||
final android = await deviceInfo.androidInfo;
|
switch (defaultTargetPlatform) {
|
||||||
final String androidId = android.id;
|
case TargetPlatform.android:
|
||||||
if (androidId.isNotEmpty) {
|
resolved = (await info.androidInfo).id;
|
||||||
await prefs.setString(_deviceIdKey, androidId);
|
case TargetPlatform.iOS:
|
||||||
return androidId;
|
resolved = (await info.iosInfo).identifierForVendor ?? '';
|
||||||
} else {
|
case TargetPlatform.macOS:
|
||||||
throw Exception('Android ID is empty');
|
resolved = (await info.macOsInfo).systemGUID ?? '';
|
||||||
|
case TargetPlatform.windows:
|
||||||
|
resolved = (await info.windowsInfo).deviceId;
|
||||||
|
case TargetPlatform.linux:
|
||||||
|
resolved = (await info.linuxInfo).machineId ?? '';
|
||||||
|
case TargetPlatform.fuchsia:
|
||||||
|
resolved = '';
|
||||||
}
|
}
|
||||||
} on PlatformException catch (e) {
|
|
||||||
throw Exception('Failed to get device ID: ${e.message}');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('Failed to get device ID: $e');
|
// A missing plugin, a denied permission, a simulator with no vendor id.
|
||||||
}
|
// None of these are reasons to stop a rider signing in.
|
||||||
|
debugPrint('[DEVICE] hardware id unavailable, generating one: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resolved.trim().isEmpty) resolved = _generateId();
|
||||||
|
await prefs.setString(_deviceIdKey, resolved);
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 128 bits of hex behind [unknownDeviceId], so a generated id is
|
||||||
|
/// self-identifying in a log and cannot collide with a hardware one.
|
||||||
|
static String _generateId() {
|
||||||
|
final rand = Random.secure();
|
||||||
|
final bytes = List<int>.generate(16, (_) => rand.nextInt(256));
|
||||||
|
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
return '$unknownDeviceId$hex';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The FCM registration token, or `''` when push is unavailable.
|
||||||
|
///
|
||||||
|
/// Already total, and stays that way: a rider who declined notifications
|
||||||
|
/// still has a day's work to do.
|
||||||
static Future<String> ensureFcmToken(SharedPreferences prefs) async {
|
static Future<String> ensureFcmToken(SharedPreferences prefs) async {
|
||||||
try {
|
try {
|
||||||
final String? existing = prefs.getString(_fcmTokenKey);
|
final String? existing = prefs.getString(_fcmTokenKey);
|
||||||
|
|||||||
34
lib/utils/external_navigation.dart
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
/// Hands a destination to the phone's own maps app.
|
||||||
|
///
|
||||||
|
/// Native intent first, web as the fallback, so a rider gets the same app
|
||||||
|
/// whichever control in Miler he presses. Three screens had their own copy of
|
||||||
|
/// these two URLs — a route row, a map screen and a confirmation sheet — which
|
||||||
|
/// is three chances for one of them to open a different app, or the wrong
|
||||||
|
/// scheme, on the one action a rider takes at every single stop.
|
||||||
|
///
|
||||||
|
/// Silent on failure by design: there is nothing useful to say to a rider whose
|
||||||
|
/// phone has no maps application, and the screen he pressed from still has the
|
||||||
|
/// address on it.
|
||||||
|
Future<void> openExternalNavigation(double lat, double lng) async {
|
||||||
|
try {
|
||||||
|
final native = Uri.parse('google.navigation:q=$lat,$lng&mode=d');
|
||||||
|
if (await launchUrl(native, mode: LaunchMode.externalApplication)) return;
|
||||||
|
} catch (_) {
|
||||||
|
// Falls through to the web URL below — a device with no navigation intent
|
||||||
|
// handler is the normal case on iOS, not an error.
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await launchUrl(
|
||||||
|
Uri.parse(
|
||||||
|
'https://www.google.com/maps/dir/?api=1'
|
||||||
|
'&destination=$lat,$lng&travelmode=driving',
|
||||||
|
),
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[NAV] could not open maps: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
187
lib/views/Dashboard/activity/activity_format.dart
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// HOW A FINISHED STOP IS WRITTEN DOWN
|
||||||
|
///
|
||||||
|
/// The Activity list and the Delivery Details page describe the same records
|
||||||
|
/// from two distances — one for scanning, one for investigating — and both have
|
||||||
|
/// to write a clock, a duration, a distance, a rupee figure and a route the
|
||||||
|
/// same way. A rider who reads `1h 27m` on a row and `1:27` on the page it
|
||||||
|
/// opens has two facts to reconcile where there is one.
|
||||||
|
///
|
||||||
|
/// So the formatters live here, once, as plain functions. No widgets, no
|
||||||
|
/// state, no I/O — testable without a widget tree, and impossible to
|
||||||
|
/// accidentally fork by copying a screen.
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:miler/data/milk_run.dart';
|
||||||
|
import 'package:miler/data/service_profile.dart';
|
||||||
|
|
||||||
|
/// First parseable timestamp among [keys], or null.
|
||||||
|
///
|
||||||
|
/// Null is a real answer: a stop closed in bulk from Home never opened the map,
|
||||||
|
/// so it genuinely has no departure time. Callers drop what they cannot say.
|
||||||
|
DateTime? stampOf(Map<String, dynamic> stop, List<String> keys) {
|
||||||
|
for (final k in keys) {
|
||||||
|
final parsed = parseStamp(stop[k]);
|
||||||
|
if (parsed != null) return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One timestamp, read as the wall-clock it actually is.
|
||||||
|
///
|
||||||
|
/// ── The false `Z` ──
|
||||||
|
///
|
||||||
|
/// Doormile timestamps are IST wall-clock in Postgres `timestamp without time
|
||||||
|
/// zone` columns. Some responses come back with a trailing `Z` anyway — a known
|
||||||
|
/// Go + pgx footgun where a naive DB timestamp loads into `time.Time` under the
|
||||||
|
/// UTC location and marshals with a zone marker it never had. The console hit
|
||||||
|
/// this first and strips it the same way; see `doormileTimestamp.js`.
|
||||||
|
///
|
||||||
|
/// `DateTime.tryParse` believes the `Z`, so `11:43Z` became a real UTC instant
|
||||||
|
/// — and that broke the tracking rail in the one way it cannot survive: an
|
||||||
|
/// `Order placed 11:43` landed *below* two 11:57 events, because as an absolute
|
||||||
|
/// instant it is 17:13 IST. A timeline whose whole meaning is the order of its
|
||||||
|
/// rungs was rendering them out of order, while each rung's own clock still
|
||||||
|
/// printed the right digits, so nothing on screen explained why.
|
||||||
|
///
|
||||||
|
/// Stripping the marker before parsing makes both shapes — truly naive, and
|
||||||
|
/// naive-with-a-false-`Z` — read as the digits the backend meant. A stamp the
|
||||||
|
/// app wrote itself carries no marker and is unaffected.
|
||||||
|
DateTime? parseStamp(dynamic raw) {
|
||||||
|
final s = (raw ?? '').toString().trim();
|
||||||
|
if (s.isEmpty) return null;
|
||||||
|
final stripped = s.replaceFirst(RegExp(r'(Z|[+-]\d{2}:?\d{2})$'), '');
|
||||||
|
return DateTime.tryParse(stripped) ?? DateTime.tryParse(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best available "when did this happen", epoch-comparable.
|
||||||
|
///
|
||||||
|
/// The app's own stamps first: they are written at the moment the rider acts,
|
||||||
|
/// so they are both the most accurate and the only ones present on a stop the
|
||||||
|
/// backend has not caught up with yet. Falls back to zero so a record with no
|
||||||
|
/// timestamp sinks rather than jumping to the top of the day.
|
||||||
|
DateTime happenedAt(Map<String, dynamic> stop) =>
|
||||||
|
stampOf(stop, const [
|
||||||
|
'completedat',
|
||||||
|
'skippedat',
|
||||||
|
'pickedtime',
|
||||||
|
'picked_time',
|
||||||
|
'deliverytime',
|
||||||
|
'updatedon',
|
||||||
|
'modifiedon',
|
||||||
|
'expected_pickup_time',
|
||||||
|
]) ??
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
|
||||||
|
/// `14:05` → `2:05 PM`. The same shape as the clock the map sheet's ETA prints,
|
||||||
|
/// so a time read on the way to a stop and the time recorded against it
|
||||||
|
/// afterwards are written the same way.
|
||||||
|
String clockOf(DateTime t) {
|
||||||
|
final hour12 = t.hour % 12 == 0 ? 12 : t.hour % 12;
|
||||||
|
final minute = t.minute.toString().padLeft(2, '0');
|
||||||
|
return '$hour12:$minute ${t.hour < 12 ? 'AM' : 'PM'}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A duration at a glance: `6m`, `1h 12m`.
|
||||||
|
///
|
||||||
|
/// Seconds are never shown — nothing on these screens is decided to the second,
|
||||||
|
/// and they only make the figure harder to compare against the one on the row
|
||||||
|
/// below it.
|
||||||
|
String shortDuration(Duration d) {
|
||||||
|
final total = d.inMinutes;
|
||||||
|
if (total < 1) return '<1m';
|
||||||
|
final hours = total ~/ 60;
|
||||||
|
final minutes = total % 60;
|
||||||
|
if (hours == 0) return '${minutes}m';
|
||||||
|
return minutes == 0 ? '${hours}h' : '${hours}h ${minutes}m';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `4.6`, `12`. One decimal under ten kilometres and none over it: the second
|
||||||
|
/// digit stops meaning anything at that distance and costs width on a row.
|
||||||
|
String kmText(double v) =>
|
||||||
|
v >= 10 ? v.toStringAsFixed(0) : v.toStringAsFixed(1);
|
||||||
|
|
||||||
|
/// `₹1,240`.
|
||||||
|
///
|
||||||
|
/// Grouped the Indian way — the last three digits, then twos — because that is
|
||||||
|
/// how the figure is read back to a hub clerk, and an ungrouped `₹112450` is
|
||||||
|
/// the one number on the page a rider has to count.
|
||||||
|
String rupees(double v) {
|
||||||
|
final whole = v.truncate();
|
||||||
|
final fraction = v - whole;
|
||||||
|
final digits = whole.abs().toString();
|
||||||
|
|
||||||
|
String grouped;
|
||||||
|
if (digits.length <= 3) {
|
||||||
|
grouped = digits;
|
||||||
|
} else {
|
||||||
|
final last3 = digits.substring(digits.length - 3);
|
||||||
|
var rest = digits.substring(0, digits.length - 3);
|
||||||
|
final parts = <String>[];
|
||||||
|
while (rest.length > 2) {
|
||||||
|
parts.insert(0, rest.substring(rest.length - 2));
|
||||||
|
rest = rest.substring(0, rest.length - 2);
|
||||||
|
}
|
||||||
|
if (rest.isNotEmpty) parts.insert(0, rest);
|
||||||
|
grouped = '${parts.join(',')},$last3';
|
||||||
|
}
|
||||||
|
|
||||||
|
final sign = v < 0 ? '-' : '';
|
||||||
|
final tail = fraction == 0 ? '' : '.${(fraction * 10).round().clamp(0, 9)}';
|
||||||
|
return '$sign₹$grouped$tail';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ETA the hub gave for this stop — minutes allowed, or the clock time it
|
||||||
|
/// was due by, whichever the booking carries. Null when it carries neither.
|
||||||
|
String? plannedEtaOf(Map<String, dynamic> stop) {
|
||||||
|
final minutes = int.tryParse((stop['eta'] ?? '').toString().trim());
|
||||||
|
if (minutes != null && minutes > 0) return '$minutes min allowed';
|
||||||
|
|
||||||
|
final due = stampOf(stop, const [
|
||||||
|
'expected_pickup_time',
|
||||||
|
'expectedpickuptime',
|
||||||
|
'slotendtime',
|
||||||
|
]);
|
||||||
|
return due == null ? null : 'By ${clockOf(due)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two ends of a stop's journey, or one end when the payload names only
|
||||||
|
/// one.
|
||||||
|
///
|
||||||
|
/// Which name belongs at which end is a question about the **line**, and
|
||||||
|
/// getting it wrong reads as a rider having ridden the route backwards:
|
||||||
|
///
|
||||||
|
/// • **A round** collects at a source and hands over at a door, so the kitchen
|
||||||
|
/// leads and the subscriber is the destination.
|
||||||
|
/// • **A parcel booking** is a first-mile collection *from* the customer, and
|
||||||
|
/// where it goes afterwards is the hub's problem — there is no second name
|
||||||
|
/// to print, so the customer stands alone rather than getting an arrow to
|
||||||
|
/// nowhere.
|
||||||
|
///
|
||||||
|
/// Read from [MilkRun] and [ServiceProfile] so this and the delivery card
|
||||||
|
/// cannot describe one stop as two different journeys.
|
||||||
|
({String from, String? to}) routeEndsOf(Map<String, dynamic> stop) {
|
||||||
|
final customer = (stop['pickupcustomer'] ?? stop['tenantname'] ?? '')
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
final source = MilkRun.sourceNameOf(stop);
|
||||||
|
if (ServiceProfile.active.deliversToCustomer &&
|
||||||
|
source.isNotEmpty &&
|
||||||
|
customer.isNotEmpty) {
|
||||||
|
return (from: source, to: customer);
|
||||||
|
}
|
||||||
|
return (from: customer.isEmpty ? 'Stop' : customer, to: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Vidhya Kitchen to Joe Mathew`, or the bare customer. See [routeEndsOf].
|
||||||
|
///
|
||||||
|
/// **For semantics and logs, not for drawing.** The arrow between the two ends
|
||||||
|
/// is a widget on screen — Poppins carries no U+2192 and the character rendered
|
||||||
|
/// as a tofu box on the one line of the record a rider reads first. A screen
|
||||||
|
/// reader wants the word anyway: "Vidhya Kitchen right-arrow Joe Mathew" is not
|
||||||
|
/// a sentence.
|
||||||
|
String routeLineOf(Map<String, dynamic> stop) {
|
||||||
|
final ends = routeEndsOf(stop);
|
||||||
|
return ends.to == null ? ends.from : '${ends.from} to ${ends.to}';
|
||||||
|
}
|
||||||
1504
lib/views/Dashboard/activity/delivery_details_page.dart
Normal file
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
|
||||||
|
import 'package:miler/data/service_profile.dart';
|
||||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||||
@@ -17,7 +19,7 @@ import 'package:miler/widget/Bottom_page.dart';
|
|||||||
/// three stops and scrolled back to the top had nothing on screen telling him
|
/// three stops and scrolled back to the top had nothing on screen telling him
|
||||||
/// so, and no way through.
|
/// so, and no way through.
|
||||||
///
|
///
|
||||||
/// This says both things in one control, in one fixed place: **how many
|
/// This says both things in one control, in one fixed p~lace: **how many
|
||||||
/// bookings he is holding**, and one tap to go work them. It is the counterpart
|
/// bookings he is holding**, and one tap to go work them. It is the counterpart
|
||||||
/// to [SelectionBar] — that one is about the stops he is choosing, this one
|
/// to [SelectionBar] — that one is about the stops he is choosing, this one
|
||||||
/// about the stops he has already taken — so they share the floating slot at
|
/// about the stops he has already taken — so they share the floating slot at
|
||||||
@@ -29,6 +31,10 @@ import 'package:miler/widget/Bottom_page.dart';
|
|||||||
/// at some point today".
|
/// at some point today".
|
||||||
/// ─────────────────────────────────────────────────────────────────────────
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
class AcceptedPill extends StatelessWidget {
|
class AcceptedPill extends StatelessWidget {
|
||||||
|
/// The tallest this pill gets, excluding the device inset. See
|
||||||
|
/// [SelectionBar.maxHeight] for why the surface declares its own.
|
||||||
|
static double get maxHeight => ButtonSizes.compact + 24.h;
|
||||||
|
|
||||||
/// Accepted bookings still waiting to be worked. Zero renders nothing.
|
/// Accepted bookings still waiting to be worked. Zero renders nothing.
|
||||||
final int count;
|
final int count;
|
||||||
|
|
||||||
@@ -41,6 +47,8 @@ class AcceptedPill extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (count <= 0) return const SizedBox.shrink();
|
if (count <= 0) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final profile = ServiceProfile.active;
|
||||||
|
|
||||||
final radius = BorderRadius.circular(DesignConstants.radiusFull);
|
final radius = BorderRadius.circular(DesignConstants.radiusFull);
|
||||||
|
|
||||||
// Rides the floating nav bar: it is `Positioned` outside the Scaffold's
|
// Rides the floating nav bar: it is `Positioned` outside the Scaffold's
|
||||||
@@ -64,11 +72,29 @@ class AcceptedPill extends StatelessWidget {
|
|||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: Semantics(
|
child: Semantics(
|
||||||
button: true,
|
button: true,
|
||||||
|
// The noun follows the rider's line of work — "booking" on a
|
||||||
|
// parcel route, "delivery" on a meal run. See [ServiceProfile].
|
||||||
label: count == 1
|
label: count == 1
|
||||||
? '1 accepted booking. Go to Bookings.'
|
? '1 ${profile.jobNoun} ${_verb(profile)}. '
|
||||||
: '$count accepted bookings. Go to Bookings.',
|
'Go to ${profile.workTabLabel}.'
|
||||||
|
: '$count ${profile.jobNounPlural} ${_verb(profile)}. '
|
||||||
|
'Go to ${profile.workTabLabel}.',
|
||||||
|
// ── Red is for the action, not for the notice ──
|
||||||
|
//
|
||||||
|
// This was a solid brand-red slab with a white disc in it, floating
|
||||||
|
// over the foot of the route. On a page whose one accent moment is
|
||||||
|
// supposed to be the journey button, it was the largest and
|
||||||
|
// reddest object on the screen — and it is not an action the screen
|
||||||
|
// exists for at all. It is a *notice with a way through*: "you are
|
||||||
|
// holding nine of these, they are over there."
|
||||||
|
//
|
||||||
|
// So the surface goes quiet — the same lifted white the selection
|
||||||
|
// bar uses, which is the app's one "floating over the list"
|
||||||
|
// material — and the brand is spent on the two things that carry
|
||||||
|
// the meaning: the count and the arrow out. Same height, same
|
||||||
|
// wording, a third of the ink.
|
||||||
child: Material(
|
child: Material(
|
||||||
color: ColorConstants.primary,
|
color: ColorConstants.pureSurface,
|
||||||
borderRadius: radius,
|
borderRadius: radius,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
@@ -78,10 +104,11 @@ class AcceptedPill extends StatelessWidget {
|
|||||||
// 48 clears the Material touch-target floor without the pill
|
// 48 clears the Material touch-target floor without the pill
|
||||||
// reading as a primary CTA.
|
// reading as a primary CTA.
|
||||||
height: 48.h,
|
height: 48.h,
|
||||||
padding: EdgeInsets.fromLTRB(8.w, 0, 14.w, 0),
|
padding: EdgeInsets.fromLTRB(7.w, 0, 10.w, 0),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
color: ColorConstants.pureSurface,
|
||||||
borderRadius: radius,
|
borderRadius: radius,
|
||||||
boxShadow: DesignConstants.shadowLg,
|
boxShadow: DesignConstants.shadowFloat,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -90,41 +117,52 @@ class AcceptedPill extends StatelessWidget {
|
|||||||
// is the thing the rider is checking from across the
|
// is the thing the rider is checking from across the
|
||||||
// handlebars, and it changes while the words do not.
|
// handlebars, and it changes while the words do not.
|
||||||
Container(
|
Container(
|
||||||
width: 32.w,
|
width: 30.w,
|
||||||
height: 32.w,
|
height: 30.w,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: ColorConstants.pureSurface,
|
color: ColorConstants.primary,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'$count',
|
'$count',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15.sp,
|
fontSize: 14.sp,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
height: 1,
|
height: 1,
|
||||||
letterSpacing: -0.3,
|
letterSpacing: -0.3,
|
||||||
color: ColorConstants.primary,
|
color: ColorConstants.onAccent,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(width: 9.w),
|
SizedBox(width: 9.w),
|
||||||
Text(
|
Text(
|
||||||
count == 1 ? 'booking accepted' : 'bookings accepted',
|
// ── The word has to match the boundary ──
|
||||||
|
//
|
||||||
|
// It said "accepted" on both lines. On a round the
|
||||||
|
// count is `WorkBoundary.deliveryQueue` — work already
|
||||||
|
// **collected** and waiting to be delivered — so a
|
||||||
|
// rider holding twenty-one bags read "21 deliveries
|
||||||
|
// accepted", which is the wrong verb for the wrong
|
||||||
|
// rung and understates what he is carrying.
|
||||||
|
//
|
||||||
|
// Logistics hands over at acceptance, so there the word
|
||||||
|
// is right. Same knob the boundary itself reads.
|
||||||
|
_waiting(profile, count),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13.sp,
|
fontSize: 13.sp,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: -0.1,
|
letterSpacing: -0.1,
|
||||||
color: Colors.white,
|
color: ColorConstants.slateText,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(width: 2.w),
|
SizedBox(width: 2.w),
|
||||||
Icon(
|
Icon(
|
||||||
Icons.chevron_right_rounded,
|
LucideIcons.chevronRight,
|
||||||
size: 20.sp,
|
size: 20.sp,
|
||||||
color: Colors.white,
|
color: ColorConstants.primary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -136,4 +174,19 @@ class AcceptedPill extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The verb for the rung this count actually sits on.
|
||||||
|
///
|
||||||
|
/// The pill counts `WorkBoundary.deliveryQueue`, and where that boundary sits
|
||||||
|
/// is a property of the line — [ServiceProfile.handoffAt]. On a round it is
|
||||||
|
/// **pickup-complete**, so the work is in the rider's hands; on logistics it
|
||||||
|
/// is acceptance, so "accepted" is exactly right. One knob, read here rather
|
||||||
|
/// than a line-name check.
|
||||||
|
static String _verb(ServiceProfile profile) =>
|
||||||
|
profile.handsOffAtCollection ? 'in hand' : 'accepted';
|
||||||
|
|
||||||
|
/// `deliveries in hand` · `bookings accepted`.
|
||||||
|
static String _waiting(ServiceProfile profile, int count) =>
|
||||||
|
'${count == 1 ? profile.jobNoun : profile.jobNounPlural} '
|
||||||
|
'${_verb(profile)}';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,39 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'package:slide_to_submit_button/slide_to_submit_button.dart';
|
|
||||||
|
|
||||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||||
import 'package:miler/views/helpers/widgets/miler_app_bar.dart' show milerGlassSheet;
|
import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
|
||||||
import 'package:miler/views/helpers/widgets/page_transitions.dart';
|
|
||||||
|
|
||||||
/// ─────────────────────────────────────────────────────────────────────────
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
/// GOING ON / OFF DUTY
|
/// GOING ON / OFF DUTY
|
||||||
///
|
///
|
||||||
/// The confirmation behind the duty switch in the app bar. It used to be a
|
/// One decision, asked once.
|
||||||
/// white slab with a 32pt red ✕ floating above a 88pt red circle holding a
|
|
||||||
/// **wifi** glyph, under the heading "Go Online?" and one grey line — "You
|
|
||||||
/// will stop receiving bookings".
|
|
||||||
///
|
///
|
||||||
/// Three things were wrong with that, and none of them was the styling:
|
/// ── What this had accumulated ──
|
||||||
///
|
///
|
||||||
/// • **Wifi is the wrong idea.** Duty is not connectivity. A rider reading a
|
/// A grab handle, a tinted icon disc, a title, a subtitle, a **drawn diagram of
|
||||||
/// wifi symbol on a screen that stops his work is being told the app has a
|
/// the switch moving from one state to the other**, three icon-and-text rows
|
||||||
/// network problem.
|
/// naming the consequences, and a slide-to-confirm. Eight blocks for a question
|
||||||
/// • **It did not say what changes.** "You will stop receiving bookings" is
|
/// with two answers — the shape of a screen assembled from good intentions one
|
||||||
/// one consequence of three. Live tracking stops, and — the one that
|
/// at a time rather than designed.
|
||||||
/// actually catches people out — the app will refuse to take him off duty
|
|
||||||
/// while a booking is still open. He found that out *after* sliding.
|
|
||||||
/// • **It was the only sheet in the app that looked like this.** Every other
|
|
||||||
/// sheet is frosted glass with a grab handle and a small round close
|
|
||||||
/// button; see [milerGlassSheet].
|
|
||||||
///
|
///
|
||||||
/// So: the app's own sheet chrome, the duty glyph the rest of the app uses,
|
/// Each piece was individually defensible and the total was not. The diagram
|
||||||
/// a strip showing the switch moving from one state to the other, and the
|
/// illustrated a state change the title had already stated. The consequence
|
||||||
/// consequences named one per line instead of summarised in a sentence.
|
/// rows were three lines of icons for facts that fit in one sentence. And the
|
||||||
|
/// slide was borrowed from the payment flows, where the app's own rule is that
|
||||||
|
/// **slides are reserved for money** — a rider ending his shift is not
|
||||||
|
/// confirming a transaction, and asking for a drag rather than a tap made the
|
||||||
|
/// commonest action of his day the most laborious.
|
||||||
///
|
///
|
||||||
/// The slide-to-confirm survives both directions. Going off duty mid-shift is
|
/// ── What it is ──
|
||||||
/// worth a deliberate gesture, and a rider whose thumb is wet from the rain
|
///
|
||||||
/// should not be able to end his day with a stray tap.
|
/// The question, one sentence of consequence, and two answers with an obvious
|
||||||
|
/// primary. Nothing is illustrated that is also written. Going off duty is the
|
||||||
|
/// consequential direction, so it carries the brand and states the one thing
|
||||||
|
/// that genuinely catches riders out — the app will not let him off duty while
|
||||||
|
/// a booking is open — in the sentence rather than after a failed gesture.
|
||||||
/// ─────────────────────────────────────────────────────────────────────────
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
class DutySheet extends StatelessWidget {
|
class DutySheet extends StatelessWidget {
|
||||||
/// True when this sheet is asking to go **on** duty.
|
/// True when this sheet is asking to go **on** duty.
|
||||||
@@ -64,15 +62,9 @@ class DutySheet extends StatelessWidget {
|
|||||||
required bool goingOnline,
|
required bool goingOnline,
|
||||||
required Future<bool> Function() onConfirm,
|
required Future<bool> Function() onConfirm,
|
||||||
}) {
|
}) {
|
||||||
return showModalBottomSheet<void>(
|
return showMilerSheet<void>(
|
||||||
context: context,
|
context,
|
||||||
sheetAnimationStyle: kMilerSheetStyle,
|
builder: (_) => DutySheet(goingOnline: goingOnline, onConfirm: onConfirm),
|
||||||
isScrollControlled: true,
|
|
||||||
// The sheet paints its own frosted shape — a white sheet behind it would
|
|
||||||
// show as a square corner behind the rounded one.
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
builder: (_) =>
|
|
||||||
DutySheet(goingOnline: goingOnline, onConfirm: onConfirm),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,395 +79,116 @@ class DutySheet extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SafeArea(
|
// ── The SafeArea used to wrap the GLASS ──
|
||||||
top: false,
|
//
|
||||||
left: false,
|
// So the frosted surface stopped at the top of the gesture inset and the
|
||||||
right: false,
|
// brand-red home indicator area showed through beneath it — a coloured
|
||||||
bottom: true,
|
// stripe under every duty change. The kit pads *inside* the fabric.
|
||||||
child: milerGlassSheet(
|
return MilerSheetScaffold(
|
||||||
child: Padding(
|
padding: EdgeInsets.fromLTRB(22.w, 0, 22.w, 18.h),
|
||||||
padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 20.h),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Center(
|
SizedBox(height: 6.h),
|
||||||
child: Container(
|
|
||||||
width: 40.w,
|
|
||||||
height: 4.h,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: ColorConstants.borderSubtle,
|
|
||||||
borderRadius: BorderRadius.circular(
|
|
||||||
DesignConstants.radiusLg,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: 16.h),
|
|
||||||
_header(context),
|
|
||||||
SizedBox(height: 18.h),
|
|
||||||
// What the switch is about to do, drawn rather than described.
|
|
||||||
_TransitionStrip(goingOnline: goingOnline, accent: _accent),
|
|
||||||
SizedBox(height: 16.h),
|
|
||||||
..._consequences(),
|
|
||||||
SizedBox(height: 20.h),
|
|
||||||
_slider(context),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _header(BuildContext context) {
|
|
||||||
return Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// The duty glyph, not a wifi bar. Same icon the "Go on duty" button on
|
|
||||||
// an empty trip carries, so one idea keeps one symbol.
|
|
||||||
Container(
|
|
||||||
padding: EdgeInsets.all(10.r),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _accent.withValues(alpha: 0.10),
|
|
||||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
Icons.power_settings_new_rounded,
|
|
||||||
color: _accent,
|
|
||||||
size: 20.sp,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(width: 12.w),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
Text(
|
||||||
goingOnline ? 'Go on duty?' : 'Go off duty?',
|
goingOnline ? 'Go on duty?' : 'Go off duty?',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 19.sp,
|
fontSize: 21.sp,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: -0.3,
|
letterSpacing: -0.5,
|
||||||
|
height: 1.2,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
color: ColorConstants.slateText,
|
color: ColorConstants.slateText,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 2.h),
|
SizedBox(height: 8.h),
|
||||||
|
|
||||||
|
// One sentence. The off-duty version carries the fact that used
|
||||||
|
// to be discovered only after a failed slide.
|
||||||
Text(
|
Text(
|
||||||
goingOnline
|
goingOnline
|
||||||
? 'Your hub can start assigning you trips again.'
|
? 'Your hub can add you to the next slot, and live '
|
||||||
: 'Your hub stops assigning you trips until you are back.',
|
'tracking runs while you are on.'
|
||||||
|
: 'Your hub stops assigning you trips and live tracking '
|
||||||
|
'pauses. Any booking you are holding has to be '
|
||||||
|
'finished first.',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12.5.sp,
|
fontSize: 14.5.sp,
|
||||||
height: 1.35,
|
height: 1.45,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w500,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
color: ColorConstants.secondaryText,
|
color: ColorConstants.secondaryText,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SizedBox(height: 24.h),
|
||||||
|
|
||||||
|
_confirm(context),
|
||||||
|
SizedBox(height: 4.h),
|
||||||
|
_dismiss(context),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
SizedBox(width: 10.w),
|
|
||||||
// The same small round dismiss every other sheet uses, in place of the
|
|
||||||
// 32pt red ✕ that used to sit alone on its own row above everything.
|
|
||||||
InkWell(
|
|
||||||
onTap: () => Navigator.of(context).maybePop(),
|
|
||||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
|
||||||
child: Container(
|
|
||||||
padding: EdgeInsets.all(8.r),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: ColorConstants.neutralLight,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
Icons.close_rounded,
|
|
||||||
color: ColorConstants.slateText,
|
|
||||||
size: 18.sp,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One line per thing that actually changes.
|
/// The answer, as a button. Green to start the day, brand to end it — the
|
||||||
///
|
/// same two colours the duty switch itself uses, so the sheet is visibly
|
||||||
/// The third off-duty line is the one the old sheet withheld: the app blocks
|
/// about the control that opened it.
|
||||||
/// the switch while a booking is still open, and it used to say so only
|
Widget _confirm(BuildContext context) {
|
||||||
/// after the rider had completed the slide and watched it bounce back.
|
return SizedBox(
|
||||||
List<Widget> _consequences() {
|
height: ButtonSizes.primary,
|
||||||
final rows = goingOnline
|
child: ElevatedButton(
|
||||||
? const [
|
onPressed: () async {
|
||||||
(Icons.alt_route_rounded, 'Your hub can add you to the next slot'),
|
final changed = await onConfirm();
|
||||||
(
|
if (changed && context.mounted) Navigator.of(context).maybePop();
|
||||||
Icons.my_location_rounded,
|
},
|
||||||
'Live tracking runs while you are on duty',
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: _accent,
|
||||||
|
foregroundColor: ColorConstants.onAccent,
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(ButtonSizes.radius),
|
||||||
),
|
),
|
||||||
]
|
|
||||||
: const [
|
|
||||||
(
|
|
||||||
Icons.notifications_paused_rounded,
|
|
||||||
'No new trips until you are back on',
|
|
||||||
),
|
),
|
||||||
(Icons.location_off_rounded, 'Live tracking pauses'),
|
child: Text(
|
||||||
(
|
goingOnline ? 'Go on duty' : 'Go off duty',
|
||||||
Icons.inventory_2_rounded,
|
style: TextStyle(
|
||||||
'Any booking you are holding must be finished first',
|
fontSize: 16.sp,
|
||||||
),
|
fontWeight: FontWeight.w700,
|
||||||
];
|
letterSpacing: -0.3,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
return [
|
|
||||||
for (var i = 0; i < rows.length; i++) ...[
|
|
||||||
if (i > 0) SizedBox(height: 10.h),
|
|
||||||
_ConsequenceRow(
|
|
||||||
icon: rows[i].$1,
|
|
||||||
label: rows[i].$2,
|
|
||||||
accent: _accent,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _slider(BuildContext context) {
|
|
||||||
final label = goingOnline ? 'Slide to go on duty' : 'Slide to go off duty';
|
|
||||||
|
|
||||||
return SlideToSubmit.custom(
|
|
||||||
height: 58,
|
|
||||||
sliderWidth: 46,
|
|
||||||
padding: const EdgeInsets.all(6),
|
|
||||||
// The track is the accent at low alpha, so the control is the same colour
|
|
||||||
// as the decision. It was a flat grey slab that gave no clue which of the
|
|
||||||
// two things it was about to do.
|
|
||||||
backgroundDecoration: BoxDecoration(
|
|
||||||
color: _accent.withValues(alpha: 0.10),
|
|
||||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
|
||||||
),
|
|
||||||
foregroundDecoration: const BoxDecoration(color: Colors.transparent),
|
|
||||||
slider: Center(
|
|
||||||
child: Container(
|
|
||||||
height: 46,
|
|
||||||
width: 46,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
// A filled knob in the accent, not a white disc with a grey
|
|
||||||
// chevron: on a tinted track the white one read as the empty part.
|
|
||||||
color: _accent,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: _accent.withValues(alpha: 0.30),
|
|
||||||
blurRadius: 10,
|
|
||||||
offset: const Offset(0, 3),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
Icons.arrow_forward_rounded,
|
|
||||||
size: 20.sp,
|
|
||||||
color: ColorConstants.onAccent,
|
color: ColorConstants.onAccent,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Words, not just a drifting arrow. The old hint was an arrow asset and
|
|
||||||
// nothing else, which says "something slides" without saying what
|
|
||||||
// happens when it lands.
|
|
||||||
hint: IgnorePointer(
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
SizedBox(width: 46.0 + 8.w),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14.sp,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
letterSpacing: -0.2,
|
|
||||||
fontFamily: FontConstants.fontFamily,
|
|
||||||
color: _accent,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.only(right: 14.w),
|
|
||||||
// Boxed and clipped. The arrow animates in from `Offset(-2, 0)` —
|
|
||||||
// two of its own widths to the left — so left to itself it drifts
|
|
||||||
// straight across the label and prints an arrow through the
|
|
||||||
// middle of the words. Its own box turns that into what it was
|
|
||||||
// meant to be: an arrow sliding in at the end of the track.
|
|
||||||
child: SizedBox(
|
|
||||||
width: 26.w,
|
|
||||||
child: ClipRect(
|
|
||||||
// The asset is a fixed dark glyph, so it is tinted rather
|
|
||||||
// than dropped: the track is coloured now, and a black arrow
|
|
||||||
// on a green track was the one thing on the control that did
|
|
||||||
// not belong to it.
|
|
||||||
child: ColorFiltered(
|
|
||||||
colorFilter: ColorFilter.mode(
|
|
||||||
_accent.withValues(alpha: 0.55),
|
|
||||||
BlendMode.srcIn,
|
|
||||||
),
|
|
||||||
child: const AnimatedSlideArrow(
|
|
||||||
arrowImage: AssetImage(
|
|
||||||
'assets/arrow_right.png',
|
|
||||||
package: 'slide_to_submit_button',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// The knob springs back whatever happens, and the sheet closes only when
|
|
||||||
// duty actually changed — a failed API call leaves the rider looking at
|
|
||||||
// the decision he was trying to make, not at a screen that has silently
|
|
||||||
// dropped it.
|
|
||||||
onSubmit: (controller) async {
|
|
||||||
final ok = await onConfirm();
|
|
||||||
try {
|
|
||||||
controller.reset();
|
|
||||||
} catch (_) {}
|
|
||||||
if (ok && context.mounted) Navigator.of(context).maybePop();
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// `Off duty ──→ On duty`, as the two states of the switch that opened this.
|
/// The way out, stated as what it leaves him as rather than as "Cancel" — a
|
||||||
///
|
/// rider reading two buttons should not have to work out which one is the
|
||||||
/// A confirmation should show the change, not only name it. The state he is
|
/// no-op.
|
||||||
/// leaving stays grey and the one he is heading for takes the accent, so which
|
Widget _dismiss(BuildContext context) {
|
||||||
/// direction this sheet runs in is legible before a word is read.
|
return SizedBox(
|
||||||
class _TransitionStrip extends StatelessWidget {
|
height: ButtonSizes.secondary,
|
||||||
final bool goingOnline;
|
child: TextButton(
|
||||||
final Color accent;
|
onPressed: () => Navigator.of(context).maybePop(),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
const _TransitionStrip({required this.goingOnline, required this.accent});
|
foregroundColor: ColorConstants.secondaryText,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
@override
|
borderRadius: BorderRadius.circular(ButtonSizes.radius),
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _StateChip(
|
|
||||||
label: goingOnline ? 'Off duty' : 'On duty',
|
|
||||||
color: ColorConstants.secondaryText,
|
|
||||||
filled: false,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 10.w),
|
|
||||||
child: Icon(
|
|
||||||
Icons.arrow_forward_rounded,
|
|
||||||
size: 16.sp,
|
|
||||||
color: ColorConstants.borderStrong,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: _StateChip(
|
|
||||||
label: goingOnline ? 'On duty' : 'Off duty',
|
|
||||||
color: accent,
|
|
||||||
filled: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _StateChip extends StatelessWidget {
|
|
||||||
final String label;
|
|
||||||
final Color color;
|
|
||||||
|
|
||||||
/// The destination state. The one being left is drawn hollow.
|
|
||||||
final bool filled;
|
|
||||||
|
|
||||||
const _StateChip({
|
|
||||||
required this.label,
|
|
||||||
required this.color,
|
|
||||||
required this.filled,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: EdgeInsets.symmetric(vertical: 10.h, horizontal: 12.w),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: filled ? color.withValues(alpha: 0.12) : Colors.transparent,
|
|
||||||
border: Border.all(
|
|
||||||
color: filled ? Colors.transparent : ColorConstants.borderStrong,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 8.w,
|
|
||||||
height: 8.w,
|
|
||||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
|
||||||
),
|
|
||||||
SizedBox(width: 8.w),
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
child: Text(
|
||||||
label,
|
goingOnline ? 'Not yet' : 'Stay on duty',
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13.sp,
|
fontSize: 15.sp,
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
letterSpacing: -0.2,
|
|
||||||
fontFamily: FontConstants.fontFamily,
|
|
||||||
color: filled ? color : ColorConstants.secondaryText,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ConsequenceRow extends StatelessWidget {
|
|
||||||
final IconData icon;
|
|
||||||
final String label;
|
|
||||||
final Color accent;
|
|
||||||
|
|
||||||
const _ConsequenceRow({
|
|
||||||
required this.icon,
|
|
||||||
required this.label,
|
|
||||||
required this.accent,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.only(top: 1.h),
|
|
||||||
child: Icon(icon, size: 17.sp, color: accent.withValues(alpha: 0.75)),
|
|
||||||
),
|
|
||||||
SizedBox(width: 10.w),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13.sp,
|
|
||||||
height: 1.35,
|
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
color: ColorConstants.slateText,
|
color: ColorConstants.secondaryText,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
|
||||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||||
|
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||||
import 'package:miler/widget/Bottom_page.dart';
|
import 'package:miler/widget/Bottom_page.dart';
|
||||||
import 'package:miler/Models/stop_status.dart';
|
import 'package:miler/Models/stop_status.dart';
|
||||||
@@ -29,6 +31,15 @@ import 'package:miler/views/helpers/constants/design_constants.dart';
|
|||||||
/// So the banner lifts itself clear by [BottomPage.bottomInset]. Both call sites
|
/// So the banner lifts itself clear by [BottomPage.bottomInset]. Both call sites
|
||||||
/// just pin it to `bottom: 0` and let it place itself.
|
/// just pin it to `bottom: 0` and let it place itself.
|
||||||
class ActivePickupBanner extends StatefulWidget {
|
class ActivePickupBanner extends StatefulWidget {
|
||||||
|
/// The tallest this banner gets, excluding the device inset.
|
||||||
|
///
|
||||||
|
/// Declared here because the surface that overlays a list is the only thing
|
||||||
|
/// that knows how much of the list it covers. The Deliveries queue reserved
|
||||||
|
/// a flat `80.h` for it — a number measured once, by eye, that every later
|
||||||
|
/// change to this widget made wrong silently by hiding the last row of the
|
||||||
|
/// run behind it.
|
||||||
|
static double get maxHeight => ButtonSizes.primary + 46.h;
|
||||||
|
|
||||||
final List<Map<String, dynamic>> activePickups;
|
final List<Map<String, dynamic>> activePickups;
|
||||||
final void Function(Map<String, dynamic>) onTap;
|
final void Function(Map<String, dynamic>) onTap;
|
||||||
|
|
||||||
@@ -166,11 +177,7 @@ class _Card extends StatelessWidget {
|
|||||||
color: ColorConstants.pureSurface.withValues(alpha: 0.16),
|
color: ColorConstants.pureSurface.withValues(alpha: 0.16),
|
||||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(LucideIcons.bike, color: Colors.white, size: 22.sp),
|
||||||
Icons.two_wheeler_rounded,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 22.sp,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
SizedBox(width: 11.w),
|
SizedBox(width: 11.w),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -196,7 +203,7 @@ class _Card extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 15.sp,
|
fontSize: 15.sp,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: -0.3,
|
letterSpacing: -0.3,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
),
|
),
|
||||||
@@ -208,7 +215,7 @@ class _Card extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.place_rounded,
|
LucideIcons.mapPin,
|
||||||
color: Colors.white.withValues(alpha: 0.72),
|
color: Colors.white.withValues(alpha: 0.72),
|
||||||
size: 13.sp,
|
size: 13.sp,
|
||||||
),
|
),
|
||||||
@@ -244,7 +251,7 @@ class _Card extends StatelessWidget {
|
|||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.arrow_forward_rounded,
|
LucideIcons.arrowRight,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
size: 16.sp,
|
size: 16.sp,
|
||||||
),
|
),
|
||||||
@@ -312,11 +319,11 @@ class _LiveChip extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
SizedBox(width: 5.w),
|
SizedBox(width: 5.w),
|
||||||
Text(
|
Text(
|
||||||
'LIVE',
|
'Active',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 9.sp,
|
fontSize: 9.sp,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 0.7,
|
letterSpacing: 0.7,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
),
|
),
|
||||||
|
|||||||
380
lib/views/Dashboard/home/pickup_preview_sheet.dart
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
import 'package:latlong2/latlong.dart' show LatLng;
|
||||||
|
|
||||||
|
import 'package:miler/data/bag_manifest.dart';
|
||||||
|
import 'package:miler/views/helpers/constants/miler_surface.dart';
|
||||||
|
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
|
||||||
|
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||||||
|
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||||
|
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||||
|
import 'package:miler/views/helpers/constants/miler_type.dart';
|
||||||
|
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||||
|
import 'package:miler/views/helpers/widgets/miler_map.dart';
|
||||||
|
import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
|
||||||
|
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// PICKUP PREVIEW — where am I going, and what am I collecting there
|
||||||
|
///
|
||||||
|
/// ── Why this exists ──
|
||||||
|
///
|
||||||
|
/// `Navigate to pickup` used to hand straight off to Google Maps. That is one
|
||||||
|
/// tap too few. Handing off means the rider **leaves Miler** — his route, his
|
||||||
|
/// bag counts and his manifest all go behind another app — and he has to make
|
||||||
|
/// that commitment on the strength of a kitchen's name and a straight-line
|
||||||
|
/// distance. If the hub has routed him somewhere he does not recognise, he finds
|
||||||
|
/// out inside a different app, already navigating.
|
||||||
|
///
|
||||||
|
/// So the button opens a preview first: the leg he is about to ride, drawn from
|
||||||
|
/// where he is standing to where he is going, with the load he will pick up when
|
||||||
|
/// he arrives. He confirms, and *then* Miler hands off.
|
||||||
|
///
|
||||||
|
/// It is the same surface the card's own tap opens, because they answer the same
|
||||||
|
/// question — "what is this place?" — and a screen that answered it two
|
||||||
|
/// different ways depending on which part of a card you touched would be worse
|
||||||
|
/// than one that answers it once.
|
||||||
|
///
|
||||||
|
/// ── What is on it ──
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// ┌───────────────────────────────────────┐
|
||||||
|
/// │ ● ─────────── ▼ │ ← the leg: him, the kitchen
|
||||||
|
/// └───────────────────────────────────────┘
|
||||||
|
/// Vidhya Kitchen
|
||||||
|
/// ➤ 3.1 km ◷ ~9 min
|
||||||
|
///
|
||||||
|
/// 5 orders · 5 bags
|
||||||
|
/// ○ Joe Mathew Bag 1
|
||||||
|
/// ○ Arun Prakash Bag 2
|
||||||
|
/// …
|
||||||
|
///
|
||||||
|
/// [ ➤ Start navigation ] [ 📞 ]
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The manifest is read straight off [BagManifest] — the same source as the
|
||||||
|
/// card, the confirmation sheet and the row, so the number he checks against the
|
||||||
|
/// shelf cannot differ between the screen he decided on and the screen he
|
||||||
|
/// confirms on. **One order is one bag**, here as everywhere.
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
class PickupPreviewSheet extends StatelessWidget {
|
||||||
|
/// The place, as the card knows it.
|
||||||
|
final String placeName;
|
||||||
|
|
||||||
|
/// Every order the rider collects here, in route order.
|
||||||
|
final List<BagLine> lines;
|
||||||
|
|
||||||
|
/// Straight-line metres from the rider to the place. Null when unknown —
|
||||||
|
/// nothing is invented, exactly as on the card.
|
||||||
|
final double? meters;
|
||||||
|
|
||||||
|
/// Ride time to the place. Zero renders nothing.
|
||||||
|
final Duration ride;
|
||||||
|
|
||||||
|
/// Where the rider is. Null draws the destination alone rather than a leg
|
||||||
|
/// starting from a guess.
|
||||||
|
final double? riderLat;
|
||||||
|
final double? riderLng;
|
||||||
|
|
||||||
|
/// Hands off to the phone's navigation app. Null hides the action — a stop
|
||||||
|
/// with no coordinates has nothing to navigate to.
|
||||||
|
final VoidCallback? onNavigate;
|
||||||
|
|
||||||
|
/// Rings the place. Null hides the action.
|
||||||
|
final VoidCallback? onCall;
|
||||||
|
|
||||||
|
const PickupPreviewSheet({
|
||||||
|
super.key,
|
||||||
|
required this.placeName,
|
||||||
|
required this.lines,
|
||||||
|
required this.meters,
|
||||||
|
required this.ride,
|
||||||
|
this.riderLat,
|
||||||
|
this.riderLng,
|
||||||
|
this.onNavigate,
|
||||||
|
this.onCall,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Opens the sheet. Kept here so callers do not each re-declare the shape.
|
||||||
|
static Future<void> show(
|
||||||
|
BuildContext context, {
|
||||||
|
required String placeName,
|
||||||
|
required List<BagLine> lines,
|
||||||
|
required double? meters,
|
||||||
|
required Duration ride,
|
||||||
|
double? riderLat,
|
||||||
|
double? riderLng,
|
||||||
|
VoidCallback? onNavigate,
|
||||||
|
VoidCallback? onCall,
|
||||||
|
}) {
|
||||||
|
return showMilerSheet<void>(
|
||||||
|
context,
|
||||||
|
large: true,
|
||||||
|
builder: (_) => PickupPreviewSheet(
|
||||||
|
placeName: placeName,
|
||||||
|
lines: lines,
|
||||||
|
meters: meters,
|
||||||
|
ride: ride,
|
||||||
|
riderLat: riderLat,
|
||||||
|
riderLng: riderLng,
|
||||||
|
onNavigate: onNavigate,
|
||||||
|
onCall: onCall,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The place's own coordinates, taken from the first order collected there —
|
||||||
|
/// they all share one counter, so they all share one pin.
|
||||||
|
({double lat, double lng})? get _place {
|
||||||
|
if (lines.isEmpty) return null;
|
||||||
|
final stop = lines.first.stop;
|
||||||
|
final lat = _d(stop['pickuplat'] ?? stop['PickupLat']);
|
||||||
|
final lng = _d(stop['pickuplon'] ?? stop['PickupLon']);
|
||||||
|
return (lat == 0 || lng == 0) ? null : (lat: lat, lng: lng);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _d(dynamic v) {
|
||||||
|
if (v == null) return 0;
|
||||||
|
if (v is num) return v.toDouble();
|
||||||
|
return double.tryParse(v.toString().trim()) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final place = _place;
|
||||||
|
|
||||||
|
// The kit owns fabric, handle and insets; this sheet owns only content.
|
||||||
|
// It was one of the two opaque holdouts — see the sheet kit's audit.
|
||||||
|
return MilerSheetScaffold(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (place != null) ...[_map(place), SizedBox(height: 16.h)],
|
||||||
|
Text(
|
||||||
|
placeName,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: MilerType.pageTitle,
|
||||||
|
),
|
||||||
|
SizedBox(height: 8.h),
|
||||||
|
_journey(),
|
||||||
|
SizedBox(height: 18.h),
|
||||||
|
_manifest(),
|
||||||
|
SizedBox(height: 18.h),
|
||||||
|
_actions(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The leg, drawn once. Him, the counter, and the line between them.
|
||||||
|
///
|
||||||
|
/// Deliberately not interactive and deliberately not a navigation surface:
|
||||||
|
/// this is an *orientation* aid answering "which way is that, and is it far".
|
||||||
|
/// Turn-by-turn is the phone's job and the button below hands off to it.
|
||||||
|
Widget _map(({double lat, double lng}) place) {
|
||||||
|
final placePos = LatLng(place.lat, place.lng);
|
||||||
|
final hasRider =
|
||||||
|
riderLat != null && riderLng != null && riderLat != 0 && riderLng != 0;
|
||||||
|
final riderPos = hasRider ? LatLng(riderLat!, riderLng!) : null;
|
||||||
|
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 190.h,
|
||||||
|
// Held back until the sheet has finished rising: a platform map view
|
||||||
|
// costs the UI thread a couple of hundred milliseconds to build, and in
|
||||||
|
// the same frame as the entrance it swallows the animation whole. See
|
||||||
|
// [AfterEntrance].
|
||||||
|
child: AfterEntrance(
|
||||||
|
placeholder: MapPlaceholder(radius: 16.r),
|
||||||
|
// The real road, both ends pinned, camera fitted to the pair — the
|
||||||
|
// straight solid stroke this drew was geometry no road follows, and
|
||||||
|
// a fixed zoom hid the half of the journey the rider is standing
|
||||||
|
// in. See [MilerLegMap].
|
||||||
|
builder: (context) => MilerLegMap(
|
||||||
|
to: placePos,
|
||||||
|
from: riderPos,
|
||||||
|
accent: ColorConstants.primary,
|
||||||
|
icon: StopKind.pickup.icon,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `➤ 3.1 km ◷ ~9 min` — the same pair, the same glyphs and the same rule
|
||||||
|
/// as the card this opened from. Absent halves are absent, never guessed.
|
||||||
|
Widget _journey() {
|
||||||
|
final distance = (meters != null && meters! > 0)
|
||||||
|
? RouteMetricsHelper.formatDistance(meters)
|
||||||
|
: null;
|
||||||
|
final time = ride > Duration.zero
|
||||||
|
? '~${RouteMetricsHelper.formatDuration(ride)}'
|
||||||
|
: null;
|
||||||
|
if (distance == null && time == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
// Flexible, because rigid text in a Row is a right-edge overflow waiting
|
||||||
|
// for a large text scale — at 2.0x this pair ran 72px past the sheet.
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
if (distance != null) ...[
|
||||||
|
Icon(
|
||||||
|
LucideIcons.navigation,
|
||||||
|
size: 16.sp,
|
||||||
|
color: ColorConstants.secondaryText,
|
||||||
|
),
|
||||||
|
SizedBox(width: 6.w),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
distance,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: MilerType.figure(17, color: ColorConstants.slateText),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (distance != null && time != null) SizedBox(width: 18.w),
|
||||||
|
if (time != null) ...[
|
||||||
|
Icon(
|
||||||
|
LucideIcons.clock,
|
||||||
|
size: 16.sp,
|
||||||
|
color: ColorConstants.secondaryText,
|
||||||
|
),
|
||||||
|
SizedBox(width: 6.w),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
time,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: MilerType.label.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: ColorConstants.secondaryText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What he is collecting, and which bag each order travels in.
|
||||||
|
///
|
||||||
|
/// Capped and scrollable rather than unbounded: a counter can hand over
|
||||||
|
/// fifteen orders, and a sheet that grows with the manifest pushes its own
|
||||||
|
/// actions off the bottom of the screen — the one thing this surface exists to
|
||||||
|
/// offer.
|
||||||
|
Widget _manifest() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
BagManifest.countLabel(lines.length),
|
||||||
|
style: MilerType.body.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: ColorConstants.slateText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 10.h),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxHeight: 190.h),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
for (final line in lines)
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 7.h),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 7.w,
|
||||||
|
height: 7.w,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: ColorConstants.borderStrong,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 12.w),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
line.customer.isEmpty ? 'Order' : line.customer,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: MilerType.body.copyWith(
|
||||||
|
fontSize: 15.sp,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: ColorConstants.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 10.w),
|
||||||
|
// Ranked under the name, exactly as on the route
|
||||||
|
// timeline: w700 slate against a w500 mid-tone name
|
||||||
|
// made a column of shelf references the first thing
|
||||||
|
// read on a manifest of people.
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 7.w,
|
||||||
|
vertical: 3.h,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: MilerSurface.canvas,
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
DesignConstants.radiusLg,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
line.bag,
|
||||||
|
maxLines: 1,
|
||||||
|
style: MilerType.label.copyWith(
|
||||||
|
fontSize: 12.sp,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: ColorConstants.secondaryText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One primary action and one secondary, in the thumb zone.
|
||||||
|
///
|
||||||
|
/// "Start navigation" rather than "Navigate to pickup": by this point the
|
||||||
|
/// rider is looking at the leg, so the label's job is no longer to say where —
|
||||||
|
/// it is to say that pressing it *leaves Miler*.
|
||||||
|
Widget _actions() {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
if (onNavigate != null)
|
||||||
|
Expanded(
|
||||||
|
child: MilerButton(
|
||||||
|
label: 'Start navigation',
|
||||||
|
icon: LucideIcons.navigation,
|
||||||
|
height: ButtonSizes.primary,
|
||||||
|
onPressed: onNavigate,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (onNavigate != null && onCall != null) SizedBox(width: 10.w),
|
||||||
|
if (onCall != null)
|
||||||
|
MilerIconButton(
|
||||||
|
icon: LucideIcons.phone,
|
||||||
|
color: ColorConstants.acceptGreen,
|
||||||
|
size: ButtonSizes.primary,
|
||||||
|
semanticLabel: 'Call this pickup location',
|
||||||
|
tonal: true,
|
||||||
|
onPressed: onCall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
|
||||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||||
@@ -355,18 +356,14 @@ class ShiftBanner extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(LucideIcons.clock, color: ColorConstants.primary, size: 15.sp),
|
||||||
Icons.access_time_rounded,
|
|
||||||
color: ColorConstants.primary,
|
|
||||||
size: 15.sp,
|
|
||||||
),
|
|
||||||
SizedBox(width: 7.w),
|
SizedBox(width: 7.w),
|
||||||
Text(
|
Text(
|
||||||
brief.shiftWindowLabel,
|
brief.shiftWindowLabel,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: ColorConstants.slateText,
|
color: ColorConstants.slateText,
|
||||||
fontSize: 12.5.sp,
|
fontSize: 12.5.sp,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w600,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -381,7 +378,7 @@ class ShiftBanner extends StatelessWidget {
|
|||||||
? ColorConstants.warning
|
? ColorConstants.warning
|
||||||
: ColorConstants.secondaryText),
|
: ColorConstants.secondaryText),
|
||||||
fontSize: 11.5.sp,
|
fontSize: 11.5.sp,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w600,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
3163
lib/views/Dashboard/home/route_timeline.dart
Normal file
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
|
||||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||||
@@ -39,7 +40,79 @@ import 'package:miler/widget/Bottom_page.dart';
|
|||||||
/// untick each stop by hand, which on a six-stop route is six taps to undo one
|
/// untick each stop by hand, which on a six-stop route is six taps to undo one
|
||||||
/// mistake.
|
/// mistake.
|
||||||
/// ─────────────────────────────────────────────────────────────────────────
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// What the ticked stops are ready for.
|
||||||
|
///
|
||||||
|
/// A service rider works a kitchen as a batch: he ticks the five parcels that
|
||||||
|
/// come from it, accepts them, walks in, slides *arrived*, is handed the crate,
|
||||||
|
/// slides *picked up*. One gesture per rung for the whole set rather than five
|
||||||
|
/// gestures per rung — the five bags arrived as one handover and the control
|
||||||
|
/// should match what happened.
|
||||||
|
enum SelectionAction {
|
||||||
|
/// Take these stops on.
|
||||||
|
accept,
|
||||||
|
|
||||||
|
/// Say he has reached the source.
|
||||||
|
arrive,
|
||||||
|
|
||||||
|
/// Say the parcels are in his box. The last rung on Home.
|
||||||
|
pickUp,
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SelectionActionX on SelectionAction {
|
||||||
|
/// The words on the control, in the rider's voice.
|
||||||
|
///
|
||||||
|
/// "Mark as…" rather than "I've…": these two are *claims recorded against a
|
||||||
|
/// place*, and the rider is not talking to the app, he is marking a set of
|
||||||
|
/// orders. The wording also matches what the sheet behind it then asks him
|
||||||
|
/// to confirm, so the button and the sheet read as one action rather than
|
||||||
|
/// two.
|
||||||
|
String get label => switch (this) {
|
||||||
|
SelectionAction.accept => 'Accept',
|
||||||
|
SelectionAction.arrive => 'Mark as Arrived',
|
||||||
|
SelectionAction.pickUp => 'Mark as Picked',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The status token this rung writes.
|
||||||
|
String get status => switch (this) {
|
||||||
|
SelectionAction.accept => 'ACCEPTED',
|
||||||
|
SelectionAction.arrive => 'ARRIVED',
|
||||||
|
SelectionAction.pickUp => 'PICKED',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Accepting commits from the bar; the two that move real parcels go through
|
||||||
|
/// a sheet with a slide in it.
|
||||||
|
///
|
||||||
|
/// Not decoration. "Picked" is the rider asserting he is holding somebody's
|
||||||
|
/// lunch, and it is the last thing standing between the order and a delivery
|
||||||
|
/// list — worth a deliberate gesture, and worth taking his eyes off a list of
|
||||||
|
/// cards for the second it takes. Accepting is reversible and happens in bulk
|
||||||
|
/// at the start of a shift, so it stays a tap.
|
||||||
|
///
|
||||||
|
/// The slide used to be *on* this bar, inline. It lost: a 44dp-high track
|
||||||
|
/// squeezed between a ✕ and the screen edge is a gesture the rider misses
|
||||||
|
/// with a thumb on a moving bike, and there was no room left to say what he
|
||||||
|
/// was confirming. The bar now says what will happen and [StopActionSheet]
|
||||||
|
/// gives the gesture a whole surface — see the note there.
|
||||||
|
bool get confirmsInSheet => this != SelectionAction.accept;
|
||||||
|
}
|
||||||
|
|
||||||
class SelectionBar extends StatelessWidget {
|
class SelectionBar extends StatelessWidget {
|
||||||
|
/// The tallest this bar gets, excluding the device inset.
|
||||||
|
///
|
||||||
|
/// ── Why this is declared rather than guessed ──
|
||||||
|
///
|
||||||
|
/// Home reserved `80.h` of scroll extent to clear whatever floats over it.
|
||||||
|
/// That number was measured once, by eye, against one of the two surfaces
|
||||||
|
/// that can occupy the slot — and every later change to either (a second
|
||||||
|
/// line of copy, a taller button, a text scale the tester did not try) made
|
||||||
|
/// it wrong silently, by hiding the end of the route under the bar.
|
||||||
|
///
|
||||||
|
/// A control that overlays a list is the only thing that knows how much of
|
||||||
|
/// the list it covers, so it says so. `ButtonSizes.primary` is the action
|
||||||
|
/// row; the rest is this widget's own vertical padding, and the two-line
|
||||||
|
/// accessibility layout is covered because the button grows with it.
|
||||||
|
static double get maxHeight => ButtonSizes.primary + 28.h;
|
||||||
|
|
||||||
/// How many stops are ticked. Zero renders nothing.
|
/// How many stops are ticked. Zero renders nothing.
|
||||||
final int count;
|
final int count;
|
||||||
|
|
||||||
@@ -56,6 +129,14 @@ class SelectionBar extends StatelessWidget {
|
|||||||
/// True while the accept is in flight.
|
/// True while the accept is in flight.
|
||||||
final bool busy;
|
final bool busy;
|
||||||
|
|
||||||
|
/// Which rung the ticked stops are on. Anything past [SelectionAction.accept]
|
||||||
|
/// swaps the buttons for a slide and drops Reject — a stop already taken on
|
||||||
|
/// cannot be declined from here.
|
||||||
|
final SelectionAction action;
|
||||||
|
|
||||||
|
/// Runs the rung named by [action]. Falls back to [onAccept] when null.
|
||||||
|
final Future<void> Function()? onAdvance;
|
||||||
|
|
||||||
const SelectionBar({
|
const SelectionBar({
|
||||||
super.key,
|
super.key,
|
||||||
required this.count,
|
required this.count,
|
||||||
@@ -63,6 +144,8 @@ class SelectionBar extends StatelessWidget {
|
|||||||
required this.onClear,
|
required this.onClear,
|
||||||
this.onReject,
|
this.onReject,
|
||||||
this.busy = false,
|
this.busy = false,
|
||||||
|
this.action = SelectionAction.accept,
|
||||||
|
this.onAdvance,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -88,23 +171,72 @@ class SelectionBar extends StatelessWidget {
|
|||||||
// read that way against a list of white cards.
|
// read that way against a list of white cards.
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: EdgeInsets.fromLTRB(6.w, 8.h, 10.w, 8.h),
|
// 8 → 6. The bar is a *command surface*, not a card: what should
|
||||||
|
// read as generous is the buttons on it, not the air around them.
|
||||||
|
padding: EdgeInsets.fromLTRB(6.w, 6.h, 8.w, 6.h),
|
||||||
|
// Borderless, like every other surface on this screen: the lift is
|
||||||
|
// what says "this is over the list", and a hairline under a shadow
|
||||||
|
// only muddies the edge.
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: ColorConstants.pureSurface,
|
color: ColorConstants.pureSurface,
|
||||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
borderRadius: BorderRadius.circular(22),
|
||||||
border: Border.all(color: ColorConstants.borderSubtle),
|
boxShadow: DesignConstants.shadowFloat,
|
||||||
boxShadow: DesignConstants.shadowLg,
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: action.confirmsInSheet
|
||||||
|
// ── The rung, as one big button ──
|
||||||
|
//
|
||||||
|
// At this point in the shift the rider is standing at a counter
|
||||||
|
// holding a crate, and there is exactly one thing to press.
|
||||||
|
// Nothing shares the bar with it: no Reject (these orders are
|
||||||
|
// already his), no running-count sentence (the count is on the
|
||||||
|
// button), no slide track squeezed to whatever width was left.
|
||||||
|
//
|
||||||
|
// It is a full-width control because it is pressed with a thumb,
|
||||||
|
// one-handed, without looking — and because the gesture that
|
||||||
|
// actually commits is one layer away, in the sheet it opens, so
|
||||||
|
// this press is safe to make easy.
|
||||||
|
//
|
||||||
|
// 56 → 48. At 56 inside an 8pt-padded bar the whole thing stood
|
||||||
|
// 72pt off the nav bar and covered the last two rows of the
|
||||||
|
// route it was acting on — a command surface must not hide its
|
||||||
|
// own subject. 48 is the same button one size down the app's
|
||||||
|
// own scale, still 20pt above the tap-target floor, and it
|
||||||
|
// matches the height Reject and Accept use on the other branch
|
||||||
|
// so the bar does not change stature between rungs.
|
||||||
|
? Row(
|
||||||
|
children: [
|
||||||
|
_ClearButton(onTap: busy ? null : onClear),
|
||||||
|
SizedBox(width: 6.w),
|
||||||
|
Expanded(
|
||||||
|
child: MilerButton(
|
||||||
|
// `Mark as Arrived (3)`. The count in brackets, not
|
||||||
|
// after a middot: a dotted suffix reads as a second
|
||||||
|
// fact about the action, and this is the action's
|
||||||
|
// own object — how many stops the press closes.
|
||||||
|
label: '${action.label} ($count)',
|
||||||
|
icon: action == SelectionAction.arrive
|
||||||
|
? LucideIcons.mapPinCheck
|
||||||
|
: LucideIcons.shoppingBag,
|
||||||
|
color: action == SelectionAction.arrive
|
||||||
|
? ColorConstants.primary
|
||||||
|
: ColorConstants.acceptGreen,
|
||||||
|
height: ButtonSizes.secondary,
|
||||||
|
loading: busy,
|
||||||
|
onPressed: busy ? null : () => onAdvance?.call(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
children: [
|
children: [
|
||||||
_ClearButton(onTap: busy ? null : onClear),
|
_ClearButton(onTap: busy ? null : onClear),
|
||||||
SizedBox(width: 2.w),
|
SizedBox(width: 2.w),
|
||||||
// The running count, in words — but only when there is room for
|
// The running count, in words — but only when there is
|
||||||
// it. With two decisions on the bar there is not: at 320pt the
|
// room for it. With two decisions on the bar there is
|
||||||
// three fixed controls plus this line overflowed by 94pt, and
|
// not: at 320pt the three fixed controls plus this line
|
||||||
// "Accept 3" already carries the number. So the sentence is a
|
// overflowed by 94pt, and "Accept 3" already carries the
|
||||||
// luxury for the one-button case and the space goes to the
|
// number. So the sentence is a luxury for the one-button
|
||||||
// buttons otherwise.
|
// case and the space goes to the buttons otherwise.
|
||||||
if (onReject == null)
|
if (onReject == null)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -113,7 +245,7 @@ class SelectionBar extends StatelessWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13.5.sp,
|
fontSize: 13.5.sp,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: -0.2,
|
letterSpacing: -0.2,
|
||||||
color: ColorConstants.slateText,
|
color: ColorConstants.slateText,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
@@ -125,23 +257,26 @@ class SelectionBar extends StatelessWidget {
|
|||||||
SizedBox(width: 6.w),
|
SizedBox(width: 6.w),
|
||||||
// ── Both decisions, on the bar ──
|
// ── Both decisions, on the bar ──
|
||||||
//
|
//
|
||||||
// Reject used to live on every stop row and went when the rows
|
// Reject used to live on every stop row and went when the
|
||||||
// became tick-only. That left the rider able to say yes to a
|
// rows became tick-only. That left the rider able to say
|
||||||
// selection and nothing else: declining a stop he could not do
|
// yes to a selection and nothing else: declining a stop he
|
||||||
// — shop shut, address wrong — had no route at all, and the hub
|
// could not do — shop shut, address wrong — had no route
|
||||||
// learned about it by the stop simply never being accepted.
|
// at all, and the hub learned about it by the stop simply
|
||||||
|
// never being accepted.
|
||||||
//
|
//
|
||||||
// So it comes back where Accept is, on the set he has picked.
|
// So it comes back where Accept is, on the set he has
|
||||||
// Outlined and unlabelled-by-count against Accept's filled
|
// picked. Outlined against Accept's filled green: they are
|
||||||
// green: they are not equal choices, and the one that ends work
|
// not equal choices, and the one that ends work should not
|
||||||
// should not look like the one that starts it.
|
// look like the one that starts it.
|
||||||
if (onReject != null) ...[
|
if (onReject != null) ...[
|
||||||
Flexible(
|
// Capped, not flexed. [MilerButton] fills whatever box
|
||||||
|
// it is given, so these are the widths the two labels
|
||||||
|
// actually need — anything larger and the pair overflows
|
||||||
|
// the bar on a 390pt phone, anything flexible and the
|
||||||
|
// Spacer steals half.
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxWidth: 104.w),
|
||||||
child: MilerButton(
|
child: MilerButton(
|
||||||
// No icon, and flexible: the width an icon costs is width
|
|
||||||
// the two labels need side by side on a 320pt phone, and
|
|
||||||
// under real pressure the pair should give ground evenly
|
|
||||||
// rather than run off the edge of the bar.
|
|
||||||
label: 'Reject',
|
label: 'Reject',
|
||||||
variant: MilerButtonVariant.outlined,
|
variant: MilerButtonVariant.outlined,
|
||||||
color: ColorConstants.errorRed,
|
color: ColorConstants.errorRed,
|
||||||
@@ -152,13 +287,15 @@ class SelectionBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
SizedBox(width: 8.w),
|
SizedBox(width: 8.w),
|
||||||
],
|
],
|
||||||
// Content-hugging, so the count on the left keeps its room on a
|
// Content-hugging, so the count on the left keeps its room
|
||||||
// narrow phone and the label never ellipsises — a bulk action
|
// on a narrow phone and the label never ellipsises — a
|
||||||
// that reads "Accept 3 sto…" is not one the rider will trust.
|
// bulk action reading "Accept 3 sto…" is not one the rider
|
||||||
Flexible(
|
// will trust.
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxWidth: 142.w),
|
||||||
child: MilerButton(
|
child: MilerButton(
|
||||||
label: 'Accept $count',
|
label: 'Accept ($count)',
|
||||||
icon: Icons.done_all_rounded,
|
icon: LucideIcons.checkCheck,
|
||||||
color: ColorConstants.acceptGreen,
|
color: ColorConstants.acceptGreen,
|
||||||
height: ButtonSizes.secondary,
|
height: ButtonSizes.secondary,
|
||||||
expand: false,
|
expand: false,
|
||||||
@@ -191,7 +328,7 @@ class _ClearButton extends StatelessWidget {
|
|||||||
width: ButtonSizes.minTapTarget,
|
width: ButtonSizes.minTapTarget,
|
||||||
height: ButtonSizes.minTapTarget,
|
height: ButtonSizes.minTapTarget,
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.close_rounded,
|
LucideIcons.x,
|
||||||
size: 20.sp,
|
size: 20.sp,
|
||||||
color: ColorConstants.secondaryText,
|
color: ColorConstants.secondaryText,
|
||||||
),
|
),
|
||||||
|
|||||||
815
lib/views/Dashboard/home/stop_action_sheet.dart
Normal file
@@ -0,0 +1,815 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:slide_to_submit_button/slide_to_submit_button.dart';
|
||||||
|
|
||||||
|
import 'package:miler/Models/stop_status.dart';
|
||||||
|
import 'package:miler/views/helpers/constants/miler_surface.dart';
|
||||||
|
import 'package:miler/data/bag_manifest.dart';
|
||||||
|
import 'package:miler/data/service_profile.dart';
|
||||||
|
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||||
|
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||||
|
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||||
|
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||||
|
import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
|
||||||
|
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// THE RUNG SHEET — one deliberate gesture, at the place it describes.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// ┌─────────────────────────────────┐
|
||||||
|
/// │ ARRIVED AT VIDHYA KITCHEN │
|
||||||
|
/// │ 3 orders to collect here │
|
||||||
|
/// │ │
|
||||||
|
/// │ ⟶ Slide to update │
|
||||||
|
/// └─────────────────────────────────┘
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ── Why a sheet and not a button on the row ──
|
||||||
|
///
|
||||||
|
/// Arriving and collecting are claims about the physical world: *I am at this
|
||||||
|
/// counter*, *this bag is in my box*. They are written to the hub, they move
|
||||||
|
/// money and food, and a stray thumb on a moving bike should not be able to
|
||||||
|
/// make them. The rest of this app already reserves the slide gesture for
|
||||||
|
/// exactly that class of statement, and a sheet is what gives the slide room
|
||||||
|
/// and takes the rider's attention off the list for the second it takes.
|
||||||
|
///
|
||||||
|
/// The batch ladder on the selection bar does the same job for a set the rider
|
||||||
|
/// ticked. This is the single-stop path: he is standing at one counter with one
|
||||||
|
/// card open, and making him tick it first to reach a control that then acts on
|
||||||
|
/// "the selection" is a detour around what he is looking at.
|
||||||
|
///
|
||||||
|
/// ── What it says ──
|
||||||
|
///
|
||||||
|
/// The heading names the **place**, not the status. "Arrived at Vidhya Kitchen"
|
||||||
|
/// tells him what he is confirming; "Update status" tells him nothing he could
|
||||||
|
/// be wrong about. Under it, the count of what that confirmation covers.
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// What the rider actually confirmed, once the slide lands.
|
||||||
|
///
|
||||||
|
/// A rung used to be a yes/no: he slid, and every order in the batch moved. It
|
||||||
|
/// is not, at a kitchen hatch — the counter hands over four bags out of five
|
||||||
|
/// often enough that the app needs somewhere to say so, and the alternative was
|
||||||
|
/// a *second* bulk flow with its own tick-list living beside this one. The two
|
||||||
|
/// have been folded together: this sheet is the only place a pickup is
|
||||||
|
/// confirmed, and this is what it reports back.
|
||||||
|
class RungOutcome {
|
||||||
|
/// Orders the counter could not supply. They do **not** move up the ladder —
|
||||||
|
/// they leave the route with their own record. See [MilerApi] callers.
|
||||||
|
final List<String> missingOrderIds;
|
||||||
|
|
||||||
|
/// One photo of the whole load, if he took one. Never per bag: a rider at a
|
||||||
|
/// hatch with fifteen boxes cannot photograph each one, and the question
|
||||||
|
/// anybody asks afterwards is "what was in the crate when it left?".
|
||||||
|
final String? photoPath;
|
||||||
|
|
||||||
|
const RungOutcome({this.missingOrderIds = const [], this.photoPath});
|
||||||
|
|
||||||
|
bool get isClean => missingOrderIds.isEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
class StopActionSheet extends StatefulWidget {
|
||||||
|
/// The rung being confirmed. Only [StopStatus.accepted] (→ arrived) and
|
||||||
|
/// [StopStatus.arrived] (→ picked) open this sheet.
|
||||||
|
final StopStatus stage;
|
||||||
|
|
||||||
|
/// Where he is — the kitchen for a pickup rung, the customer for a drop.
|
||||||
|
final String placeName;
|
||||||
|
|
||||||
|
/// How many orders this confirmation covers at that place.
|
||||||
|
final int count;
|
||||||
|
|
||||||
|
/// The orders this covers, each with the bag it travels in.
|
||||||
|
///
|
||||||
|
/// ── Why the sheet lists them ──
|
||||||
|
///
|
||||||
|
/// This is the moment the rider is standing at a counter with bags in front
|
||||||
|
/// of him, and the only question that matters is *are these the right ones,
|
||||||
|
/// and are they all here*. A number alone cannot be checked against a shelf;
|
||||||
|
/// five named lines with a bag each can, and a missing sixth is visible
|
||||||
|
/// before he rides away rather than at a door twenty minutes later.
|
||||||
|
///
|
||||||
|
/// Empty renders the sheet as it was — a heading, a count and the slide —
|
||||||
|
/// which is correct for a rung that covers a single anonymous stop.
|
||||||
|
final List<BagLine> lines;
|
||||||
|
|
||||||
|
/// Runs the write, given what the rider actually confirmed. Returns null on
|
||||||
|
/// success, or a message to show without closing — the sheet stays open so he
|
||||||
|
/// can retry from where he is.
|
||||||
|
final Future<String?> Function(RungOutcome) onConfirm;
|
||||||
|
|
||||||
|
const StopActionSheet({
|
||||||
|
super.key,
|
||||||
|
required this.stage,
|
||||||
|
required this.placeName,
|
||||||
|
required this.count,
|
||||||
|
required this.onConfirm,
|
||||||
|
this.lines = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Opens the sheet and answers true once the rung was written.
|
||||||
|
static Future<bool> show(
|
||||||
|
BuildContext context, {
|
||||||
|
required StopStatus stage,
|
||||||
|
required String placeName,
|
||||||
|
required int count,
|
||||||
|
required Future<String?> Function(RungOutcome) onConfirm,
|
||||||
|
List<BagLine> lines = const [],
|
||||||
|
}) async {
|
||||||
|
// Through the kit — which also gives this sheet the app's entrance curve.
|
||||||
|
// It was the one sheet still on Flutter's default 250ms decelerate: the
|
||||||
|
// most consequential confirmation in the app arrived with a pop while
|
||||||
|
// everything around it rose.
|
||||||
|
final done = await showMilerSheet<bool>(
|
||||||
|
context,
|
||||||
|
builder: (_) => StopActionSheet(
|
||||||
|
stage: stage,
|
||||||
|
placeName: placeName,
|
||||||
|
count: count,
|
||||||
|
onConfirm: onConfirm,
|
||||||
|
lines: lines,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return done ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StopActionSheet> createState() => _StopActionSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StopActionSheetState extends State<StopActionSheet> {
|
||||||
|
bool _busy = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
/// Orders the counter could not supply, by id.
|
||||||
|
///
|
||||||
|
/// Empty is the overwhelmingly common case and the sheet is built for it: the
|
||||||
|
/// rider slides and leaves. Reporting a shortfall is a deliberate detour he
|
||||||
|
/// takes only when the shelf disagrees with the list — see [_reporting].
|
||||||
|
final Set<String> _missing = <String>{};
|
||||||
|
|
||||||
|
/// True once he has opened the shortfall list. The manifest is read-only
|
||||||
|
/// until then: a tappable row on a screen he is only meant to *read* is a
|
||||||
|
/// row he will mark by accident with a thumb on a moving bike.
|
||||||
|
bool _reporting = false;
|
||||||
|
|
||||||
|
/// One photo of the whole load. Null until he takes it, and optional always.
|
||||||
|
String? _photoPath;
|
||||||
|
bool _takingPhoto = false;
|
||||||
|
|
||||||
|
/// How many are actually coming with him.
|
||||||
|
int get _loaded => widget.count - _missing.length;
|
||||||
|
|
||||||
|
/// An empty crate is not a collection.
|
||||||
|
///
|
||||||
|
/// Marking every bag missing means the counter handed over nothing, and that
|
||||||
|
/// is a wasted trip and a call to the hub — not a state to file silently. The
|
||||||
|
/// slide goes dead and says so, which is the rule the collect sheet this
|
||||||
|
/// replaced enforced with a disabled button.
|
||||||
|
bool get _canSlide => _isArrival || _loaded > 0;
|
||||||
|
|
||||||
|
/// Whether the shortfall controls apply at all. Arrival is a claim about a
|
||||||
|
/// place — he is either there or he is not — and nothing is handed over, so
|
||||||
|
/// there is nothing to be short of.
|
||||||
|
bool get _canReportMissing => !_isArrival && widget.lines.isNotEmpty;
|
||||||
|
|
||||||
|
bool get _isArrival => widget.stage == StopStatus.accepted;
|
||||||
|
|
||||||
|
/// Names the place, because that is the thing he can be wrong about.
|
||||||
|
String get _title {
|
||||||
|
final place = widget.placeName.trim();
|
||||||
|
if (_isArrival) {
|
||||||
|
return place.isEmpty ? 'Confirm arrival' : 'Arrived at $place';
|
||||||
|
}
|
||||||
|
return place.isEmpty ? 'Confirm pickup' : 'Picked up from $place';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `5 orders · 5 bags` — the rule, stated where he can check it against the
|
||||||
|
/// shelf. See [BagManifest]. Once he reports a shortfall it counts what he is
|
||||||
|
/// actually leaving with, and names the gap rather than quietly shrinking.
|
||||||
|
String get _subtitle => _missing.isEmpty
|
||||||
|
? BagManifest.countLabel(widget.count)
|
||||||
|
: '${BagManifest.countLabel(_loaded)} · ${_missing.length} missing';
|
||||||
|
|
||||||
|
/// What the slide will actually change, in the rider's words. "Slide to
|
||||||
|
/// update" said nothing a rider could be wrong about; these name the claim —
|
||||||
|
/// including the one where nothing was handed over at all.
|
||||||
|
String get _slideLabel {
|
||||||
|
if (_isArrival) return 'Slide to confirm arrival';
|
||||||
|
if (!_canSlide) return 'Nothing to collect';
|
||||||
|
return 'Slide to confirm pickup';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one instruction that belongs on a pickup: count the bags first.
|
||||||
|
///
|
||||||
|
/// ── Why it carries its own severity ──
|
||||||
|
///
|
||||||
|
/// Three different sentences come out of here and only two of them are
|
||||||
|
/// exceptions. All three were painted amber, so the standing "count your
|
||||||
|
/// bags" line — which shows on *every* clean pickup — arrived in the ink the
|
||||||
|
/// app reserves for something genuinely wrong. A warning a rider sees every
|
||||||
|
/// single time is one he stops seeing, and the two lines that really do need
|
||||||
|
/// him had nothing left to say it with.
|
||||||
|
///
|
||||||
|
/// So each returns whether it is an exception, and the colour follows that
|
||||||
|
/// rather than the slot.
|
||||||
|
(String, bool)? get _instruction {
|
||||||
|
if (_isArrival || widget.lines.isEmpty) return null;
|
||||||
|
if (!_canSlide) {
|
||||||
|
return ('Nothing was handed over — call the hub before you leave.', true);
|
||||||
|
}
|
||||||
|
if (_missing.isEmpty) {
|
||||||
|
return (
|
||||||
|
'Make sure all ${widget.count} bags are loaded before continuing.',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
'${_missing.length} of ${widget.count} not supplied — they will leave '
|
||||||
|
'your route.',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color get _accent =>
|
||||||
|
_isArrival ? ColorConstants.primary : ColorConstants.acceptGreen;
|
||||||
|
|
||||||
|
/// Drives the confirmation without the drag, for tests that pin the
|
||||||
|
/// *payload* rather than the gesture. Named so it cannot be mistaken for a
|
||||||
|
/// production entry point — the slide is the only way a rider reaches this.
|
||||||
|
@visibleForTesting
|
||||||
|
Future<void> confirmForTest() => _run();
|
||||||
|
|
||||||
|
Future<void> _run() async {
|
||||||
|
if (_busy || !_canSlide) return;
|
||||||
|
setState(() {
|
||||||
|
_busy = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final failure = await widget.onConfirm(
|
||||||
|
RungOutcome(missingOrderIds: _missing.toList(), photoPath: _photoPath),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (failure != null) {
|
||||||
|
// Held open on purpose. The most common failure here is the geofence
|
||||||
|
// refusing because he is not close enough yet — and the answer to that is
|
||||||
|
// to walk twenty metres and slide again, which he can only do if the
|
||||||
|
// sheet is still in front of him.
|
||||||
|
setState(() {
|
||||||
|
_busy = false;
|
||||||
|
_error = failure;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MilerSheetScaffold(
|
||||||
|
padding: EdgeInsets.fromLTRB(18.w, 0, 18.w, 16.h),
|
||||||
|
// Scrolls only when it must. At normal scale this sheet fits whole; at
|
||||||
|
// a 2.0x system text a long manifest plus the photo row outgrows the
|
||||||
|
// screen by a hair, and a Column that cannot give overflows instead of
|
||||||
|
// giving the rider a way to reach the slide.
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 38.w,
|
||||||
|
height: 38.w,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _accent.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
DesignConstants.radiusLg,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_isArrival ? LucideIcons.mapPin : LucideIcons.shoppingBag,
|
||||||
|
size: 19.sp,
|
||||||
|
color: _accent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 12.w),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_title,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 17.sp,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: -0.3,
|
||||||
|
height: 1.2,
|
||||||
|
color: ColorConstants.slateText,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 3.h),
|
||||||
|
Text(
|
||||||
|
_subtitle,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12.5.sp,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: ColorConstants.secondaryText,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_instruction case (final text, final exceptional)) ...[
|
||||||
|
SizedBox(height: 12.h),
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13.5.sp,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
height: 1.35,
|
||||||
|
color: exceptional
|
||||||
|
? ColorConstants.warning
|
||||||
|
: ColorConstants.secondaryText,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (widget.lines.isNotEmpty) ...[
|
||||||
|
SizedBox(height: 14.h),
|
||||||
|
_manifest(),
|
||||||
|
],
|
||||||
|
if (_canReportMissing) ...[SizedBox(height: 10.h), _reportToggle()],
|
||||||
|
// One shot of the whole load, where the load is confirmed. It was
|
||||||
|
// on a separate collect sheet; that sheet is gone and this is the
|
||||||
|
// only place a pickup is now confirmed, so the proof comes with
|
||||||
|
// it. Never blocking — see [_CratePhotoRow].
|
||||||
|
if (!_isArrival && ServiceProfile.active.bulkLoadProof) ...[
|
||||||
|
SizedBox(height: 10.h),
|
||||||
|
_CratePhotoRow(
|
||||||
|
path: _photoPath,
|
||||||
|
busy: _takingPhoto || _busy,
|
||||||
|
accent: _accent,
|
||||||
|
onTake: _takePhoto,
|
||||||
|
onClear: () => setState(() => _photoPath = null),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (_error != null) ...[
|
||||||
|
SizedBox(height: 14.h),
|
||||||
|
InfoBanner(
|
||||||
|
icon: LucideIcons.circleAlert,
|
||||||
|
text: _error!,
|
||||||
|
color: ColorConstants.errorRed,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
SizedBox(height: 18.h),
|
||||||
|
_slider(),
|
||||||
|
SizedBox(height: 6.h),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The way into reporting a shortfall, and the way back out.
|
||||||
|
///
|
||||||
|
/// ── Why this is a detour and not the default ──
|
||||||
|
///
|
||||||
|
/// The collect flow this replaced started with every bag *ticked* and asked
|
||||||
|
/// the rider to untick what was missing. That is the right default — the
|
||||||
|
/// crate is almost always right — but it made a checklist out of the common
|
||||||
|
/// case: ten rows of controls on a screen whose answer was "yes" nine times
|
||||||
|
/// out of ten, which is a screen riders learn to dismiss without reading.
|
||||||
|
///
|
||||||
|
/// So the common case is now a list he reads and a slide, and the exception
|
||||||
|
/// is one tap away. The default is unchanged in meaning: nothing marked means
|
||||||
|
/// everything came.
|
||||||
|
Widget _reportToggle() {
|
||||||
|
if (!_reporting) {
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: _busy ? null : () => setState(() => _reporting = true),
|
||||||
|
icon: Icon(
|
||||||
|
LucideIcons.triangleAlert,
|
||||||
|
size: 18.sp,
|
||||||
|
color: ColorConstants.errorRed,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
'Report missing bag',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: ColorConstants.errorRed,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_missing.isEmpty
|
||||||
|
? 'Tap any bag the counter could not supply'
|
||||||
|
: '${_missing.length} marked missing',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13.5.sp,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: ColorConstants.secondaryText,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _busy
|
||||||
|
? null
|
||||||
|
: () => setState(() {
|
||||||
|
_reporting = false;
|
||||||
|
_missing.clear();
|
||||||
|
}),
|
||||||
|
child: Text(
|
||||||
|
'Cancel',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: ColorConstants.secondaryText,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ── Camera, not gallery ──
|
||||||
|
///
|
||||||
|
/// The photo is evidence of a moment: this crate, at this counter, now.
|
||||||
|
/// `ImageSource.gallery` would let a picture taken anywhere at any time stand
|
||||||
|
/// in for that, which is worse than having no photo at all — it looks like
|
||||||
|
/// proof and is not. Quality is capped because it travels over a rider's
|
||||||
|
/// mobile data and nobody is zooming in on a rice box.
|
||||||
|
Future<void> _takePhoto() async {
|
||||||
|
if (_takingPhoto) return;
|
||||||
|
setState(() => _takingPhoto = true);
|
||||||
|
try {
|
||||||
|
final shot = await ImagePicker().pickImage(
|
||||||
|
source: ImageSource.camera,
|
||||||
|
imageQuality: 70,
|
||||||
|
maxWidth: 1280,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (shot != null) setState(() => _photoPath = shot.path);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[LOAD] Could not take crate photo: $e');
|
||||||
|
if (mounted) {
|
||||||
|
AppFeedback.error(
|
||||||
|
context,
|
||||||
|
"Couldn't open the camera — carry on without it",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _takingPhoto = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The orders this rung covers, one line each: who it is for, and the bag it
|
||||||
|
/// travels in.
|
||||||
|
///
|
||||||
|
/// Capped in height rather than paged: a kitchen handing over twenty bags is
|
||||||
|
/// rare and a sheet that grows past the fold is worse than one that scrolls.
|
||||||
|
/// It is a list, not a checklist — ticking twenty boxes at a counter is the
|
||||||
|
/// itemised load sheet's job, and this rung is one physical handover.
|
||||||
|
Widget _manifest() {
|
||||||
|
return Container(
|
||||||
|
constraints: BoxConstraints(maxHeight: 260.h),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: ColorConstants.neutralLight,
|
||||||
|
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||||
|
),
|
||||||
|
child: ListView.separated(
|
||||||
|
shrinkWrap: true,
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h),
|
||||||
|
itemCount: widget.lines.length,
|
||||||
|
separatorBuilder: (_, _) => SizedBox(height: 10.h),
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final line = widget.lines[i];
|
||||||
|
final gone = _missing.contains(line.orderId);
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: _reporting && !_busy
|
||||||
|
? () => setState(() {
|
||||||
|
gone
|
||||||
|
? _missing.remove(line.orderId)
|
||||||
|
: _missing.add(line.orderId);
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// The mark only appears while he is reporting: until then this is
|
||||||
|
// a list to read against a shelf, not a form to fill in.
|
||||||
|
if (_reporting) ...[
|
||||||
|
Icon(
|
||||||
|
gone ? LucideIcons.circleMinus : LucideIcons.circleCheck,
|
||||||
|
size: 20.sp,
|
||||||
|
color: gone
|
||||||
|
? ColorConstants.errorRed
|
||||||
|
: ColorConstants.borderStrong,
|
||||||
|
),
|
||||||
|
SizedBox(width: 10.w),
|
||||||
|
],
|
||||||
|
// The position, so a rider counting down a shelf can keep his
|
||||||
|
// place without re-reading the names.
|
||||||
|
SizedBox(
|
||||||
|
width: 18.w,
|
||||||
|
child: Text(
|
||||||
|
'${i + 1}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13.sp,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: ColorConstants.secondaryText,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 6.w),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
line.customer.isEmpty
|
||||||
|
? 'Order ${line.orderId}'
|
||||||
|
: line.customer,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15.sp,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
color: gone
|
||||||
|
? ColorConstants.secondaryText
|
||||||
|
: ColorConstants.slateText,
|
||||||
|
decoration: gone ? TextDecoration.lineThrough : null,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 10.w),
|
||||||
|
// The same tag the route rows wear — one language for one
|
||||||
|
// fact, from Home to the sheet it opens. White stands on the
|
||||||
|
// manifest's tinted panel; a missing bag keeps the error ink
|
||||||
|
// in the same body so the exception is the colour, not the
|
||||||
|
// shape.
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 7.w, vertical: 3.h),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: gone
|
||||||
|
? ColorConstants.errorRed.withValues(alpha: 0.08)
|
||||||
|
: MilerSurface.working,
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
DesignConstants.radiusLg,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
gone ? 'Missing' : line.bag,
|
||||||
|
maxLines: 1,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12.5.sp,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.1,
|
||||||
|
color: gone
|
||||||
|
? ColorConstants.errorRed
|
||||||
|
: ColorConstants.secondaryText,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _slider() {
|
||||||
|
// The slide control is a fixed 52pt pill whose internals come from a
|
||||||
|
// package and do not reflow — at 2.0x system text its own row ran off the
|
||||||
|
// right edge. A control that physically cannot grow clamps its type
|
||||||
|
// instead, the same trade Material's fixed-height chrome makes; the label
|
||||||
|
// is supplementary ("slide to…") and the gesture itself is the
|
||||||
|
// affordance, so nothing a rider needs is lost at the cap.
|
||||||
|
return MediaQuery.withClampedTextScaling(
|
||||||
|
maxScaleFactor: 1.4,
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: !_canSlide,
|
||||||
|
child: SlideToSubmit.custom(
|
||||||
|
height: ButtonSizes.primary,
|
||||||
|
sliderWidth: 46,
|
||||||
|
padding: const EdgeInsets.all(5),
|
||||||
|
backgroundDecoration: BoxDecoration(
|
||||||
|
color: _canSlide
|
||||||
|
? _accent.withValues(alpha: 0.10)
|
||||||
|
: ColorConstants.neutralLight,
|
||||||
|
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||||||
|
),
|
||||||
|
foregroundDecoration: const BoxDecoration(color: Colors.transparent),
|
||||||
|
slider: Center(
|
||||||
|
child: Container(
|
||||||
|
height: 46,
|
||||||
|
width: 46,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _canSlide ? _accent : ColorConstants.borderStrong,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: _busy
|
||||||
|
? SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: ColorConstants.onAccent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
LucideIcons.arrowRight,
|
||||||
|
size: 20.sp,
|
||||||
|
color: ColorConstants.onAccent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
hint: IgnorePointer(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(width: 46.0 + 10.w),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_slideLabel,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
color: _canSlide ? _accent : ColorConstants.secondaryText,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 14.w),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onSubmit: (controller) async {
|
||||||
|
await _run();
|
||||||
|
// Reset only if we are still here — a successful slide has popped the
|
||||||
|
// route and the controller is gone with it.
|
||||||
|
if (mounted) {
|
||||||
|
try {
|
||||||
|
controller.reset();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The crate photo control — a row, not a section.
|
||||||
|
///
|
||||||
|
/// ── Optional on purpose ──
|
||||||
|
///
|
||||||
|
/// A required photo is a rider standing at a counter unable to record what he
|
||||||
|
/// is holding because the camera permission was declined nine weeks ago, or the
|
||||||
|
/// hatch is dark, or the phone is at 2%. The load is the thing that must be
|
||||||
|
/// recorded; the picture is what makes a later dispute cheap. So it is offered
|
||||||
|
/// prominently and never blocks the button — which is also exactly what the app
|
||||||
|
/// this flow came from did with its bulk proof.
|
||||||
|
class _CratePhotoRow extends StatelessWidget {
|
||||||
|
final String? path;
|
||||||
|
final bool busy;
|
||||||
|
final Color accent;
|
||||||
|
final VoidCallback onTake;
|
||||||
|
final VoidCallback onClear;
|
||||||
|
|
||||||
|
const _CratePhotoRow({
|
||||||
|
required this.path,
|
||||||
|
required this.busy,
|
||||||
|
required this.accent,
|
||||||
|
required this.onTake,
|
||||||
|
required this.onClear,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final taken = path != null;
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: busy ? null : (taken ? onClear : onTake),
|
||||||
|
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||||
|
child: Container(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.h),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: taken
|
||||||
|
? ColorConstants.acceptGreen.withValues(alpha: 0.08)
|
||||||
|
: ColorConstants.neutralLight,
|
||||||
|
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// The shot itself once there is one: a thumbnail is the only
|
||||||
|
// confirmation that answers "did it actually capture the crate,
|
||||||
|
// or my shoe?" — a tick beside the word "Photo" does not.
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 38.w,
|
||||||
|
height: 38.w,
|
||||||
|
child: taken
|
||||||
|
? Image.file(File(path!), fit: BoxFit.cover)
|
||||||
|
: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accent.withValues(alpha: 0.10),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
LucideIcons.camera,
|
||||||
|
size: 19.sp,
|
||||||
|
color: accent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 11.w),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
taken ? 'Crate photographed' : 'Photograph the crate',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13.5.sp,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: -0.1,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
color: taken
|
||||||
|
? ColorConstants.acceptGreen
|
||||||
|
: ColorConstants.slateText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 1.h),
|
||||||
|
Text(
|
||||||
|
taken
|
||||||
|
? 'Tap to retake'
|
||||||
|
: 'Optional · one shot for the load',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11.5.sp,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
color: ColorConstants.secondaryText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 8.w),
|
||||||
|
if (busy)
|
||||||
|
SizedBox(
|
||||||
|
width: 18.w,
|
||||||
|
height: 18.w,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: accent),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Icon(
|
||||||
|
taken ? LucideIcons.rotateCw : LucideIcons.chevronRight,
|
||||||
|
size: 20.sp,
|
||||||
|
color: ColorConstants.secondaryText,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
977
lib/views/Dashboard/home/stop_card.dart
Normal file
@@ -0,0 +1,977 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
|
||||||
|
import 'package:miler/data/service_profile.dart';
|
||||||
|
import 'package:miler/views/Dashboard/home/trip.dart';
|
||||||
|
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
|
||||||
|
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||||||
|
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||||
|
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||||
|
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||||
|
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||||
|
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// STOP CARD — one order, drawn as the journey it is.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// ✓ ACCEPTED ~5 min · by 12:30 PM ( )
|
||||||
|
///
|
||||||
|
/// ● 🍴 PICKUP
|
||||||
|
/// │ Vidhya Kitchen
|
||||||
|
/// │ 📍 Ritham Tours & Travels 20, Gandhi nagar, Peelamedu…
|
||||||
|
/// │ Quantity 1
|
||||||
|
/// ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
|
||||||
|
/// ● 📦 DROP
|
||||||
|
/// Joe
|
||||||
|
/// 📍 12, SNS Colony, Peelamedu, Coimbatore
|
||||||
|
/// 🏢 Daily Grubs
|
||||||
|
/// ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
|
||||||
|
/// 🧾 Stop 1 · #916-2024116374 [📞] [ⓘ]
|
||||||
|
///
|
||||||
|
/// ┌───────────────────────────────────────────────────────┐
|
||||||
|
/// │ ➤ Navigate to pickup │
|
||||||
|
/// └───────────────────────────────────────────────────────┘
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ── The three rules this card is built on ──
|
||||||
|
///
|
||||||
|
/// 1. **No boxes.** One pane of glass ([GlassCard]) with no border, and
|
||||||
|
/// nothing bordered inside it. Everything that used to be a box — the type
|
||||||
|
/// chip, the state panel, the reserved control slots, the tinted meta
|
||||||
|
/// tags — is type and colour on plain rows now.
|
||||||
|
///
|
||||||
|
/// 2. **The rider reads this at arm's length, in sun, on a bike.** So the
|
||||||
|
/// things he actually reads are large: the place name at 19sp, the address
|
||||||
|
/// at 14.5, the time he is working to at 13.5. Nothing on this card is
|
||||||
|
/// under 11.5.
|
||||||
|
///
|
||||||
|
/// 3. **One state, one next action.** The card always says where the order is
|
||||||
|
/// (the chip, top left) and offers exactly one thing to press for it
|
||||||
|
/// (bottom). An accepted order's one action is *get me there* — see
|
||||||
|
/// [_primaryAction].
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
class StopCard extends StatelessWidget {
|
||||||
|
final Map<String, dynamic> stop;
|
||||||
|
|
||||||
|
/// 1-based position in the route, printed in the footer beside the order id.
|
||||||
|
final int index;
|
||||||
|
|
||||||
|
final StopState state;
|
||||||
|
|
||||||
|
/// The pickup-side address, already shortened by the trip if the design's
|
||||||
|
/// tail-trimming is on.
|
||||||
|
final String address;
|
||||||
|
|
||||||
|
/// Time at the door, shown in the card's header.
|
||||||
|
final Duration service;
|
||||||
|
|
||||||
|
/// Which bag this order travels in — `Bag 3`, or the label the kitchen
|
||||||
|
/// printed. One order, one bag; the count is never a separate figure. See
|
||||||
|
/// [BagManifest].
|
||||||
|
final String bag;
|
||||||
|
|
||||||
|
/// True when this card sits under a heading that already names its pickup
|
||||||
|
/// location.
|
||||||
|
///
|
||||||
|
/// It changes what the card leads with. Under a kitchen heading, five cards
|
||||||
|
/// each headlined "Vidhya Kitchen" is the same name six times on one screen —
|
||||||
|
/// the heading, then once per card — and the one thing that actually tells
|
||||||
|
/// the five apart, the customer, was demoted to a line underneath. So a
|
||||||
|
/// grouped card leads with **who it is for** and shows the address it is
|
||||||
|
/// going to, which is also how the kitchen's own manifest reads.
|
||||||
|
final bool groupedUnderSource;
|
||||||
|
|
||||||
|
final bool selected;
|
||||||
|
|
||||||
|
/// True while this stop's acceptance is on the wire — the tick goes dead so
|
||||||
|
/// it cannot disagree with the request in flight.
|
||||||
|
final bool busy;
|
||||||
|
|
||||||
|
/// Opens the detail sheet. The whole card carries it, and so does the ⓘ tile.
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
/// Null hides the tick entirely — see the note in [TripCard] on which states
|
||||||
|
/// are still choosable.
|
||||||
|
final VoidCallback? onToggleSelect;
|
||||||
|
|
||||||
|
final VoidCallback? onCall;
|
||||||
|
|
||||||
|
/// Opens turn-by-turn navigation to whichever leg this stop is on. This is
|
||||||
|
/// the accepted card's primary action, not a tile in a corner: an accepted
|
||||||
|
/// order's whole content is "go to this kitchen".
|
||||||
|
final VoidCallback? onNavigate;
|
||||||
|
|
||||||
|
/// Returns a rejected stop to undecided.
|
||||||
|
final VoidCallback? onUnreject;
|
||||||
|
|
||||||
|
/// Takes the rider back into the stop he is already working.
|
||||||
|
final VoidCallback? onContinue;
|
||||||
|
|
||||||
|
/// The next rung for a stop already under way, behind the same slide sheet
|
||||||
|
/// the selection bar uses. Only ever offered on an *arrived* stop — the rung
|
||||||
|
/// before that is reached by navigating there and ticking the counter's
|
||||||
|
/// orders. See [_primaryAction].
|
||||||
|
final VoidCallback? onAdvance;
|
||||||
|
final String? advanceLabel;
|
||||||
|
|
||||||
|
const StopCard({
|
||||||
|
super.key,
|
||||||
|
required this.stop,
|
||||||
|
required this.index,
|
||||||
|
required this.state,
|
||||||
|
required this.address,
|
||||||
|
required this.service,
|
||||||
|
this.bag = '',
|
||||||
|
this.groupedUnderSource = false,
|
||||||
|
this.selected = false,
|
||||||
|
this.busy = false,
|
||||||
|
this.onTap,
|
||||||
|
this.onToggleSelect,
|
||||||
|
this.onCall,
|
||||||
|
this.onNavigate,
|
||||||
|
this.onUnreject,
|
||||||
|
this.onContinue,
|
||||||
|
this.onAdvance,
|
||||||
|
this.advanceLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get _live => state == StopState.active;
|
||||||
|
bool get _rejected => state == StopState.rejected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final kind = stopKindOf(stop);
|
||||||
|
|
||||||
|
final source = stopSourceName(stop);
|
||||||
|
final customer = (stop['pickupcustomer'] ?? stop['tenantname'] ?? 'Stop')
|
||||||
|
.toString();
|
||||||
|
final drop = (stop['dropaddress'] ?? stop['DropAddress'] ?? '')
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// ── The card leads with where he is going ──
|
||||||
|
//
|
||||||
|
// Not with both ends of the journey. A meal order has two, and drawing both
|
||||||
|
// at equal weight — two avatars, two labels, two addresses joined by a
|
||||||
|
// thread — made every card a diagram of a journey rather than an answer to
|
||||||
|
// "where am I going and what do I do there". The destination takes the
|
||||||
|
// headline; the other end drops to one supporting line underneath.
|
||||||
|
//
|
||||||
|
// Before pickup the destination is the counter, because that is the address
|
||||||
|
// he is riding to. After it, the drop leads — and that card lives on the
|
||||||
|
// work tab, where [PickupCard] does exactly the same thing in the other
|
||||||
|
// direction.
|
||||||
|
final twoLeg = ServiceProfile.active.deliversToCustomer && drop.isNotEmpty;
|
||||||
|
final grouped = groupedUnderSource && source.isNotEmpty;
|
||||||
|
final headline = (twoLeg && !grouped && source.isNotEmpty)
|
||||||
|
? source
|
||||||
|
: customer;
|
||||||
|
final headlineAddress = grouped && drop.isNotEmpty ? drop : address;
|
||||||
|
|
||||||
|
final action = _primaryAction();
|
||||||
|
|
||||||
|
return GlassCard(
|
||||||
|
margin: EdgeInsets.only(bottom: 12.h),
|
||||||
|
onTap: onTap,
|
||||||
|
opacity: _rejected ? 0.6 : 1,
|
||||||
|
tint: _live ? ColorConstants.glassCardLive : null,
|
||||||
|
child: Padding(
|
||||||
|
// One inset for the whole card, and the button spans it. The card used
|
||||||
|
// to run four different paddings — a header inset, a leg inset, a
|
||||||
|
// footer inset and a CTA inset — which is what made the left edge of
|
||||||
|
// the content wander down the card.
|
||||||
|
padding: EdgeInsets.fromLTRB(18.w, 16.h, 18.w, 16.h),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_statusRow(kind),
|
||||||
|
SizedBox(height: 10.h),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// ── The headline ──
|
||||||
|
//
|
||||||
|
// The loudest thing on the card, by a clear step. It is
|
||||||
|
// what the rider shouts at a gate and what he checks a
|
||||||
|
// shop sign against, and at 19sp beside a 16sp address it
|
||||||
|
// was not winning by enough to be found without reading.
|
||||||
|
Text(
|
||||||
|
headline,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 21.sp,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
height: 1.12,
|
||||||
|
letterSpacing: -0.6,
|
||||||
|
color: ColorConstants.slateText,
|
||||||
|
decoration: _rejected
|
||||||
|
? TextDecoration.lineThrough
|
||||||
|
: null,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (headlineAddress.isNotEmpty) ...[
|
||||||
|
SizedBox(height: 5.h),
|
||||||
|
Text(
|
||||||
|
headlineAddress,
|
||||||
|
maxLines: kFullAddressTrial ? 3 : 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15.sp,
|
||||||
|
height: 1.4,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: ColorConstants.secondaryText,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Ringing ahead is the one thing a rider does from a list that
|
||||||
|
// is not "go there", so it keeps a place on the card — a single
|
||||||
|
// quiet glyph, not one of a row of tiles.
|
||||||
|
if (onCall != null) ...[
|
||||||
|
SizedBox(width: 10.w),
|
||||||
|
_IconAction(
|
||||||
|
icon: LucideIcons.phone,
|
||||||
|
semanticLabel: 'Call this stop',
|
||||||
|
onTap: onCall!,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (twoLeg && !grouped) ...[
|
||||||
|
SizedBox(height: 12.h),
|
||||||
|
_dropLine(customer),
|
||||||
|
],
|
||||||
|
SizedBox(height: 12.h),
|
||||||
|
_factsRow(
|
||||||
|
kind,
|
||||||
|
bagShownAbove: twoLeg && !grouped && bag.isNotEmpty,
|
||||||
|
),
|
||||||
|
if (action != null) ...[SizedBox(height: 16.h), action],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A quantity of at least one: a stop that carries nothing is not a stop, and
|
||||||
|
/// a payload that forgot to say so should not print "0".
|
||||||
|
int _qty(int v) => v > 0 ? v : 1;
|
||||||
|
|
||||||
|
/// The type, the state and the tick, on one line above the name.
|
||||||
|
///
|
||||||
|
/// All three are *marks*: small, quiet, and never competing with the address
|
||||||
|
/// underneath. The state chip is the only one that carries colour, because it
|
||||||
|
/// is the only one that changes.
|
||||||
|
Widget _statusRow(StopKind kind) {
|
||||||
|
final chip = _live
|
||||||
|
? const LiveMark()
|
||||||
|
: (state == StopState.pending &&
|
||||||
|
!ServiceProfile.active.handsOffAtCollection)
|
||||||
|
? null
|
||||||
|
: StopStateChip(state: state, filled: true);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
kind.chipLabel,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12.sp,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 1.0,
|
||||||
|
// A mark, in the type's own colour — never a fill and never a
|
||||||
|
// border. See `stop_card_neutral_test`, which pins exactly that.
|
||||||
|
color: kind.chipColor,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 10.w),
|
||||||
|
if (chip != null) Flexible(child: chip),
|
||||||
|
const Spacer(),
|
||||||
|
if (onToggleSelect != null)
|
||||||
|
SelectBox(
|
||||||
|
selected: selected,
|
||||||
|
accent: ColorConstants.primary,
|
||||||
|
label: 'Select stop $index',
|
||||||
|
circular: true,
|
||||||
|
// Dead while this stop's acceptance is on the wire: un-ticking it
|
||||||
|
// there would leave the tick and the request disagreeing.
|
||||||
|
onTap: busy ? null : onToggleSelect,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `→ Joe · Bag 1` — the far end, in one line.
|
||||||
|
///
|
||||||
|
/// Everything the second leg used to spend a whole block on: who it is for,
|
||||||
|
/// and which bag it is. The address is one tap away in the detail sheet,
|
||||||
|
/// where a rider going to a *kitchen* has no use for it yet.
|
||||||
|
Widget _dropLine(String customer) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
LucideIcons.arrowRight,
|
||||||
|
size: 16.sp,
|
||||||
|
color: ColorConstants.deliveryChip,
|
||||||
|
),
|
||||||
|
SizedBox(width: 7.w),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
customer,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15.5.sp,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
color: ColorConstants.slateText,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (bag.isNotEmpty) ...[SizedBox(width: 10.w), _BagChip(label: bag)],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The figures, on one line: time at the door, when it is due, what is in it.
|
||||||
|
///
|
||||||
|
/// This is the line a rider re-reads at a red light, so nothing on it is
|
||||||
|
/// under 14sp and none of it is in a box.
|
||||||
|
Widget _factsRow(StopKind kind, {required bool bagShownAbove}) {
|
||||||
|
final due = DateTime.tryParse(
|
||||||
|
(stop['expected_pickup_time'] ?? stop['eta'] ?? '').toString().trim(),
|
||||||
|
);
|
||||||
|
final cash = stopCollectionAmount(stop);
|
||||||
|
|
||||||
|
final parts = <String>[
|
||||||
|
if (service > Duration.zero) '~${formatTripDuration(service)}',
|
||||||
|
if (due != null) 'by ${RouteMetricsHelper.formatClock(due)}',
|
||||||
|
// Position in the route. The trip's own index, which does not renumber
|
||||||
|
// itself as stops leave the screen.
|
||||||
|
'Stop $index',
|
||||||
|
];
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
LucideIcons.clock,
|
||||||
|
size: 16.sp,
|
||||||
|
color: ColorConstants.secondaryText,
|
||||||
|
),
|
||||||
|
SizedBox(width: 7.w),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
parts.join(' · '),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
color: ColorConstants.secondaryText,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// What is in it, and which bag it is. The bag appears here whenever the
|
||||||
|
// drop line above is not already carrying it — a card must never be
|
||||||
|
// silent about which box this order is, and it must never say it twice.
|
||||||
|
Builder(
|
||||||
|
builder: (_) {
|
||||||
|
final label = bag.isNotEmpty ? bag : stopBagLabel(stop);
|
||||||
|
final meta = _MetaLine(
|
||||||
|
deliver: kind.hasDelivery ? _qty(deliveryParcelCount(stop)) : 0,
|
||||||
|
collect: kind.hasPickup ? _qty(pickupParcelCount(stop)) : 0,
|
||||||
|
cash: cash,
|
||||||
|
muted: _rejected || state == StopState.done,
|
||||||
|
// On a meal run the parcel counts are the one-bag rule restated
|
||||||
|
// as an item count, which is exactly the second number this app
|
||||||
|
// does not allow. Only the money survives.
|
||||||
|
countsHidden: ServiceProfile.active.deliversToCustomer,
|
||||||
|
);
|
||||||
|
final showBag = !bagShownAbove && label.isNotEmpty;
|
||||||
|
if (!showBag && meta.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(top: 8.h),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (!meta.isEmpty) Flexible(child: meta),
|
||||||
|
if (showBag) ...[
|
||||||
|
if (!meta.isEmpty) SizedBox(width: 10.w),
|
||||||
|
_BagChip(label: label),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one thing to press on this card, sized like it matters.
|
||||||
|
///
|
||||||
|
/// ── Why an accepted order's action is "navigate" ──
|
||||||
|
///
|
||||||
|
/// Accepting is a promise; the next physical act is riding to the counter.
|
||||||
|
/// The rung after that — *I have arrived* — is a claim about a place, and it
|
||||||
|
/// is made from the selection bar with every order from that counter ticked,
|
||||||
|
/// because he arrives at a kitchen, not at an order. So the card offers the
|
||||||
|
/// journey and the bar offers the claim, and neither duplicates the other.
|
||||||
|
///
|
||||||
|
/// A stop that is already *arrived* is the one exception: the single-order
|
||||||
|
/// case ("one bag, one counter") would otherwise make him tick a card he is
|
||||||
|
/// looking at to reach a control that talks about "the selection". That
|
||||||
|
/// button opens the same slide sheet the bar does, over the same batch.
|
||||||
|
Widget? _primaryAction() {
|
||||||
|
Widget wrap(Widget child) => Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(16.w, 6.h, 16.w, 16.h),
|
||||||
|
child: SizedBox(width: double.infinity, child: child),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_rejected) {
|
||||||
|
return onUnreject == null
|
||||||
|
? null
|
||||||
|
: wrap(
|
||||||
|
MilerButton(
|
||||||
|
label: 'Undo reject',
|
||||||
|
icon: LucideIcons.undo2,
|
||||||
|
variant: MilerButtonVariant.outlined,
|
||||||
|
color: ColorConstants.slateText,
|
||||||
|
height: ButtonSizes.secondary,
|
||||||
|
loading: busy,
|
||||||
|
onPressed: busy ? null : onUnreject,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stop under way, on a parcel route: back into the verification flow.
|
||||||
|
if (_live && onContinue != null) {
|
||||||
|
return wrap(
|
||||||
|
MilerButton(
|
||||||
|
label: stopKindOf(stop).isDelivery
|
||||||
|
? 'Continue delivery'
|
||||||
|
: 'Continue pickup',
|
||||||
|
icon: LucideIcons.arrowRight,
|
||||||
|
color: ColorConstants.primary,
|
||||||
|
height: ButtonSizes.primary,
|
||||||
|
onPressed: onContinue,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stop under way, on a service route: the next rung, over the batch at
|
||||||
|
// this counter.
|
||||||
|
if (_live && onAdvance != null && advanceLabel != null) {
|
||||||
|
return wrap(
|
||||||
|
MilerButton(
|
||||||
|
label: advanceLabel!,
|
||||||
|
icon: LucideIcons.shoppingBag,
|
||||||
|
color: ColorConstants.acceptGreen,
|
||||||
|
height: ButtonSizes.primary,
|
||||||
|
loading: busy,
|
||||||
|
onPressed: busy ? null : onAdvance,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Under way, with nothing on this screen able to move it on: say where it
|
||||||
|
// *is* worked. A sentence, not a button — a control that cannot be pressed
|
||||||
|
// is not a status, and this is the one case where the card has nothing to
|
||||||
|
// offer but wayfinding.
|
||||||
|
if (_live) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(16.w, 2.h, 16.w, 16.h),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.bike, size: 17.sp, color: ColorConstants.primary),
|
||||||
|
SizedBox(width: 8.w),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
'In progress — finish it in '
|
||||||
|
'${ServiceProfile.active.workTabLabel}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: ColorConstants.primary,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taken on, and not yet there.
|
||||||
|
if (state == StopState.accepted && onNavigate != null) {
|
||||||
|
return wrap(
|
||||||
|
MilerButton(
|
||||||
|
label: 'Navigate to pickup',
|
||||||
|
icon: LucideIcons.navigation,
|
||||||
|
// Maroon, not green: across this app green *advances the job* and
|
||||||
|
// brand red is navigation and identity. Setting off changes nothing
|
||||||
|
// at the hub, so it must not wear the colour that does.
|
||||||
|
color: ColorConstants.primary,
|
||||||
|
height: ButtonSizes.primary,
|
||||||
|
onPressed: onNavigate,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A quiet round glyph button — the card's only icon control.
|
||||||
|
///
|
||||||
|
/// One, not a row of three. The footer used to carry call, navigate and
|
||||||
|
/// details as equal tiles; navigating is the card's primary button now and the
|
||||||
|
/// details are the card's own tap, which leaves exactly one thing worth a
|
||||||
|
/// glyph.
|
||||||
|
class _IconAction extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String semanticLabel;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _IconAction({
|
||||||
|
required this.icon,
|
||||||
|
required this.semanticLabel,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
label: semanticLabel,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: SizedBox(
|
||||||
|
width: 48.w,
|
||||||
|
height: 48.w,
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
width: 42.w,
|
||||||
|
height: 42.w,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: ColorConstants.primary.withValues(alpha: 0.08),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 20.sp, color: ColorConstants.primary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Bag 3` — the one code the rider matches against a box in his hand.
|
||||||
|
///
|
||||||
|
/// A neutral chip, not a coloured one: it is an identifier, not a state, and
|
||||||
|
/// the card has exactly one colour to spend. It is a chip rather than plain
|
||||||
|
/// text because it is read *against an object* — a bordered word is findable on
|
||||||
|
/// a card at arm's length in a way a run of grey text is not.
|
||||||
|
class _BagChip extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
const _BagChip({required this.label});
|
||||||
|
|
||||||
|
/// Backend labels are codes — `DG-1042`, `BAG-1` — and derived ones already
|
||||||
|
/// read `Bag 3`. The word belongs on both, and the test for "already has it"
|
||||||
|
/// is the *word*, not the prefix: `BAG-1` starts with those three letters and
|
||||||
|
/// is still a code, so it becomes `Bag BAG-1` exactly as it always read.
|
||||||
|
String get _text =>
|
||||||
|
label.toLowerCase().startsWith('bag ') ? label : 'Bag $label';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 5.h),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: ColorConstants.neutralLight,
|
||||||
|
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_text,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13.5.sp,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: -0.1,
|
||||||
|
color: ColorConstants.slateText,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tick box for selecting a stop.
|
||||||
|
///
|
||||||
|
/// Square on the select-all strip, circular on a card — a card's tick sits
|
||||||
|
/// beside a round avatar and a round dot, and a square there was the only
|
||||||
|
/// corner in that column.
|
||||||
|
class SelectBox extends StatelessWidget {
|
||||||
|
final bool selected;
|
||||||
|
final bool partial;
|
||||||
|
final Color accent;
|
||||||
|
final String label;
|
||||||
|
final bool circular;
|
||||||
|
|
||||||
|
/// Null while the stop's acceptance is in flight.
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
const SelectBox({
|
||||||
|
super.key,
|
||||||
|
required this.selected,
|
||||||
|
required this.accent,
|
||||||
|
required this.label,
|
||||||
|
required this.onTap,
|
||||||
|
this.partial = false,
|
||||||
|
this.circular = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final on = selected || partial;
|
||||||
|
return Semantics(
|
||||||
|
checked: selected,
|
||||||
|
label: label,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
// 48dp hit area around a 28px box: the visible control stays compact
|
||||||
|
// but the target clears the accessibility floor comfortably. This is
|
||||||
|
// the control the rider hits most on this screen, one-handed, in
|
||||||
|
// motion — it is worth the four extra points over the 44 floor.
|
||||||
|
child: Container(
|
||||||
|
width: 48.w,
|
||||||
|
height: 48.w,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 160),
|
||||||
|
width: 28.w,
|
||||||
|
height: 28.w,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: on ? accent : ColorConstants.pureSurface,
|
||||||
|
shape: circular ? BoxShape.circle : BoxShape.rectangle,
|
||||||
|
borderRadius: circular
|
||||||
|
? null
|
||||||
|
: BorderRadius.circular(DesignConstants.radiusLg),
|
||||||
|
border: Border.all(
|
||||||
|
color: on ? accent : ColorConstants.borderStrong,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: on
|
||||||
|
? Icon(
|
||||||
|
partial ? LucideIcons.minus : LucideIcons.check,
|
||||||
|
size: 18.sp,
|
||||||
|
color: Colors.white,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `● Active` — the stop the rider is on, with a dot that breathes.
|
||||||
|
///
|
||||||
|
/// The word was `LIVE`, which is broadcast vocabulary: it says a stream is
|
||||||
|
/// on air, not that a rider is standing at a counter. **Active** is the word
|
||||||
|
/// the rest of the app already uses for this rung — the trip tab's status
|
||||||
|
/// line, the queue's own heading, `StopStatus.active` itself — so the one
|
||||||
|
/// state the rider must not miss now has one name everywhere he meets it.
|
||||||
|
///
|
||||||
|
/// Movement is what makes "this one is running right now" read without being
|
||||||
|
/// read; a static dot beside the word is a claim the interface does not back
|
||||||
|
/// up.
|
||||||
|
///
|
||||||
|
/// The controller is created on first *use* rather than on the declaration, so
|
||||||
|
/// a `dispose()` on a card that never painted cannot construct a ticker during
|
||||||
|
/// teardown — the same trap the banner hit, documented in `homepage_banner.dart`.
|
||||||
|
class LiveMark extends StatefulWidget {
|
||||||
|
const LiveMark({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LiveMark> createState() => _LiveMarkState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LiveMarkState extends State<LiveMark>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
AnimationController? _controller;
|
||||||
|
|
||||||
|
AnimationController get _pulse => _controller ??= AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 1100),
|
||||||
|
)..repeat(reverse: true);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final accent = ColorConstants.primary;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.fromLTRB(10.w, 5.h, 12.w, 5.h),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accent.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
AnimatedBuilder(
|
||||||
|
animation: _pulse,
|
||||||
|
builder: (context, _) {
|
||||||
|
// A halo that grows and fades around a dot that stays put:
|
||||||
|
// scaling the dot itself would shift the word beside it every
|
||||||
|
// frame.
|
||||||
|
return SizedBox(
|
||||||
|
width: 8.w,
|
||||||
|
height: 8.w,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
Transform.scale(
|
||||||
|
scale: 1.0 + 1.1 * _pulse.value,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accent.withValues(
|
||||||
|
alpha: 0.55 * (1 - _pulse.value),
|
||||||
|
),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: SizedBox(width: 8.w, height: 8.w),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: 7.w,
|
||||||
|
height: 7.w,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accent,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SizedBox(width: 6.w),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
'Active',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11.5.sp,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
color: accent,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where this order stands, named in one word.
|
||||||
|
///
|
||||||
|
/// [filled] gives it the tinted pill it wears at the top of a card, where it is
|
||||||
|
/// the first thing read; inline uses — anywhere it is one fact among others —
|
||||||
|
/// keep the bare icon-and-word, because a pill in a paragraph is a box.
|
||||||
|
class StopStateChip extends StatelessWidget {
|
||||||
|
final StopState state;
|
||||||
|
final bool filled;
|
||||||
|
|
||||||
|
const StopStateChip({super.key, required this.state, this.filled = false});
|
||||||
|
|
||||||
|
({Color color, IconData icon}) get _look => switch (state) {
|
||||||
|
StopState.accepted => (
|
||||||
|
color: ColorConstants.acceptGreen,
|
||||||
|
icon: LucideIcons.circleCheck,
|
||||||
|
),
|
||||||
|
// In his hands. The service accent rather than green: green is the app's
|
||||||
|
// "done" colour everywhere else, and a collected order is the opposite of
|
||||||
|
// done — it is the point at which he owes somebody a delivery.
|
||||||
|
StopState.collected => (
|
||||||
|
color: ColorConstants.serviceAccent,
|
||||||
|
icon: LucideIcons.shoppingBag,
|
||||||
|
),
|
||||||
|
StopState.active => (color: ColorConstants.primary, icon: LucideIcons.bike),
|
||||||
|
StopState.done => (
|
||||||
|
color: ColorConstants.acceptGreen,
|
||||||
|
icon: LucideIcons.badgeCheck,
|
||||||
|
),
|
||||||
|
StopState.skipped => (
|
||||||
|
color: ColorConstants.warning,
|
||||||
|
icon: LucideIcons.rotateCcw,
|
||||||
|
),
|
||||||
|
StopState.rejected => (
|
||||||
|
color: ColorConstants.secondaryText,
|
||||||
|
icon: LucideIcons.ban,
|
||||||
|
),
|
||||||
|
StopState.pending => (
|
||||||
|
color: ColorConstants.secondaryText,
|
||||||
|
icon: LucideIcons.clock,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final look = _look;
|
||||||
|
final row = Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(look.icon, size: filled ? 15.sp : 13.sp, color: look.color),
|
||||||
|
SizedBox(width: 6.w),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
state.label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: filled ? 12.5.sp : 11.sp,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
color: look.color,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!filled) return row;
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.fromLTRB(10.w, 5.h, 12.w, 5.h),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: look.color.withValues(alpha: 0.10),
|
||||||
|
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||||||
|
),
|
||||||
|
child: row,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ── The work at this stop, on one line ──
|
||||||
|
///
|
||||||
|
/// Plain text rather than tinted chips. The rule for a figure that has to
|
||||||
|
/// survive a glance is weight and contrast, not a container; three containers
|
||||||
|
/// on one row was the opposite of that, and on a combined stop they wrapped
|
||||||
|
/// onto a second line and made the card taller than its neighbours.
|
||||||
|
///
|
||||||
|
/// Only the cash is coloured. Parcel counts are labels; the money is a state
|
||||||
|
/// the rider is accountable for, and green means money throughout the app.
|
||||||
|
class _MetaLine extends StatelessWidget {
|
||||||
|
final int deliver;
|
||||||
|
final int collect;
|
||||||
|
final double cash;
|
||||||
|
final bool muted;
|
||||||
|
|
||||||
|
/// Suppresses the parcel counts, leaving only money. See the call site.
|
||||||
|
final bool countsHidden;
|
||||||
|
|
||||||
|
const _MetaLine({
|
||||||
|
required this.deliver,
|
||||||
|
required this.collect,
|
||||||
|
required this.cash,
|
||||||
|
this.muted = false,
|
||||||
|
this.countsHidden = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// True when there is nothing to say, so the caller can skip its own spacing
|
||||||
|
/// rather than laying out a gap around a zero-height widget.
|
||||||
|
bool get isEmpty =>
|
||||||
|
cash <= 0 && (countsHidden || (deliver <= 0 && collect <= 0));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
TextStyle style(Color c) => TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
color: c,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
);
|
||||||
|
|
||||||
|
final dot = TextSpan(
|
||||||
|
text: ' · ',
|
||||||
|
style: style(ColorConstants.borderStrong),
|
||||||
|
);
|
||||||
|
|
||||||
|
final body = muted
|
||||||
|
? ColorConstants.secondaryText
|
||||||
|
: ColorConstants.slateText;
|
||||||
|
|
||||||
|
final spans = <InlineSpan>[];
|
||||||
|
if (deliver > 0 && !countsHidden) {
|
||||||
|
spans.add(TextSpan(text: 'Deliver $deliver', style: style(body)));
|
||||||
|
}
|
||||||
|
if (collect > 0 && !countsHidden) {
|
||||||
|
if (spans.isNotEmpty) spans.add(dot);
|
||||||
|
spans.add(TextSpan(text: 'Collect $collect', style: style(body)));
|
||||||
|
}
|
||||||
|
if (cash > 0) {
|
||||||
|
if (spans.isNotEmpty) spans.add(dot);
|
||||||
|
spans.add(
|
||||||
|
TextSpan(
|
||||||
|
text: '₹${_trim(cash)} cash',
|
||||||
|
style: style(ColorConstants.moneyGreen),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (spans.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return Text.rich(
|
||||||
|
TextSpan(children: spans),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rupee amounts without trailing noise: `1030`, `40`, `12.5`.
|
||||||
|
String _trim(double v) {
|
||||||
|
if (v >= 100) return v.round().toString();
|
||||||
|
final rounded = (v * 10).round() / 10;
|
||||||
|
return rounded == rounded.roundToDouble()
|
||||||
|
? rounded.round().toString()
|
||||||
|
: rounded.toStringAsFixed(1);
|
||||||
|
}
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
|
||||||
import 'package:latlong2/latlong.dart' show LatLng;
|
import 'package:latlong2/latlong.dart' show LatLng;
|
||||||
import 'package:miler/views/helpers/widgets/miler_map.dart';
|
import 'package:miler/views/helpers/widgets/miler_map.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
|
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
|
||||||
|
import 'package:miler/data/milk_run.dart';
|
||||||
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||||
import 'package:miler/views/helpers/widgets/page_transitions.dart';
|
import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
|
||||||
|
import 'package:miler/views/helpers/widgets/miler_app_bar.dart'
|
||||||
|
show milerGlassSheet;
|
||||||
|
import 'package:miler/Models/stop_status.dart';
|
||||||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||||
|
|
||||||
/// ─────────────────────────────────────────────────────────────────────────
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
@@ -43,12 +47,23 @@ class StopDetailSheet extends StatelessWidget {
|
|||||||
final double? riderLat;
|
final double? riderLat;
|
||||||
final double? riderLng;
|
final double? riderLng;
|
||||||
|
|
||||||
|
/// The bag this order travels in, as the caller's store recorded it at the
|
||||||
|
/// counter. Empty falls back to the payload's own `baglabel`, and a line
|
||||||
|
/// with neither simply has no Bag row — nothing is invented.
|
||||||
|
final String bag;
|
||||||
|
|
||||||
|
/// Orders the rider is carrying — what turns a raw `pickup` row into the
|
||||||
|
/// delivery leg. Empty (Home's caller) leaves the raw kind in charge.
|
||||||
|
final Set<String> collectedIds;
|
||||||
|
|
||||||
const StopDetailSheet({
|
const StopDetailSheet({
|
||||||
super.key,
|
super.key,
|
||||||
required this.stop,
|
required this.stop,
|
||||||
required this.stopNumber,
|
required this.stopNumber,
|
||||||
this.riderLat,
|
this.riderLat,
|
||||||
this.riderLng,
|
this.riderLng,
|
||||||
|
this.bag = '',
|
||||||
|
this.collectedIds = const {},
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Opens the sheet. Kept here so callers do not each re-declare the shape.
|
/// Opens the sheet. Kept here so callers do not each re-declare the shape.
|
||||||
@@ -56,22 +71,21 @@ class StopDetailSheet extends StatelessWidget {
|
|||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required Map<String, dynamic> stop,
|
required Map<String, dynamic> stop,
|
||||||
required int stopNumber,
|
required int stopNumber,
|
||||||
|
String bag = '',
|
||||||
|
Set<String> collectedIds = const {},
|
||||||
double? riderLat,
|
double? riderLat,
|
||||||
double? riderLng,
|
double? riderLng,
|
||||||
}) {
|
}) {
|
||||||
return showModalBottomSheet<void>(
|
|
||||||
context: context,
|
|
||||||
// The tall variant: this sheet opens at 78% of the screen, so it has a
|
// The tall variant: this sheet opens at 78% of the screen, so it has a
|
||||||
// long way to travel and gets the time to do it.
|
// long way to travel and gets the time to do it.
|
||||||
sheetAnimationStyle: kMilerLargeSheetStyle,
|
return showMilerSheet<void>(
|
||||||
isScrollControlled: true,
|
context,
|
||||||
backgroundColor: ColorConstants.pureSurface,
|
large: true,
|
||||||
shape: const RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
|
||||||
),
|
|
||||||
builder: (_) => StopDetailSheet(
|
builder: (_) => StopDetailSheet(
|
||||||
stop: stop,
|
stop: stop,
|
||||||
stopNumber: stopNumber,
|
stopNumber: stopNumber,
|
||||||
|
bag: bag,
|
||||||
|
collectedIds: collectedIds,
|
||||||
riderLat: riderLat,
|
riderLat: riderLat,
|
||||||
riderLng: riderLng,
|
riderLng: riderLng,
|
||||||
),
|
),
|
||||||
@@ -94,15 +108,65 @@ class StopDetailSheet extends StatelessWidget {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The leg actually being worked, not the row's raw label.
|
||||||
|
///
|
||||||
|
/// `stopKindOf` reads the payload's `type`, which on a collected milk-run
|
||||||
|
/// stop still says *pickup* — so this sheet was captioning the customer's
|
||||||
|
/// door "PICKUP LOCATION / Collect 1 parcel" while its own CTA (driven by
|
||||||
|
/// [MilkRun.navigatesToCustomer]) said "Navigate to customer". One sheet,
|
||||||
|
/// two legs. [MilkRun.workingKind] is the ladder the card reads; the sheet
|
||||||
|
/// now reads the same rung.
|
||||||
|
StopKind get _kind => MilkRun.workingKind(stop, collectedIds: collectedIds);
|
||||||
|
|
||||||
|
/// Whether the job at this stop is the delivery leg — the one case where
|
||||||
|
/// "where is this stop" is the drop, not the collection point.
|
||||||
|
bool get _isDropLeg {
|
||||||
|
final kind = _kind;
|
||||||
|
return kind.hasDelivery && !kind.hasPickup;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final kind = stopKindOf(stop);
|
final kind = _kind;
|
||||||
final lat = _d(stop['pickuplat'] ?? stop['PickupLat']);
|
|
||||||
final lng = _d(stop['pickuplon'] ?? stop['PickupLon']);
|
// ── The address and the pin follow the LEG ────────────────────────────
|
||||||
|
//
|
||||||
|
// This sheet opens from two tabs. On Home every stop is a collection and
|
||||||
|
// the pickup keys are the job; on Deliveries every stop is a drop — and
|
||||||
|
// the sheet read `pickupaddress`/`pickuplat` regardless. So a rider
|
||||||
|
// opening a drop's details met **`DELIVER TO` over the kitchen's street**,
|
||||||
|
// a map pinned to the counter he had already left, and a distance line
|
||||||
|
// measuring the ride back to it. Same family as the record's Route bug,
|
||||||
|
// fixed the same way: the drop keys lead on a drop, and a payload that
|
||||||
|
// carries only one address still shows that one.
|
||||||
|
//
|
||||||
|
// (`RouteMetricsHelper.metersToStop` has the same pickup-only read and
|
||||||
|
// feeds the cards' distance column — noted in ABOUT_MILER.md's gaps
|
||||||
|
// rather than changed here, because every card and ETA reads through it.)
|
||||||
|
final drop = _isDropLeg;
|
||||||
|
double leg(List<String> preferred, List<String> fallback) {
|
||||||
|
final v = _d(stop[preferred[0]] ?? stop[preferred[1]]);
|
||||||
|
return v != 0 ? v : _d(stop[fallback[0]] ?? stop[fallback[1]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
final lat = drop
|
||||||
|
? leg(const ['droplat', 'DropLat'], const ['pickuplat', 'PickupLat'])
|
||||||
|
: _d(stop['pickuplat'] ?? stop['PickupLat']);
|
||||||
|
final lng = drop
|
||||||
|
? leg(const ['droplon', 'DropLon'], const ['pickuplon', 'PickupLon'])
|
||||||
|
: _d(stop['pickuplon'] ?? stop['PickupLon']);
|
||||||
final hasLocation = lat != 0 && lng != 0;
|
final hasLocation = lat != 0 && lng != 0;
|
||||||
|
|
||||||
final name = _s(['pickupcustomer', 'tenantname']);
|
final name = _s(['pickupcustomer', 'tenantname']);
|
||||||
final address = _s(['pickupaddress', 'PickupAddress']);
|
final address = drop
|
||||||
|
? _s([
|
||||||
|
'dropaddress',
|
||||||
|
'DropAddress',
|
||||||
|
'deliveryaddress',
|
||||||
|
'pickupaddress',
|
||||||
|
'PickupAddress',
|
||||||
|
])
|
||||||
|
: _s(['pickupaddress', 'PickupAddress']);
|
||||||
final phone = _s(['pickupcontactno']);
|
final phone = _s(['pickupcontactno']);
|
||||||
final notes = _s(['notes', 'Notes']);
|
final notes = _s(['notes', 'Notes']);
|
||||||
final otp = stopOtp(stop);
|
final otp = stopOtp(stop);
|
||||||
@@ -110,10 +174,14 @@ class StopDetailSheet extends StatelessWidget {
|
|||||||
final collect = pickupParcelCount(stop);
|
final collect = pickupParcelCount(stop);
|
||||||
final cash = stopCollectionAmount(stop);
|
final cash = stopCollectionAmount(stop);
|
||||||
|
|
||||||
final meters = RouteMetricsHelper.metersToStop(
|
// Through the leg's own coordinates — `metersToStop` carries the leg flag
|
||||||
|
// now, so the sheet and the cards compute the same number from the same
|
||||||
|
// reader instead of two implementations agreeing by luck.
|
||||||
|
final double? meters = RouteMetricsHelper.metersToStop(
|
||||||
stop,
|
stop,
|
||||||
riderLat: riderLat,
|
riderLat: riderLat,
|
||||||
riderLng: riderLng,
|
riderLng: riderLng,
|
||||||
|
toDrop: drop,
|
||||||
);
|
);
|
||||||
|
|
||||||
return DraggableScrollableSheet(
|
return DraggableScrollableSheet(
|
||||||
@@ -121,17 +189,12 @@ class StopDetailSheet extends StatelessWidget {
|
|||||||
minChildSize: 0.5,
|
minChildSize: 0.5,
|
||||||
maxChildSize: 0.95,
|
maxChildSize: 0.95,
|
||||||
expand: false,
|
expand: false,
|
||||||
builder: (context, scrollController) => Column(
|
// The same frosted fabric as every other sheet. This one and the
|
||||||
|
// preview were the two opaque holdouts — see the sheet kit's audit.
|
||||||
|
builder: (context, scrollController) => milerGlassSheet(
|
||||||
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(height: 10.h),
|
const MilerSheetHandle(),
|
||||||
Container(
|
|
||||||
width: 40.w,
|
|
||||||
height: 4.h,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: ColorConstants.borderStrong,
|
|
||||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView(
|
child: ListView(
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
@@ -155,7 +218,7 @@ class StopDetailSheet extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14.5.sp,
|
fontSize: 14.5.sp,
|
||||||
height: 1.45,
|
height: 1.45,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w500,
|
||||||
color: ColorConstants.slateText,
|
color: ColorConstants.slateText,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
),
|
),
|
||||||
@@ -166,18 +229,16 @@ class StopDetailSheet extends StatelessWidget {
|
|||||||
SizedBox(height: 8.h),
|
SizedBox(height: 8.h),
|
||||||
if (kind.hasDelivery)
|
if (kind.hasDelivery)
|
||||||
_actionRow(
|
_actionRow(
|
||||||
icon: Icons.local_shipping_rounded,
|
icon: LucideIcons.truck,
|
||||||
color: ColorConstants.deliveryAccent,
|
color: ColorConstants.deliveryAccent,
|
||||||
title:
|
title: _countLabel('Deliver', deliver),
|
||||||
'Deliver ${deliver > 0 ? deliver : 1} parcel${deliver == 1 ? '' : 's'}',
|
|
||||||
detail: otp.isNotEmpty ? 'OTP $otp' : 'OTP + photo proof',
|
detail: otp.isNotEmpty ? 'OTP $otp' : 'OTP + photo proof',
|
||||||
),
|
),
|
||||||
if (kind.hasPickup)
|
if (kind.hasPickup)
|
||||||
_actionRow(
|
_actionRow(
|
||||||
icon: Icons.inventory_2_rounded,
|
icon: LucideIcons.package,
|
||||||
color: ColorConstants.pickupAccent,
|
color: ColorConstants.pickupAccent,
|
||||||
title:
|
title: _countLabel('Collect', collect),
|
||||||
'Collect ${collect > 0 ? collect : 1} parcel${collect == 1 ? '' : 's'}',
|
|
||||||
detail: cash > 0
|
detail: cash > 0
|
||||||
? 'Collect ₹${cash.round()}'
|
? 'Collect ₹${cash.round()}'
|
||||||
: 'Photo + weight',
|
: 'Photo + weight',
|
||||||
@@ -195,24 +256,38 @@ class StopDetailSheet extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "Collect 1 parcel", "Deliver 3 parcels". The count that is *shown* picks
|
||||||
|
/// the word — the old inline form defaulted a zero count to the figure 1
|
||||||
|
/// while pluralising off the raw zero, and printed "Collect 1 parcels".
|
||||||
|
static String _countLabel(String verb, int count) {
|
||||||
|
final n = count > 0 ? count : 1;
|
||||||
|
return '$verb $n parcel${n == 1 ? '' : 's'}';
|
||||||
|
}
|
||||||
|
|
||||||
Widget _header(StopKind kind, String name) {
|
Widget _header(StopKind kind, String name) {
|
||||||
return Row(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// Tonal, like every disc in the app — this was the last solid-filled
|
||||||
|
// badge left, a saturated coloured coin above the customer's name.
|
||||||
Container(
|
Container(
|
||||||
width: 34.w,
|
width: 34.w,
|
||||||
height: 34.w,
|
height: 34.w,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(color: kind.accent, shape: BoxShape.circle),
|
decoration: BoxDecoration(
|
||||||
|
color: kind.accent.withValues(alpha: 0.12),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'$stopNumber',
|
'$stopNumber',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15.sp,
|
fontSize: 15.sp,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
color: Colors.white,
|
color: kind.accent,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -222,35 +297,32 @@ class StopDetailSheet extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
// ── The type chip is gone from the header ──
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h),
|
//
|
||||||
decoration: BoxDecoration(
|
// A filled "MILK" / "DELIVERY" tab sat ABOVE the customer's
|
||||||
color: kind.accent,
|
// name — category before subject, in a solid service colour,
|
||||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
// on the one surface where no card idiom shows that chip any
|
||||||
),
|
// more. What the stop *is* the sheet already states twice, in
|
||||||
child: Text(
|
// words: the WHAT TO DO HERE row and the grid's From/Bag
|
||||||
kind.badgeLabel,
|
// lines. The name leads now, as it does on every card.
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 10.sp,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
letterSpacing: 0.3,
|
|
||||||
color: Colors.white,
|
|
||||||
fontFamily: FontConstants.fontFamily,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: 6.h),
|
|
||||||
Text(
|
Text(
|
||||||
name.isEmpty ? 'Stop $stopNumber' : name,
|
name.isEmpty ? 'Stop $stopNumber' : name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20.sp,
|
fontSize: 20.sp,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: -0.5,
|
letterSpacing: -0.5,
|
||||||
height: 1.15,
|
height: 1.15,
|
||||||
color: ColorConstants.slateText,
|
color: ColorConstants.slateText,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// ── The one fact the sheet never stated ──
|
||||||
|
//
|
||||||
|
// Everything else here is reference; where this stop IS in its
|
||||||
|
// lifecycle was only knowable from the card underneath the
|
||||||
|
// barrier. The word, glyph and colour come from the canonical
|
||||||
|
// vocabulary ([StopStatusX]) — never a switch written here.
|
||||||
|
_statusLine(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -258,6 +330,34 @@ class StopDetailSheet extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _statusLine() {
|
||||||
|
final status = stopStatusOf(stop);
|
||||||
|
if (status == StopStatus.unknown) return const SizedBox.shrink();
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(top: 5.h),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(status.icon, size: 14.sp, color: status.color),
|
||||||
|
SizedBox(width: 5.w),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
status.label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13.sp,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: status.color,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A small, non-interactive map. Orientation only — it answers "which side of
|
/// A small, non-interactive map. Orientation only — it answers "which side of
|
||||||
/// town is this?" in one glance and then gets out of the way.
|
/// town is this?" in one glance and then gets out of the way.
|
||||||
///
|
///
|
||||||
@@ -278,11 +378,6 @@ class StopDetailSheet extends StatelessWidget {
|
|||||||
/// is already paid for by [AfterEntrance] holding it back, so nothing is lost
|
/// is already paid for by [AfterEntrance] holding it back, so nothing is lost
|
||||||
/// by dropping it.
|
/// by dropping it.
|
||||||
Widget _map(double lat, double lng, StopKind kind) {
|
Widget _map(double lat, double lng, StopKind kind) {
|
||||||
final stopPos = LatLng(lat, lng);
|
|
||||||
final hasRider =
|
|
||||||
riderLat != null && riderLng != null && riderLat != 0 && riderLng != 0;
|
|
||||||
final riderPos = hasRider ? LatLng(riderLat!, riderLng!) : null;
|
|
||||||
|
|
||||||
return ClipRRect(
|
return ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
@@ -301,45 +396,21 @@ class StopDetailSheet extends StatelessWidget {
|
|||||||
// later, which is what Uber's own sheets do.
|
// later, which is what Uber's own sheets do.
|
||||||
child: AfterEntrance(
|
child: AfterEntrance(
|
||||||
placeholder: MapPlaceholder(radius: 14.r),
|
placeholder: MapPlaceholder(radius: 14.r),
|
||||||
builder: (context) => IgnorePointer(
|
// The leg, drawn honestly — the real road from OSRM, both endpoints
|
||||||
// Not interactive: a pannable map inside a draggable sheet fights
|
// pinned, camera fitted to the pair. This was a solid straight line
|
||||||
// the sheet's own gestures, and this is not where routing happens.
|
// to a fixed-zoom frame: a rider 4 km out watched the stroke leave
|
||||||
// [MilerMap] carries its own gesture set; the IgnorePointer above it
|
// the screen with no second point anywhere on it. See [MilerLegMap].
|
||||||
// is what actually settles the argument.
|
builder: (context) => MilerLegMap(
|
||||||
child: MilerMap(
|
to: LatLng(lat, lng),
|
||||||
initialCenter: stopPos,
|
from:
|
||||||
initialZoom: 14.5,
|
(riderLat != null &&
|
||||||
polylines: [
|
riderLng != null &&
|
||||||
if (riderPos != null)
|
riderLat != 0 &&
|
||||||
Polyline(
|
riderLng != 0)
|
||||||
points: [riderPos, stopPos],
|
? LatLng(riderLat!, riderLng!)
|
||||||
color: kind.accent,
|
: null,
|
||||||
strokeWidth: 4,
|
accent: kind.accent,
|
||||||
strokeCap: StrokeCap.round,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
markers: [
|
|
||||||
milerMarker(
|
|
||||||
point: stopPos,
|
|
||||||
width: MilerPin.size + 8,
|
|
||||||
height: MilerPin.size + MilerPin.tail,
|
|
||||||
// The stop's own accent, not one of nine stock hues — the same
|
|
||||||
// colour as the chip on the card this sheet opened from.
|
|
||||||
child: MilerPin(
|
|
||||||
color: kind.accent,
|
|
||||||
icon: kind.icon,
|
icon: kind.icon,
|
||||||
emphasised: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (riderPos != null)
|
|
||||||
milerMarker(
|
|
||||||
point: riderPos,
|
|
||||||
width: MilerRiderDot.size,
|
|
||||||
height: MilerRiderDot.size,
|
|
||||||
child: const MilerRiderDot(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -351,33 +422,45 @@ class StopDetailSheet extends StatelessWidget {
|
|||||||
final travel = RouteMetricsHelper.travelTime(meters);
|
final travel = RouteMetricsHelper.travelTime(meters);
|
||||||
if (distance == '—') return const SizedBox.shrink();
|
if (distance == '—') return const SizedBox.shrink();
|
||||||
|
|
||||||
|
// Both figures are Flexible: the pair was rigid, and rigid text in a Row
|
||||||
|
// is a right-edge overflow waiting for a large text scale — the sheet
|
||||||
|
// sweep caught exactly that. The distance keeps priority; the ride time
|
||||||
|
// is the half that gives way.
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.near_me_rounded,
|
LucideIcons.navigation,
|
||||||
size: 16.sp,
|
size: 16.sp,
|
||||||
color: ColorConstants.secondaryText,
|
color: ColorConstants.secondaryText,
|
||||||
),
|
),
|
||||||
SizedBox(width: 6.w),
|
SizedBox(width: 6.w),
|
||||||
Text(
|
Flexible(
|
||||||
|
child: Text(
|
||||||
'$distance away',
|
'$distance away',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14.sp,
|
fontSize: 14.sp,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
color: ColorConstants.slateText,
|
color: ColorConstants.slateText,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
if (travel > Duration.zero) ...[
|
if (travel > Duration.zero) ...[
|
||||||
Text(
|
Flexible(
|
||||||
|
child: Text(
|
||||||
' · ${RouteMetricsHelper.formatDuration(travel)} ride',
|
' · ${RouteMetricsHelper.formatDuration(travel)} ride',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13.sp,
|
fontSize: 13.sp,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w600,
|
||||||
color: ColorConstants.secondaryText,
|
color: ColorConstants.secondaryText,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -387,7 +470,7 @@ class StopDetailSheet extends StatelessWidget {
|
|||||||
text,
|
text,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 10.sp,
|
fontSize: 10.sp,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 0.8,
|
letterSpacing: 0.8,
|
||||||
color: ColorConstants.secondaryText,
|
color: ColorConstants.secondaryText,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
@@ -414,26 +497,59 @@ class StopDetailSheet extends StatelessWidget {
|
|||||||
child: Icon(icon, size: 17.sp, color: color),
|
child: Icon(icon, size: 17.sp, color: color),
|
||||||
),
|
),
|
||||||
SizedBox(width: 10.w),
|
SizedBox(width: 10.w),
|
||||||
|
// ── The task and what it asks for, stacked ──
|
||||||
|
//
|
||||||
|
// These were side by side, with the requirement pushed hard against
|
||||||
|
// the right edge — so "OTP + photo proof" was the one line on the
|
||||||
|
// sheet cut off first at large text, and at any size it read as a
|
||||||
|
// column heading rather than as a condition of the job above it.
|
||||||
|
//
|
||||||
|
// Under the title it is plainly a qualifier, it has the full width
|
||||||
|
// to wrap into, and the row can no longer overflow.
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
title,
|
title,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15.sp,
|
fontSize: 15.sp,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
color: ColorConstants.slateText,
|
color: ColorConstants.slateText,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (detail.isNotEmpty) ...[
|
||||||
|
SizedBox(height: 3.h),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
LucideIcons.squareCheckBig,
|
||||||
|
size: 12.sp,
|
||||||
|
color: ColorConstants.secondaryText,
|
||||||
),
|
),
|
||||||
Text(
|
SizedBox(width: 4.w),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
detail,
|
detail,
|
||||||
|
maxLines: 2,
|
||||||
|
// Secondary, not the leg accent: set in link blue it
|
||||||
|
// read as something to tap, and it is a statement.
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13.sp,
|
fontSize: 12.5.sp,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w600,
|
||||||
color: color,
|
color: ColorConstants.secondaryText,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -441,6 +557,15 @@ class StopDetailSheet extends StatelessWidget {
|
|||||||
|
|
||||||
Widget _detailGrid() {
|
Widget _detailGrid() {
|
||||||
final rows = <MapEntry<String, String>>[
|
final rows = <MapEntry<String, String>>[
|
||||||
|
// Whose counter the bag came off — the fact the delivery card carries
|
||||||
|
// ("PICKED UP FROM …") and this sheet used to drop. Only on the drop
|
||||||
|
// leg: on a pickup the header IS the source context. One reader,
|
||||||
|
// [stopSourceName], as the project rule demands.
|
||||||
|
MapEntry('From', _isDropLeg ? stopSourceName(stop) : ''),
|
||||||
|
// The bag is what a rider matches against a shelf, so it outranks the
|
||||||
|
// reference. Stored pairing first ([bag], written at the counter), the
|
||||||
|
// kitchen-printed label as fallback, absent on a line with no load.
|
||||||
|
MapEntry('Bag', bag.isNotEmpty ? bag : _s(['baglabel', 'BagLabel'])),
|
||||||
MapEntry('Order', _s(['orderid', 'OrderId'])),
|
MapEntry('Order', _s(['orderid', 'OrderId'])),
|
||||||
MapEntry('Product', _s(['description', 'productname', 'product'])),
|
MapEntry('Product', _s(['description', 'productname', 'product'])),
|
||||||
MapEntry('Weight', () {
|
MapEntry('Weight', () {
|
||||||
@@ -457,42 +582,124 @@ class StopDetailSheet extends StatelessWidget {
|
|||||||
|
|
||||||
if (rows.isEmpty) return const SizedBox.shrink();
|
if (rows.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
return Column(
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(top: 16.h),
|
||||||
|
child: Container(
|
||||||
|
padding: EdgeInsets.fromLTRB(4.w, 4.h, 4.w, 4.h),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: ColorConstants.pureSurface,
|
||||||
|
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||||
|
border: Border.all(color: ColorConstants.borderSubtle, width: 1.2),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Divider(height: 20.h, color: ColorConstants.borderSubtle),
|
for (var i = 0; i < rows.length; i += 2) ...[
|
||||||
for (final r in rows)
|
if (i > 0)
|
||||||
Padding(
|
Container(height: 1, color: ColorConstants.borderSubtle),
|
||||||
padding: EdgeInsets.only(bottom: 8.h),
|
// Intrinsic, so the vertical hairline takes the height of the
|
||||||
|
// taller of the two tiles. `stretch` alone asks for infinity
|
||||||
|
// inside a scrolling column.
|
||||||
|
IntrinsicHeight(
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
Expanded(child: _factTile(rows[i])),
|
||||||
width: 110.w,
|
// A single trailing fact takes the full row rather than half
|
||||||
child: Text(
|
// of one — a lone tile beside empty space reads as a missing
|
||||||
r.key,
|
// value, which on a sheet full of real ones is alarming.
|
||||||
|
if (i + 1 < rows.length) ...[
|
||||||
|
Container(width: 1, color: ColorConstants.borderSubtle),
|
||||||
|
Expanded(child: _factTile(rows[i + 1])),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// ── Rules between rows, not around every cell ──
|
||||||
|
//
|
||||||
|
// A grid drawn as boxes is a spreadsheet; drawn as one surface cut
|
||||||
|
// by hairlines it is a set of facts. The last row gets none, so
|
||||||
|
// the block closes on its own edge.
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One label-over-value fact.
|
||||||
|
///
|
||||||
|
/// ── Why this stopped being a table ──
|
||||||
|
///
|
||||||
|
/// It was `label ── value` rows on the sheet's own grey, with a fixed 110pt
|
||||||
|
/// label column. Two problems, both of them about reading it at a gate. The
|
||||||
|
/// values — the half a rider actually needs — sat in a ragged right column
|
||||||
|
/// and had to be found one at a time; and a long one ("SNS Colony,
|
||||||
|
/// Peelamedu") wrapped under a wide empty label, so the block grew a hole in
|
||||||
|
/// the middle of it.
|
||||||
|
///
|
||||||
|
/// Stacked, the value is the big line and the label is the small one above
|
||||||
|
/// it. Two to a row, so the whole set is scanned in a couple of saccades
|
||||||
|
/// rather than seven.
|
||||||
|
Widget _factTile(MapEntry<String, String> fact) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(12.w, 11.h, 12.w, 11.h),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
fact.key.toUpperCase(),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13.sp,
|
fontSize: 9.5.sp,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.9,
|
||||||
color: ColorConstants.secondaryText,
|
color: ColorConstants.secondaryText,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
SizedBox(height: 3.h),
|
||||||
Expanded(
|
// ── A reference is one token, so it renders as one ──
|
||||||
|
//
|
||||||
|
// `DM-BK-BDD9EF53-35734` in a half-width tile wrapped at a hyphen —
|
||||||
|
// "DM-BK-" over "BDD9EF53-35734" — which is exactly how a reference
|
||||||
|
// gets misread down a phone to the office. Anything with no spaces
|
||||||
|
// in it is a token, not prose: it shrinks to fit its line instead
|
||||||
|
// of breaking. Real sentences keep the two-line wrap.
|
||||||
|
if (!fact.value.contains(' ') && fact.value.length > 12)
|
||||||
|
FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
child: Text(
|
child: Text(
|
||||||
r.value,
|
fact.value,
|
||||||
|
maxLines: 1,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13.5.sp,
|
fontSize: 14.sp,
|
||||||
|
height: 1.25,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: -0.2,
|
||||||
color: ColorConstants.slateText,
|
color: ColorConstants.slateText,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
],
|
else
|
||||||
|
Text(
|
||||||
|
fact.value,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
height: 1.25,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
color: ColorConstants.slateText,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,7 +717,7 @@ class StopDetailSheet extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.sticky_note_2_rounded,
|
LucideIcons.stickyNote,
|
||||||
size: 17.sp,
|
size: 17.sp,
|
||||||
color: ColorConstants.warning,
|
color: ColorConstants.warning,
|
||||||
),
|
),
|
||||||
@@ -521,7 +728,7 @@ class StopDetailSheet extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13.sp,
|
fontSize: 13.sp,
|
||||||
height: 1.4,
|
height: 1.4,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w600,
|
||||||
color: ColorConstants.onErrorContainer,
|
color: ColorConstants.onErrorContainer,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
),
|
),
|
||||||
@@ -545,7 +752,7 @@ class StopDetailSheet extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: MilerButton(
|
child: MilerButton(
|
||||||
label: 'Call',
|
label: 'Call',
|
||||||
icon: Icons.call_rounded,
|
icon: LucideIcons.phone,
|
||||||
variant: MilerButtonVariant.outlined,
|
variant: MilerButtonVariant.outlined,
|
||||||
color: ColorConstants.acceptGreen,
|
color: ColorConstants.acceptGreen,
|
||||||
onPressed: () => _dial(phone),
|
onPressed: () => _dial(phone),
|
||||||
@@ -555,11 +762,26 @@ class StopDetailSheet extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 2,
|
flex: 2,
|
||||||
child: MilerButton(
|
child: Builder(
|
||||||
label: 'Navigate',
|
builder: (_) {
|
||||||
icon: Icons.navigation_rounded,
|
// ── Where this button goes depends on the stop's stage ──
|
||||||
|
//
|
||||||
|
// Before collection it opens the kitchen; after collection it
|
||||||
|
// opens the customer. The rule lives in [MilkRun] so the sheet,
|
||||||
|
// the card and the deliveries tab cannot drift apart about it —
|
||||||
|
// and it returns null rather than guessing when the stop carries
|
||||||
|
// no coordinates for the leg it is on.
|
||||||
|
final target = MilkRun.navigationTarget(stop);
|
||||||
|
final toCustomer = MilkRun.navigatesToCustomer(stop);
|
||||||
|
return MilerButton(
|
||||||
|
label: toCustomer ? 'Navigate to customer' : 'Navigate',
|
||||||
|
icon: LucideIcons.navigation,
|
||||||
color: ColorConstants.primary,
|
color: ColorConstants.primary,
|
||||||
onPressed: hasLocation ? () => _navigate(lat, lng) : null,
|
onPressed: target == null
|
||||||
|
? null
|
||||||
|
: () => _navigate(target.lat, target.lng),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import 'dart:math' as math;
|
|||||||
|
|
||||||
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||||||
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
|
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
|
||||||
|
import 'package:miler/data/route_order.dart';
|
||||||
|
import 'package:miler/data/service_profile.dart';
|
||||||
|
import 'package:miler/Models/stop_status.dart';
|
||||||
|
|
||||||
/// ─────────────────────────────────────────────────────────────────────────
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
/// A TRIP — one slot's worth of work, as a single unit.
|
/// A TRIP — one slot's worth of work, as a single unit.
|
||||||
@@ -24,6 +27,147 @@ import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
|
|||||||
/// This class is pure — no Flutter, no I/O — so the arithmetic is unit-tested
|
/// This class is pure — no Flutter, no I/O — so the arithmetic is unit-tested
|
||||||
/// and safe to call from `build`.
|
/// and safe to call from `build`.
|
||||||
/// ─────────────────────────────────────────────────────────────────────────
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// WHICH TRIP A STOP IS ON
|
||||||
|
///
|
||||||
|
/// A rider's day is three trips, and which one an order lands on is decided by
|
||||||
|
/// **when the work is due**, not by how many buckets happen to have filled up:
|
||||||
|
///
|
||||||
|
/// morning → Trip 1
|
||||||
|
/// afternoon → Trip 2
|
||||||
|
/// evening → Trip 3
|
||||||
|
///
|
||||||
|
/// ── What this replaces ──
|
||||||
|
///
|
||||||
|
/// Stops were bucketed into rolling three-hour windows aligned to the clock —
|
||||||
|
/// 09:00–12:00, 12:00–15:00, and so on — which produces up to eight buckets a
|
||||||
|
/// day, folded down to three at the end. Two consequences, both wrong on a
|
||||||
|
/// real morning: an 08:30 stop and an 11:00 stop are both *the morning run*
|
||||||
|
/// and landed on different trips, and which trip a stop appeared on depended
|
||||||
|
/// on what else was in the day. A rider with only afternoon work saw it as
|
||||||
|
/// "Trip 1", so his Trip 2 and the hub's Trip 2 were different things.
|
||||||
|
///
|
||||||
|
/// The boundaries are fixed here rather than derived, because they are a
|
||||||
|
/// business fact about the shift and not something to infer from the data.
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
enum DayPart {
|
||||||
|
morning('Morning'),
|
||||||
|
afternoon('Afternoon'),
|
||||||
|
evening('Evening');
|
||||||
|
|
||||||
|
const DayPart(this.label);
|
||||||
|
|
||||||
|
/// What this part of the day is called, on a tab and in a sentence.
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// Afternoon begins at noon.
|
||||||
|
static const int afternoonFromHour = 12;
|
||||||
|
|
||||||
|
/// Evening begins at 5pm.
|
||||||
|
static const int eveningFromHour = 17;
|
||||||
|
|
||||||
|
/// The day part [at] falls in.
|
||||||
|
static DayPart of(DateTime at) {
|
||||||
|
if (at.hour < afternoonFromHour) return DayPart.morning;
|
||||||
|
if (at.hour < eveningFromHour) return DayPart.afternoon;
|
||||||
|
return DayPart.evening;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The window this part covers on [day].
|
||||||
|
static (DateTime, DateTime) windowOn(DateTime day, DayPart part) {
|
||||||
|
final midnight = DateTime(day.year, day.month, day.day);
|
||||||
|
return switch (part) {
|
||||||
|
DayPart.morning => (
|
||||||
|
midnight,
|
||||||
|
midnight.add(const Duration(hours: afternoonFromHour)),
|
||||||
|
),
|
||||||
|
DayPart.afternoon => (
|
||||||
|
midnight.add(const Duration(hours: afternoonFromHour)),
|
||||||
|
midnight.add(const Duration(hours: eveningFromHour)),
|
||||||
|
),
|
||||||
|
DayPart.evening => (
|
||||||
|
midnight.add(const Duration(hours: eveningFromHour)),
|
||||||
|
midnight.add(const Duration(days: 1)),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when [from]–[to] is exactly one day part's window, which is how
|
||||||
|
/// [Trip.slotLabel] knows to say "Morning" instead of a clock range.
|
||||||
|
static bool spans(DateTime from, DateTime to) {
|
||||||
|
final (start, end) = windowOn(from, of(from));
|
||||||
|
return from.isAtSameMomentAs(start) && to.isAtSameMomentAs(end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The three slots of a day, whether or not the rider has work in each.
|
||||||
|
///
|
||||||
|
/// Trip 2 is the afternoon even on a day with no morning work — the number is
|
||||||
|
/// the day part, so the rider's "Trip 2" and the hub's are the same run.
|
||||||
|
extension TripSlots on List<Trip> {
|
||||||
|
/// The day laid out as slots: index 0 is the morning, 1 the afternoon, 2 the
|
||||||
|
/// evening, and an empty slot is a part of the day with no work in it.
|
||||||
|
///
|
||||||
|
/// Dated trips claim their own part. Anything undated — a tenant that sends
|
||||||
|
/// no times, demo data, a trip whose stops carry only addresses — falls back
|
||||||
|
/// to the first free slot, in order, because a trip that cannot be placed
|
||||||
|
/// still has to be reachable. Guessing a part from the wall clock instead
|
||||||
|
/// would quietly move such a trip from Trip 1 to Trip 2 at noon.
|
||||||
|
List<Trip?> get slots {
|
||||||
|
final out = List<Trip?>.filled(DayPart.values.length, null, growable: true);
|
||||||
|
|
||||||
|
// ── Day parts place the trips only when they can place all of them ──
|
||||||
|
//
|
||||||
|
// A trip claims the slot its part names — that is what makes an
|
||||||
|
// afternoon-only day read as Trip 2 rather than Trip 1. It works because
|
||||||
|
// the common day is one run per part.
|
||||||
|
//
|
||||||
|
// When two trips want the *same* part it stops working, and the first
|
||||||
|
// attempt at patching it made things worse: the second trip took the first
|
||||||
|
// free slot anywhere, so on an evening with two evening runs the tabs came
|
||||||
|
// out `[2, 0]` — trip one under Trip 3, trip two under Trip 1, inverted,
|
||||||
|
// and only between 5pm and midnight. A layout that depends on the hour the
|
||||||
|
// rider opened the screen is worse than one that ignores day parts.
|
||||||
|
//
|
||||||
|
// So it is all or nothing. Every trip has its own part, or the run is laid
|
||||||
|
// out in the order it arrived — which is already sorted, and which is the
|
||||||
|
// honest answer when the data does not fit the model.
|
||||||
|
final parts = <int>{};
|
||||||
|
var distinct = true;
|
||||||
|
for (final t in this) {
|
||||||
|
final p = t.dayPart?.index;
|
||||||
|
if (p == null || !parts.add(p)) {
|
||||||
|
distinct = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (distinct) {
|
||||||
|
for (final t in this) {
|
||||||
|
out[t.dayPart!.index] = t;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < length; i++) {
|
||||||
|
if (i < out.length) {
|
||||||
|
out[i] = this[i];
|
||||||
|
} else {
|
||||||
|
out.add(this[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The trip in slot [index] (0 = morning), or null when nothing is assigned
|
||||||
|
/// to that part of the day.
|
||||||
|
Trip? tripAt(int index) {
|
||||||
|
final s = slots;
|
||||||
|
return index >= 0 && index < s.length ? s[index] : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class Trip {
|
class Trip {
|
||||||
/// Stable identity for this trip (backend slot/trip id, or a derived key).
|
/// Stable identity for this trip (backend slot/trip id, or a derived key).
|
||||||
final String id;
|
final String id;
|
||||||
@@ -82,6 +226,10 @@ class Trip {
|
|||||||
final s = slotStart;
|
final s = slotStart;
|
||||||
final e = slotEnd;
|
final e = slotEnd;
|
||||||
if (s == null || e == null) return "Today's route";
|
if (s == null || e == null) return "Today's route";
|
||||||
|
// A day part is named, not spelled out as a clock range: "Morning" is what
|
||||||
|
// the rider and the hub both call it, and "5:00 AM – 12:00 PM" says the
|
||||||
|
// same thing in seven characters more and one reading step.
|
||||||
|
if (DayPart.spans(s, e)) return DayPart.of(s).label;
|
||||||
return '${RouteMetricsHelper.formatClock(s)} – '
|
return '${RouteMetricsHelper.formatClock(s)} – '
|
||||||
'${RouteMetricsHelper.formatClock(e)}';
|
'${RouteMetricsHelper.formatClock(e)}';
|
||||||
}
|
}
|
||||||
@@ -167,8 +315,43 @@ class Trip {
|
|||||||
rejectedIds: rejectedIds,
|
rejectedIds: rejectedIds,
|
||||||
).any((s) => s.needsDecision);
|
).any((s) => s.needsDecision);
|
||||||
|
|
||||||
/// Short tab label — `Trip 1`.
|
/// Short tab label for a **slot** — `Trip 1`.
|
||||||
static String tabLabel(int index) => 'Trip ${index + 1}';
|
///
|
||||||
|
/// The slot is the day part, not a running count: slot 0 is the morning
|
||||||
|
/// whether or not the rider has morning work. See [DayPart].
|
||||||
|
static String tabLabel(int index) =>
|
||||||
|
'Trip ${index + 1}'; // slot index is the day part's index
|
||||||
|
|
||||||
|
/// Which part of the day this trip belongs to — **null when nothing on it is
|
||||||
|
/// dated**.
|
||||||
|
///
|
||||||
|
/// Taken from the slot the grouping put it in. A trip the backend named with
|
||||||
|
/// its own `tripid` still gets one, from whatever window its stops fall in.
|
||||||
|
///
|
||||||
|
/// Null matters: a trip with no times at all cannot be placed by day part,
|
||||||
|
/// and guessing one from the wall clock would move it between tabs as the
|
||||||
|
/// afternoon wore on. Those keep their position instead — see
|
||||||
|
/// [TripSlots.slots].
|
||||||
|
DayPart? get dayPart {
|
||||||
|
final at = slotStart ?? _earliestDue;
|
||||||
|
return at == null ? null : DayPart.of(at);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `1`, `2` or `3` — the number the rider and the hub both use. Zero when
|
||||||
|
/// this trip has no time to place it by.
|
||||||
|
int get tripNumber => (dayPart?.index ?? -1) + 1;
|
||||||
|
|
||||||
|
DateTime? get _earliestDue {
|
||||||
|
DateTime? best;
|
||||||
|
for (final s in stops) {
|
||||||
|
final due = _parseTimestamp(
|
||||||
|
s['expected_pickup_time'] ?? s['expectedpickuptime'] ?? s['eta'],
|
||||||
|
);
|
||||||
|
if (due == null) continue;
|
||||||
|
if (best == null || due.isBefore(best)) best = due;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
/// Address components every stop on this trip shares — city, state,
|
/// Address components every stop on this trip shares — city, state,
|
||||||
/// pincode and so on. Cached per build by the caller.
|
/// pincode and so on. Cached per build by the caller.
|
||||||
@@ -211,13 +394,27 @@ class Trip {
|
|||||||
|
|
||||||
/// Estimated ride time from the previous point to stop [index]. For index 0
|
/// Estimated ride time from the previous point to stop [index]. For index 0
|
||||||
/// that is the hub → stop 1 leg.
|
/// that is the hub → stop 1 leg.
|
||||||
Duration travelTimeToStop(int index, {double? hubLat, double? hubLng}) {
|
Duration travelTimeToStop(
|
||||||
|
int index, {
|
||||||
|
double? hubLat,
|
||||||
|
double? hubLng,
|
||||||
|
// ── The rail's legs run door to door, not counter to counter ──
|
||||||
|
//
|
||||||
|
// On the Deliveries rail every stop is the delivery leg, and this walked
|
||||||
|
// the route over the PICKUP pairs — so the remaining-time estimate was a
|
||||||
|
// tour of the kitchens the rider had already left. The flag mirrors
|
||||||
|
// `RouteMetricsHelper.metersToStop(toDrop:)`: the previous stop's exit
|
||||||
|
// point and this stop's target are both the drop when the leg is a
|
||||||
|
// delivery, with the pickup pair as the payload fallback. Default false —
|
||||||
|
// Home's pickup-leg arithmetic is untouched.
|
||||||
|
bool deliveryLeg = false,
|
||||||
|
}) {
|
||||||
if (index < 0 || index >= stops.length) return Duration.zero;
|
if (index < 0 || index >= stops.length) return Duration.zero;
|
||||||
|
|
||||||
final ({double lat, double lng})? from = index == 0
|
final ({double lat, double lng})? from = index == 0
|
||||||
? _origin(hubLat, hubLng)
|
? _origin(hubLat, hubLng)
|
||||||
: _coordsOf(stops[index - 1]);
|
: _coordsOf(stops[index - 1], toDrop: deliveryLeg);
|
||||||
final to = _coordsOf(stops[index]);
|
final to = _coordsOf(stops[index], toDrop: deliveryLeg);
|
||||||
if (from == null || to == null) return Duration.zero;
|
if (from == null || to == null) return Duration.zero;
|
||||||
|
|
||||||
final meters = RouteMetricsHelper.distanceMeters(
|
final meters = RouteMetricsHelper.distanceMeters(
|
||||||
@@ -356,7 +553,10 @@ class Trip {
|
|||||||
|
|
||||||
static List<Trip> groupIntoTrips(
|
static List<Trip> groupIntoTrips(
|
||||||
List<Map<String, dynamic>> stops, {
|
List<Map<String, dynamic>> stops, {
|
||||||
int windowHours = 3,
|
|
||||||
|
/// No longer used for bucketing — stops are grouped by [DayPart]. Kept so
|
||||||
|
/// existing call sites compile; passing it changes nothing.
|
||||||
|
@Deprecated('Trips are bucketed by DayPart') int windowHours = 3,
|
||||||
double? hubLat,
|
double? hubLat,
|
||||||
double? hubLng,
|
double? hubLng,
|
||||||
DateTime? now,
|
DateTime? now,
|
||||||
@@ -403,10 +603,14 @@ class Trip {
|
|||||||
stop['eta'],
|
stop['eta'],
|
||||||
);
|
);
|
||||||
if (due != null) {
|
if (due != null) {
|
||||||
final blockHour = (due.hour ~/ windowHours) * windowHours;
|
// Morning, afternoon or evening — see [DayPart]. Not a rolling
|
||||||
from = DateTime(due.year, due.month, due.day, blockHour);
|
// window: two stops due an hour apart are on the same run, and the
|
||||||
to = from.add(Duration(hours: windowHours));
|
// trip a stop belongs to must not depend on what else is in the day.
|
||||||
key = 'win:${from.toIso8601String()}';
|
final part = DayPart.of(due);
|
||||||
|
final (partFrom, partTo) = DayPart.windowOn(due, part);
|
||||||
|
from = partFrom;
|
||||||
|
to = partTo;
|
||||||
|
key = 'part:${due.year}-${due.month}-${due.day}:${part.name}';
|
||||||
} else {
|
} else {
|
||||||
from = null;
|
from = null;
|
||||||
to = null;
|
to = null;
|
||||||
@@ -436,6 +640,11 @@ class Trip {
|
|||||||
if (as == null && bs == null) return 0;
|
if (as == null && bs == null) return 0;
|
||||||
if (as == null) return 1; // undated trips last
|
if (as == null) return 1; // undated trips last
|
||||||
if (bs == null) return -1;
|
if (bs == null) return -1;
|
||||||
|
// Day part first, so Trip 1 is always the morning even on a day that
|
||||||
|
// starts at two in the afternoon. Within a part, earliest first.
|
||||||
|
final pa = a.dayPart?.index ?? DayPart.values.length;
|
||||||
|
final pb = b.dayPart?.index ?? DayPart.values.length;
|
||||||
|
if (pa != pb) return pa.compareTo(pb);
|
||||||
return as.compareTo(bs);
|
return as.compareTo(bs);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -489,37 +698,39 @@ class Trip {
|
|||||||
/// Puts stops in the admin's intended order.
|
/// Puts stops in the admin's intended order.
|
||||||
///
|
///
|
||||||
/// `step` is authoritative — it IS the admin's solved sequence, and the app
|
/// `step` is authoritative — it IS the admin's solved sequence, and the app
|
||||||
/// must never second-guess it. Only when `step` is absent do we fall back to
|
/// must never second-guess it. Only when no stop carries one do we fall back
|
||||||
/// the booked time, then to the order the backend sent. Sorting by distance
|
/// to the booked time, then to the order the backend sent. **Distance is not
|
||||||
/// would be re-optimising the route, which the rider is not allowed to do.
|
/// one of the options here**: sorting by it would be re-optimising a route
|
||||||
|
/// the hub has planned, which the rider is not allowed to do.
|
||||||
|
///
|
||||||
|
/// The rule itself lives in [RouteOrder] — one implementation, shared with
|
||||||
|
/// the Deliveries tab, which used to keep its own and disagreed. This is the
|
||||||
|
/// pickup leg's door onto it.
|
||||||
static List<Map<String, dynamic>> sortStops(
|
static List<Map<String, dynamic>> sortStops(
|
||||||
List<Map<String, dynamic>> stops,
|
List<Map<String, dynamic>> stops,
|
||||||
) {
|
) => orderStops(stops).$1;
|
||||||
final indexed = <(int, Map<String, dynamic>)>[
|
|
||||||
for (var i = 0; i < stops.length; i++) (i, stops[i]),
|
|
||||||
];
|
|
||||||
|
|
||||||
indexed.sort((a, b) {
|
/// [sortStops], plus which rule produced the order.
|
||||||
final stepA = _toInt(a.$2['step'] ?? a.$2['Step']);
|
static (List<Map<String, dynamic>>, RouteOrderSource) orderStops(
|
||||||
final stepB = _toInt(b.$2['step'] ?? b.$2['Step']);
|
List<Map<String, dynamic>> stops,
|
||||||
if (stepA > 0 && stepB > 0 && stepA != stepB) return stepA - stepB;
|
) => RouteOrder.sort(
|
||||||
if (stepA > 0 && stepB <= 0) return -1;
|
stops,
|
||||||
if (stepB > 0 && stepA <= 0) return 1;
|
bookedTimeOf: (s) => _parseTimestamp(
|
||||||
|
s['expected_pickup_time'] ?? s['expectedpickuptime'] ?? s['eta'],
|
||||||
final dueA = _parseTimestamp(a.$2['expected_pickup_time']);
|
),
|
||||||
final dueB = _parseTimestamp(b.$2['expected_pickup_time']);
|
);
|
||||||
if (dueA != null && dueB != null && dueA != dueB) {
|
|
||||||
return dueA.compareTo(dueB);
|
|
||||||
}
|
|
||||||
return a.$1 - b.$1; // stable: keep backend order
|
|
||||||
});
|
|
||||||
|
|
||||||
return [for (final e in indexed) e.$2];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── helpers ────────────────────────────────────────────────────────────
|
// ── helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
static ({double lat, double lng})? _coordsOf(Map<String, dynamic> stop) {
|
static ({double lat, double lng})? _coordsOf(
|
||||||
|
Map<String, dynamic> stop, {
|
||||||
|
bool toDrop = false,
|
||||||
|
}) {
|
||||||
|
if (toDrop) {
|
||||||
|
final dLat = _toDouble(stop['droplat'] ?? stop['DropLat']);
|
||||||
|
final dLng = _toDouble(stop['droplon'] ?? stop['DropLon']);
|
||||||
|
if (dLat != 0 && dLng != 0) return (lat: dLat, lng: dLng);
|
||||||
|
}
|
||||||
final lat = _toDouble(stop['pickuplat'] ?? stop['PickupLat']);
|
final lat = _toDouble(stop['pickuplat'] ?? stop['PickupLat']);
|
||||||
final lng = _toDouble(stop['pickuplon'] ?? stop['PickupLon']);
|
final lng = _toDouble(stop['pickuplon'] ?? stop['PickupLon']);
|
||||||
if (lat == 0 || lng == 0) return null;
|
if (lat == 0 || lng == 0) return null;
|
||||||
@@ -540,12 +751,6 @@ class Trip {
|
|||||||
return DateTime.tryParse(v.toString().trim());
|
return DateTime.tryParse(v.toString().trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
static int _toInt(dynamic v) {
|
|
||||||
if (v == null) return 0;
|
|
||||||
if (v is num) return v.toInt();
|
|
||||||
return int.tryParse(v.toString().trim()) ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
static double _toDouble(dynamic v) {
|
static double _toDouble(dynamic v) {
|
||||||
if (v == null) return 0;
|
if (v == null) return 0;
|
||||||
if (v is num) return v.toDouble();
|
if (v is num) return v.toDouble();
|
||||||
@@ -634,6 +839,14 @@ enum StopState {
|
|||||||
/// Accepted, not yet started. Lives on the Bookings tab.
|
/// Accepted, not yet started. Lives on the Bookings tab.
|
||||||
accepted,
|
accepted,
|
||||||
|
|
||||||
|
/// Loaded from its source and physically in the rider's hands.
|
||||||
|
///
|
||||||
|
/// Service routes only. The parcel ladder has no equivalent — for a parcel,
|
||||||
|
/// collecting it from the customer *is* the job — so this state exists only
|
||||||
|
/// where accepting and carrying are two different days' work apart. See
|
||||||
|
/// [ServiceProfile.handoffAt] and the collected store.
|
||||||
|
collected,
|
||||||
|
|
||||||
/// The rider is physically on this stop right now.
|
/// The rider is physically on this stop right now.
|
||||||
active,
|
active,
|
||||||
|
|
||||||
@@ -660,16 +873,45 @@ extension StopStateX on StopState {
|
|||||||
/// Committed to: he has taken it and owes the customer a visit.
|
/// Committed to: he has taken it and owes the customer a visit.
|
||||||
bool get isCommitted =>
|
bool get isCommitted =>
|
||||||
this == StopState.accepted ||
|
this == StopState.accepted ||
|
||||||
|
this == StopState.collected ||
|
||||||
this == StopState.active ||
|
this == StopState.active ||
|
||||||
this == StopState.skipped;
|
this == StopState.skipped;
|
||||||
|
|
||||||
String get label => switch (this) {
|
/// In the rider's hands right now.
|
||||||
|
bool get isCollected => this == StopState.collected;
|
||||||
|
|
||||||
|
/// What this state is called on a card.
|
||||||
|
///
|
||||||
|
/// ── Two vocabularies, one state machine ──
|
||||||
|
///
|
||||||
|
/// The Doormile wording is written for a rider who is being *asked* something:
|
||||||
|
/// "Awaiting your decision" is true because the card under it carries Accept
|
||||||
|
/// and Reject. A service rider is asked nothing — the whole assignment came as
|
||||||
|
/// one commitment — so the same state means "the hub has given you this, it is
|
||||||
|
/// waiting for you to go and collect it", and calling that a decision invites
|
||||||
|
/// him to look for a button that is not there.
|
||||||
|
///
|
||||||
|
/// `Delivered` rather than `Completed` for the same reason: on a meal run the
|
||||||
|
/// last thing that happens at a door is a hand-over, and naming it is worth
|
||||||
|
/// more than a word that covers every stop type in the app.
|
||||||
|
String get label => ServiceProfile.active.acceptsPerStop
|
||||||
|
? switch (this) {
|
||||||
StopState.pending => 'Awaiting your decision',
|
StopState.pending => 'Awaiting your decision',
|
||||||
StopState.accepted => 'Accepted',
|
StopState.accepted => 'Accepted',
|
||||||
|
StopState.collected => 'Collected',
|
||||||
StopState.active => 'In progress',
|
StopState.active => 'In progress',
|
||||||
StopState.done => 'Completed',
|
StopState.done => 'Completed',
|
||||||
StopState.skipped => 'Skipped',
|
StopState.skipped => 'Skipped',
|
||||||
StopState.rejected => 'Rejected',
|
StopState.rejected => 'Rejected',
|
||||||
|
}
|
||||||
|
: switch (this) {
|
||||||
|
StopState.pending => 'Pending',
|
||||||
|
StopState.accepted => 'Accepted',
|
||||||
|
StopState.collected => 'Collected',
|
||||||
|
StopState.active => 'Out for delivery',
|
||||||
|
StopState.done => 'Delivered',
|
||||||
|
StopState.skipped => 'Skipped',
|
||||||
|
StopState.rejected => 'Cancelled',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,6 +926,10 @@ StopState stopStateOf(
|
|||||||
Map<String, dynamic> stop, {
|
Map<String, dynamic> stop, {
|
||||||
required Set<String> acceptedIds,
|
required Set<String> acceptedIds,
|
||||||
required Set<String> rejectedIds,
|
required Set<String> rejectedIds,
|
||||||
|
|
||||||
|
/// Orders the rider is carrying. Optional so every existing parcel call site
|
||||||
|
/// is unchanged — a Doormile route never populates it.
|
||||||
|
Set<String> collectedIds = const <String>{},
|
||||||
}) {
|
}) {
|
||||||
final id = (stop['orderid'] ?? '').toString();
|
final id = (stop['orderid'] ?? '').toString();
|
||||||
final raw = (stop['orderstatus'] ?? '').toString().trim().toLowerCase();
|
final raw = (stop['orderstatus'] ?? '').toString().trim().toLowerCase();
|
||||||
@@ -704,14 +950,29 @@ StopState stopStateOf(
|
|||||||
if (raw == 'active' || raw == 'arrived') return StopState.active;
|
if (raw == 'active' || raw == 'arrived') return StopState.active;
|
||||||
if (raw == 'skipped') return StopState.skipped;
|
if (raw == 'skipped') return StopState.skipped;
|
||||||
|
|
||||||
// 3. Local decisions next, and they beat the server's accept/reject.
|
// 2b. Released for delivery — which `pickup-complete` does by itself on
|
||||||
|
// hyperlocal work, in the same call that records the collection. It says
|
||||||
|
// the consignment may be delivered, not that the rider has set off, so it
|
||||||
|
// resolves to *carrying* and never to [StopState.active]. Asked ahead of
|
||||||
|
// the local sets because it must also hold after a reinstall, when the
|
||||||
|
// collected set is empty and this row would otherwise fall through to
|
||||||
|
// `pending` — putting a bag already in his box back on Home as work to
|
||||||
|
// accept. See `MilkRun.stageOf`.
|
||||||
|
if (raw == 'outfordelivery') return StopState.collected;
|
||||||
|
|
||||||
|
// 3. Carrying it beats every remaining local view. He has the food; no
|
||||||
|
// later tap on this screen can make that untrue, and a refetch that still
|
||||||
|
// reports `accepted` must not put the card back on Home.
|
||||||
|
if (collectedIds.contains(id)) return StopState.collected;
|
||||||
|
|
||||||
|
// 4. Local decisions next, and they beat the server's accept/reject.
|
||||||
// They are strictly newer: the rider just tapped, and the server view is
|
// They are strictly newer: the rider just tapped, and the server view is
|
||||||
// at best one poll behind. Without this, un-rejecting a stop would be
|
// at best one poll behind. Without this, un-rejecting a stop would be
|
||||||
// undone by the next refetch still reporting `rejected`.
|
// undone by the next refetch still reporting `rejected`.
|
||||||
if (rejectedIds.contains(id)) return StopState.rejected;
|
if (rejectedIds.contains(id)) return StopState.rejected;
|
||||||
if (acceptedIds.contains(id)) return StopState.accepted;
|
if (acceptedIds.contains(id)) return StopState.accepted;
|
||||||
|
|
||||||
// 4. Finally the server's own view.
|
// 5. Finally the server's own view.
|
||||||
if (raw == 'rejected') return StopState.rejected;
|
if (raw == 'rejected') return StopState.rejected;
|
||||||
if (raw == 'accepted') return StopState.accepted;
|
if (raw == 'accepted') return StopState.accepted;
|
||||||
return StopState.pending;
|
return StopState.pending;
|
||||||
@@ -732,6 +993,41 @@ enum StopProgress {
|
|||||||
skipped,
|
skipped,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How one stop's lifecycle rung reads on the route rail.
|
||||||
|
///
|
||||||
|
/// ── "Physically on" is a question about BOTH legs ──
|
||||||
|
///
|
||||||
|
/// This lived inline in `_MyPickupsState._tripProgress` and tested
|
||||||
|
/// `isActive || arrived` — and both of those are **pickup-leg** rungs. It is
|
||||||
|
/// read on the Deliveries tab, where every stop is past `pickup-complete` by
|
||||||
|
/// definition, so on a milk run the rung the rider is actually standing on is
|
||||||
|
/// [StopStatus.deliveryArrived], which matched neither. The customer's door he
|
||||||
|
/// was at *that moment* drew grey — indistinguishable from the eleven he had
|
||||||
|
/// not ridden to yet — and the scooter fell through to the caller's "promote
|
||||||
|
/// the first pending one" fallback, parking it somewhere up the route behind
|
||||||
|
/// him.
|
||||||
|
///
|
||||||
|
/// The rail's whole job is *where am I*. Answering it from one leg's
|
||||||
|
/// vocabulary, on the tab that only ever shows the other leg, is why the answer
|
||||||
|
/// was wrong all round.
|
||||||
|
///
|
||||||
|
/// [StopStatusX.isWorkComplete] decides `done`, so the answer is line-aware:
|
||||||
|
/// `picked` ends a logistics stop and is the middle of the morning on a round.
|
||||||
|
///
|
||||||
|
/// Pure, and out here rather than on the State, because `_MyPickupsState`
|
||||||
|
/// cannot be pumped — Get, Geolocator and a 3s poll — so a mapping left inside
|
||||||
|
/// it can only ever be verified by reading it. See `trip_progress_test.dart`.
|
||||||
|
StopProgress stopProgressFor(StopStatus status) {
|
||||||
|
if (status.isWorkComplete) return StopProgress.done;
|
||||||
|
if (status.isSkipped) return StopProgress.skipped;
|
||||||
|
if (status.isActive ||
|
||||||
|
status == StopStatus.arrived ||
|
||||||
|
status == StopStatus.deliveryArrived) {
|
||||||
|
return StopProgress.current;
|
||||||
|
}
|
||||||
|
return StopProgress.pending;
|
||||||
|
}
|
||||||
|
|
||||||
/// Formats a duration the way a rider plans: `2h 15m`, `45 min`.
|
/// Formats a duration the way a rider plans: `2h 15m`, `45 min`.
|
||||||
String formatTripDuration(Duration d) {
|
String formatTripDuration(Duration d) {
|
||||||
if (d.inMinutes < 1) return '—';
|
if (d.inMinutes < 1) return '—';
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
|
||||||
import 'package:miler/views/Dashboard/home/route_brief.dart';
|
import 'package:miler/views/Dashboard/home/route_brief.dart';
|
||||||
|
import 'package:miler/views/helpers/constants/miler_surface.dart';
|
||||||
import 'package:miler/views/Dashboard/home/trip.dart';
|
import 'package:miler/views/Dashboard/home/trip.dart';
|
||||||
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
|
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
|
||||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||||
@@ -89,123 +91,300 @@ class TripBriefStrip extends StatefulWidget {
|
|||||||
State<TripBriefStrip> createState() => _TripBriefStripState();
|
State<TripBriefStrip> createState() => _TripBriefStripState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ── The figures are reference, so they are folded ──
|
||||||
|
///
|
||||||
|
/// Duration, distance, parcels and payment were drawn permanently, four
|
||||||
|
/// captioned columns above `TODAY'S RUN`. On a 390×844 phone that block plus
|
||||||
|
/// the trip tabs plus the app bar reached the fold before the first kitchen,
|
||||||
|
/// so the screen opened on a dashboard about the run rather than on the run.
|
||||||
|
///
|
||||||
|
/// None of the four is something a rider acts on mid-shift. They are what he
|
||||||
|
/// checks once when he picks the trip up and again when he reconciles — the
|
||||||
|
/// definition of reference material, and this file's own doc already said as
|
||||||
|
/// much about the collapsed face.
|
||||||
|
///
|
||||||
|
/// What stays out is the progress face: stops left, and cash in hand. Those
|
||||||
|
/// two he acts on. The rest is one tap away and remembers nothing between
|
||||||
|
/// builds, because a strip that reopens itself is furniture that grows back.
|
||||||
class _TripBriefStripState extends State<TripBriefStrip> {
|
class _TripBriefStripState extends State<TripBriefStrip> {
|
||||||
/// Starts closed. The brief is a once-a-shift read, and defaulting it open
|
|
||||||
/// would give back exactly the height this widget exists to reclaim.
|
|
||||||
bool _open = false;
|
bool _open = false;
|
||||||
|
|
||||||
Trip get _trip => widget.trip;
|
Trip get _trip => widget.trip;
|
||||||
RouteBrief get _brief => widget.brief;
|
RouteBrief get _brief => widget.brief;
|
||||||
|
int get percent => widget.percent;
|
||||||
|
VoidCallback? get onViewRoute => widget.onViewRoute;
|
||||||
|
|
||||||
/// Stops the rider has finished, derived from the percentage the page already
|
/// Stops the rider has finished, derived from the percentage the page already
|
||||||
/// computed rather than recounting the sets here.
|
/// computed rather than recounting the sets here.
|
||||||
int get _done => (_trip.stopCount * widget.percent / 100).round();
|
int get _done => (_trip.stopCount * percent / 100).round();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
// ── The brief is a working region, so it stands on one ──
|
||||||
// 12, which is the page gutter — the same value `_footerBar` uses and the
|
//
|
||||||
// one the header this replaced used. It reads as 28 when [TripCard] is
|
// This was a bare `Padding` on Home's ground, and Home's ground is the
|
||||||
// composed whole (its own 16 plus this), and as 12 on Home, which calls
|
// canvas: grey figures on grey, with nothing behind them. [MilerPanel] is
|
||||||
// the three sections directly so that the trip tabs can be a pinned
|
// layer 1 of the surface ladder — white, no border — and the canvas
|
||||||
// sliver between them. The stop rows deliberately sit 4 further in; see
|
// showing around it is what turns a column of text into a region.
|
||||||
// the gutter group in `card_density_test.dart`.
|
//
|
||||||
padding: EdgeInsets.fromLTRB(12.w, 10.h, 12.w, 4.h),
|
// The panel also settles a gutter argument. The old note here claimed 12
|
||||||
child: Container(
|
// put this head "where `TODAY'S RUN` and the timeline's own gutter already
|
||||||
decoration: BoxDecoration(
|
// sit"; measured, they were four points apart. Both sections now take
|
||||||
color: ColorConstants.pureSurface,
|
// their edge from `MilerSurface.panelGutter` + `panelPad`, so they cannot
|
||||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
// disagree — and `home_gutter_test` fails if they ever do.
|
||||||
// One surface, one shadow. The stack this replaced nested a shadowed
|
return MilerPanel(
|
||||||
// card inside padding inside three more shadowed cards — four
|
// ── Not the bordered card that was removed from here ──
|
||||||
// elevations describing one object.
|
//
|
||||||
boxShadow: const [
|
// That one was a `pureSurface` fill inside a `borderSubtle` outline on a
|
||||||
BoxShadow(
|
// near-white page: #FFFFFF on #F8FAFC, a four-percent step, so the
|
||||||
color: Color(0x14000000),
|
// border did all of the separating and the fill did none. Removing it
|
||||||
blurRadius: 16,
|
// was right for that ground.
|
||||||
offset: Offset(0, 5),
|
//
|
||||||
|
// The ground changed. The canvas moved to #DEE3EA and white now stands
|
||||||
|
// off it at 1.290 : 1, which is a surface rather than a hairline — so
|
||||||
|
// this returns as a *fill with no border*, which is the thing the old
|
||||||
|
// box was only pretending to be.
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Semantics(
|
||||||
|
button: true,
|
||||||
|
expanded: _open,
|
||||||
|
label: _open ? 'Hide trip figures' : 'Show trip figures',
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: () => setState(() => _open = !_open),
|
||||||
|
child: _head(),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
// Grows and shrinks in place rather than the body appearing in one
|
ClipRect(
|
||||||
// frame, so the stop list below is pushed down visibly and the rider
|
|
||||||
// can see where the extra content came from.
|
|
||||||
child: AnimatedSize(
|
child: AnimatedSize(
|
||||||
duration: const Duration(milliseconds: 220),
|
duration: DesignConstants.motionState,
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
child: Column(
|
child: _open
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
? _body()
|
||||||
children: [_face(), if (_open) _body()],
|
: const SizedBox(width: double.infinity, height: 0),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── The collapsed face ───────────────────────────────────────────────────
|
// ── The head ─────────────────────────────────────────────────────────────
|
||||||
//
|
|
||||||
// Ring, the two live figures, the cash chip, the chevron. Nothing else ever
|
|
||||||
// appears here.
|
|
||||||
Widget _face() {
|
|
||||||
final radius = BorderRadius.circular(DesignConstants.radiusXl);
|
|
||||||
final cash = _trip.cashToCollect;
|
|
||||||
|
|
||||||
return Semantics(
|
/// ── The progress rule is gone, and so is the disclosure ──
|
||||||
button: true,
|
///
|
||||||
expanded: _open,
|
/// The 3pt `LinearProgressIndicator` under this line was a **thin
|
||||||
label: _open ? 'Hide trip details' : 'Show trip details',
|
/// indeterminate-looking bar directly below the app bar**, which is the exact
|
||||||
child: Material(
|
/// position and the exact shape every app on the phone uses to say *this
|
||||||
color: Colors.transparent,
|
/// screen is still loading*. It was reporting completion, and it was read as
|
||||||
borderRadius: radius,
|
/// a spinner. A signal that is misread by everyone who sees it is worth less
|
||||||
child: InkWell(
|
/// than the height it costs, and the sentence above it already gave the same
|
||||||
onTap: () => setState(() => _open = !_open),
|
/// figure in the unit the rider works in.
|
||||||
borderRadius: radius,
|
///
|
||||||
child: Padding(
|
/// The chevron went with it. The four journey figures behind it were a
|
||||||
padding: EdgeInsets.fromLTRB(14.w, 12.h, 10.w, 12.h),
|
/// once-a-shift read that nobody was opening, so the information was shipped
|
||||||
child: Row(
|
/// and unreachable. They are on the face now — one card, always open, which
|
||||||
children: [
|
/// is the state the rider only ever saw by accident before.
|
||||||
_MiniRing(percent: widget.percent),
|
Widget _head() {
|
||||||
SizedBox(width: 12.w),
|
final cash = _trip.cashToCollect;
|
||||||
Expanded(
|
final total = _trip.stopCount;
|
||||||
|
final left = total - _done;
|
||||||
|
final complete = total > 0 && left <= 0;
|
||||||
|
final pct = total > 0 ? (_done / total).clamp(0.0, 1.0) : 0.0;
|
||||||
|
|
||||||
|
// ── A figure, not a sentence ──
|
||||||
|
//
|
||||||
|
// "15 stops left" set in body type was a headline wearing a paragraph's
|
||||||
|
// clothes: the one number a rider steers his shift by, at the same weight
|
||||||
|
// as everything around it. Every dashboard he already reads — the duty
|
||||||
|
// apps, the pay apps — leads with the figure big and captions it small,
|
||||||
|
// because a glance lands on a numeral long before it lands on a phrase.
|
||||||
|
// So the count is the headline and the words are its caption.
|
||||||
|
//
|
||||||
|
// ── And the bar is back, on different terms ──
|
||||||
|
//
|
||||||
|
// A progress bar was removed from this widget once, correctly: 3pt thin,
|
||||||
|
// drawn directly under the app bar, it sat in the exact position and
|
||||||
|
// shape every app uses for "this screen is loading". What returns is not
|
||||||
|
// that bar. It lives inside the card, under its own numeral, above its
|
||||||
|
// own caption ("9 of 24 done"), 6pt with rounded caps and a visible
|
||||||
|
// determinate fill — the grammar of completion, not of waiting. Colour
|
||||||
|
// follows the day: brand while the run is live, green when it closes.
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 14.h),
|
||||||
|
// Everything in the hero is a figure or a figure's caption, and the
|
||||||
|
// card's own precedent holds: numbers are read at a glance, not
|
||||||
|
// studied, so they stop scaling at 1.3× while the prose elsewhere
|
||||||
|
// keeps going. Unclamped, "3 stops left" at 2.0× on a 320pt phone
|
||||||
|
// overflowed its own row by 31px.
|
||||||
|
child: MediaQuery.withClampedTextScaling(
|
||||||
|
maxScaleFactor: 1.3,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
'$_done of ${_trip.stopCount} '
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
'${_trip.stopCount == 1 ? 'stop' : 'stops'} done',
|
children: [
|
||||||
|
if (complete) ...[
|
||||||
|
Icon(
|
||||||
|
LucideIcons.circleCheck,
|
||||||
|
size: 22.sp,
|
||||||
|
color: ColorConstants.acceptGreen,
|
||||||
|
),
|
||||||
|
SizedBox(width: 8.w),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
'All $total done',
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15.sp,
|
fontSize: 20.sp,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
letterSpacing: -0.3,
|
letterSpacing: -0.5,
|
||||||
|
color: ColorConstants.acceptGreen,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
Text(
|
||||||
|
'$left',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 28.sp,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: -1,
|
||||||
|
height: 1.0,
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
color: ColorConstants.slateText,
|
color: ColorConstants.slateText,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 2.h),
|
SizedBox(width: 7.w),
|
||||||
_timeLeftLine(),
|
Flexible(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(top: 6.h),
|
||||||
|
child: Text(
|
||||||
|
left == 1 ? 'stop left' : 'stops left',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: ColorConstants.secondaryText,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
const Spacer(),
|
||||||
),
|
if (cash > 0) ...[_cashChip(cash), SizedBox(width: 8.w)],
|
||||||
if (cash > 0) ...[SizedBox(width: 8.w), _cashChip(cash)],
|
|
||||||
SizedBox(width: 2.w),
|
|
||||||
AnimatedRotation(
|
AnimatedRotation(
|
||||||
turns: _open ? 0.5 : 0,
|
turns: _open ? 0.5 : 0,
|
||||||
duration: const Duration(milliseconds: 220),
|
duration: DesignConstants.motionState,
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.keyboard_arrow_down_rounded,
|
LucideIcons.chevronDown,
|
||||||
size: 24.sp,
|
size: 20.sp,
|
||||||
color: ColorConstants.secondaryText,
|
color: ColorConstants.secondaryText,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
SizedBox(height: 12.h),
|
||||||
|
// The track is the page's own canvas tone — the one grey that
|
||||||
|
// measurably separates from this white card (1.29:1) — and the
|
||||||
|
// fill starts from zero honestly: an untouched run shows an empty
|
||||||
|
// track, which with the numeral above it cannot be misread.
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||||||
|
child: Container(
|
||||||
|
height: 6.h,
|
||||||
|
width: double.infinity,
|
||||||
|
color: MilerSurface.canvas,
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: AnimatedFractionallySizedBox(
|
||||||
|
duration: DesignConstants.motionState,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
widthFactor: pct,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: complete
|
||||||
|
? ColorConstants.acceptGreen
|
||||||
|
: ColorConstants.primary,
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
DesignConstants.radiusFull,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 9.h),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$_done of $total done',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12.5.sp,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
color: ColorConstants.secondaryText,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
// The clock keeps its own colour rules — the one line here
|
||||||
|
// allowed to carry bad news; see [_timeLeftLine]. With no
|
||||||
|
// shift and no slot the journey speaks instead, and yields
|
||||||
|
// while the grid below is open so nothing prints twice.
|
||||||
|
Flexible(
|
||||||
|
child: (!_brief.shiftSet && _trip.slotStart == null)
|
||||||
|
? (_open ? const SizedBox.shrink() : _journeyGlance())
|
||||||
|
: _timeLeftLine(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The collapsed panel's journey, in one quiet line: `≈4h · 38.9 km`.
|
||||||
|
///
|
||||||
|
/// Only the figures that exist — a route with no coordinates prints no
|
||||||
|
/// kilometres rather than a zero — and never a colour: this is context, and
|
||||||
|
/// the headline beside it is the thing being contextualised.
|
||||||
|
Widget _journeyGlance() {
|
||||||
|
final duration = roundTripDuration(_trip.totalDuration);
|
||||||
|
final parts = <String>[
|
||||||
|
if (duration > Duration.zero) '\u2248${formatTripDuration(duration)}',
|
||||||
|
if (_trip.routeMeters > 0)
|
||||||
|
RouteMetricsHelper.formatDistance(_trip.routeMeters),
|
||||||
|
];
|
||||||
|
if (parts.isEmpty) return const SizedBox.shrink();
|
||||||
|
// Scale-down, never ellipsis: this line is nothing but figures, and
|
||||||
|
// `10.3 …` is the unit cut off the number it qualifies — seen the first
|
||||||
|
// time it shared a row with the headline, both flexible.
|
||||||
|
return FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: Text(
|
||||||
|
parts.join(' \u00b7 '),
|
||||||
|
maxLines: 1,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12.5.sp,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
color: ColorConstants.secondaryText,
|
||||||
|
fontFamily: FontConstants.fontFamily,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,6 +400,13 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
|||||||
// The hub has not set a window. Say nothing about time rather than
|
// The hub has not set a window. Say nothing about time rather than
|
||||||
// inventing a state — see the note on [ShiftBanner] for why an absent
|
// inventing a state — see the note on [ShiftBanner] for why an absent
|
||||||
// shift is not the rider's problem to solve.
|
// shift is not the rider's problem to solve.
|
||||||
|
//
|
||||||
|
// And when the trip has no slot either, say nothing AT ALL:
|
||||||
|
// `slotLabel`'s fallback is the words "Today's route", which set at
|
||||||
|
// clock position read as a fact the rider should be able to act on. A
|
||||||
|
// headline row carries a live figure or it carries silence — a label
|
||||||
|
// pretending to be data is worse than the gap it fills.
|
||||||
|
if (_trip.slotStart == null) return const SizedBox.shrink();
|
||||||
text = _trip.slotLabel;
|
text = _trip.slotLabel;
|
||||||
color = ColorConstants.secondaryText;
|
color = ColorConstants.secondaryText;
|
||||||
} else if (left == Duration.zero) {
|
} else if (left == Duration.zero) {
|
||||||
@@ -232,7 +418,7 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
|||||||
text = '${formatDuration(left)} left · running tight';
|
text = '${formatDuration(left)} left · running tight';
|
||||||
color = ColorConstants.warning;
|
color = ColorConstants.warning;
|
||||||
} else {
|
} else {
|
||||||
text = '${formatDuration(left)} left in shift';
|
text = '${formatDuration(left)} left';
|
||||||
color = ColorConstants.secondaryText;
|
color = ColorConstants.secondaryText;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,30 +428,31 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12.sp,
|
fontSize: 12.sp,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w600,
|
||||||
color: color,
|
color: color,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ── The cash is a figure, not a chip ──
|
||||||
|
///
|
||||||
|
/// It was a tinted pill, and a pill has vertical padding — which made the one
|
||||||
|
/// element on the line that could **change the height of the whole strip**
|
||||||
|
/// depend on whether the trip happened to carry money. Green on the page
|
||||||
|
/// carries the meaning perfectly well without a capsule behind it.
|
||||||
Widget _cashChip(double cash) {
|
Widget _cashChip(double cash) {
|
||||||
return Container(
|
return Text(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 9.w, vertical: 5.h),
|
'\u20b9${_money(cash)}',
|
||||||
decoration: BoxDecoration(
|
maxLines: 1,
|
||||||
color: ColorConstants.acceptGreen.withValues(alpha: 0.10),
|
overflow: TextOverflow.ellipsis,
|
||||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'₹${_money(cash)}',
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13.sp,
|
fontSize: 13.sp,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: -0.3,
|
letterSpacing: -0.3,
|
||||||
color: ColorConstants.moneyGreen,
|
color: ColorConstants.moneyGreen,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,12 +487,13 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
|||||||
final duration = roundTripDuration(_trip.totalDuration);
|
final duration = roundTripDuration(_trip.totalDuration);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.fromLTRB(12.w, 0, 12.w, 14.h),
|
// Horizontally flush with [_head], for the same reason.
|
||||||
|
padding: EdgeInsets.only(bottom: 14.h),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Divider(height: 1, color: ColorConstants.borderSubtle),
|
Divider(height: 1, color: ColorConstants.borderSubtle),
|
||||||
SizedBox(height: 10.h),
|
SizedBox(height: 14.h),
|
||||||
|
|
||||||
// ── Band 1: the shift window ──
|
// ── Band 1: the shift window ──
|
||||||
//
|
//
|
||||||
@@ -322,7 +510,7 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
|||||||
// rather than as a range.
|
// rather than as a range.
|
||||||
if (_brief.shiftSet) ...[
|
if (_brief.shiftSet) ...[
|
||||||
_windowRow(
|
_windowRow(
|
||||||
icon: Icons.event_available_rounded,
|
icon: LucideIcons.calendarCheck,
|
||||||
label: 'Shift',
|
label: 'Shift',
|
||||||
value: _brief.shiftWindowLabel,
|
value: _brief.shiftWindowLabel,
|
||||||
tint: ColorConstants.slateText,
|
tint: ColorConstants.slateText,
|
||||||
@@ -342,21 +530,24 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _figure(
|
child: _figure(
|
||||||
icon: Icons.schedule_rounded,
|
icon: LucideIcons.clock,
|
||||||
value: '≈${formatTripDuration(duration)}',
|
value: '≈${formatTripDuration(duration)}',
|
||||||
label: 'DURATION',
|
label: 'DURATION',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _figure(
|
child: _figure(
|
||||||
icon: Icons.route_rounded,
|
// `straighten`, not `route`: the winding-path glyph is
|
||||||
|
// four strokes and two pins, and at this size it renders as
|
||||||
|
// a smudge. An icon the rider cannot resolve is decoration.
|
||||||
|
icon: LucideIcons.ruler,
|
||||||
value: RouteMetricsHelper.formatDistance(_trip.routeMeters),
|
value: RouteMetricsHelper.formatDistance(_trip.routeMeters),
|
||||||
label: 'DISTANCE',
|
label: 'DISTANCE',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _figure(
|
child: _figure(
|
||||||
icon: Icons.inventory_2_rounded,
|
icon: LucideIcons.package,
|
||||||
value: '${_trip.totalParcels}',
|
value: '${_trip.totalParcels}',
|
||||||
label: _trip.totalParcels == 1 ? 'PARCEL' : 'PARCELS',
|
label: _trip.totalParcels == 1 ? 'PARCEL' : 'PARCELS',
|
||||||
),
|
),
|
||||||
@@ -364,7 +555,7 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: cash > 0
|
child: cash > 0
|
||||||
? _figure(
|
? _figure(
|
||||||
icon: Icons.payments_rounded,
|
icon: LucideIcons.banknote,
|
||||||
value: '₹${_money(cash)}',
|
value: '₹${_money(cash)}',
|
||||||
label: 'PAYMENT',
|
label: 'PAYMENT',
|
||||||
money: true,
|
money: true,
|
||||||
@@ -373,8 +564,13 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
|||||||
// four even columns instead of redistributing the space
|
// four even columns instead of redistributing the space
|
||||||
// and shifting the other three figures sideways.
|
// and shifting the other three figures sideways.
|
||||||
: _figure(
|
: _figure(
|
||||||
icon: Icons.money_off_rounded,
|
icon: LucideIcons.banknoteX,
|
||||||
value: 'None',
|
// The em dash, not a word: '—' is what every other
|
||||||
|
// figure in the app prints when there is nothing to
|
||||||
|
// print, and 'None' set in figure type read as data
|
||||||
|
// — a rider scanning four numbers got three numbers
|
||||||
|
// and a word to parse.
|
||||||
|
value: '\u2014',
|
||||||
label: 'PAYMENT',
|
label: 'PAYMENT',
|
||||||
muted: true,
|
muted: true,
|
||||||
),
|
),
|
||||||
@@ -413,7 +609,7 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
|||||||
label,
|
label,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12.5.sp,
|
fontSize: 12.5.sp,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w600,
|
||||||
color: ColorConstants.secondaryText,
|
color: ColorConstants.secondaryText,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
),
|
),
|
||||||
@@ -427,7 +623,7 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
|||||||
textAlign: TextAlign.right,
|
textAlign: TextAlign.right,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13.5.sp,
|
fontSize: 13.5.sp,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: -0.2,
|
letterSpacing: -0.2,
|
||||||
color: ColorConstants.slateText,
|
color: ColorConstants.slateText,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
@@ -487,7 +683,7 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
|||||||
value,
|
value,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16.5.sp,
|
fontSize: 16.5.sp,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: -0.5,
|
letterSpacing: -0.5,
|
||||||
height: 1.05,
|
height: 1.05,
|
||||||
color: accent,
|
color: accent,
|
||||||
@@ -505,7 +701,7 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
|||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 9.sp,
|
fontSize: 9.sp,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
color: ColorConstants.secondaryText,
|
color: ColorConstants.secondaryText,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
@@ -532,59 +728,3 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
|||||||
return parts.length > 1 ? '${buf.toString()}.${parts[1]}' : buf.toString();
|
return parts.length > 1 ? '${buf.toString()}.${parts[1]}' : buf.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The completion ring, at a third of the area it used to occupy.
|
|
||||||
///
|
|
||||||
/// It was 66dp and sat beside a 25sp headline; it is 34 here and sits beside a
|
|
||||||
/// 15sp line, so it holds the same relative weight in a strip that is a fifth
|
|
||||||
/// of the height. The percentage itself is not printed inside it — the pinned
|
|
||||||
/// trip tab below already prints "50% done", and the fraction next to the ring
|
|
||||||
/// says the same thing in stops, which is the unit the rider works in.
|
|
||||||
class _MiniRing extends StatelessWidget {
|
|
||||||
final int percent;
|
|
||||||
const _MiniRing({required this.percent});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final complete = percent >= 100;
|
|
||||||
final color = complete
|
|
||||||
? ColorConstants.acceptGreen
|
|
||||||
: ColorConstants.primary;
|
|
||||||
|
|
||||||
return SizedBox(
|
|
||||||
width: 34.w,
|
|
||||||
height: 34.w,
|
|
||||||
child: Stack(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
SizedBox.expand(
|
|
||||||
child: TweenAnimationBuilder<double>(
|
|
||||||
tween: Tween(begin: 0, end: percent / 100),
|
|
||||||
duration: const Duration(milliseconds: 550),
|
|
||||||
curve: Curves.easeOutCubic,
|
|
||||||
builder: (_, value, __) => CircularProgressIndicator(
|
|
||||||
value: value,
|
|
||||||
strokeWidth: 3.5,
|
|
||||||
backgroundColor: ColorConstants.borderSubtle,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(color),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (complete)
|
|
||||||
Icon(Icons.check_rounded, size: 17.sp, color: color)
|
|
||||||
else
|
|
||||||
Text(
|
|
||||||
'$percent',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11.sp,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
letterSpacing: -0.4,
|
|
||||||
color: ColorConstants.slateText,
|
|
||||||
fontFamily: FontConstants.fontFamily,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
|
||||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
|
||||||
import 'package:miler/views/helpers/constants/miler_type.dart';
|
|
||||||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||||
|
|
||||||
/// ─────────────────────────────────────────────────────────────────────────
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
@@ -29,7 +28,7 @@ import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
|||||||
/// is coming until he goes on — so there the motion stops and the colour
|
/// is coming until he goes on — so there the motion stops and the colour
|
||||||
/// drains to grey.
|
/// drains to grey.
|
||||||
/// ─────────────────────────────────────────────────────────────────────────
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
class TripEmptyState extends StatefulWidget {
|
class TripEmptyState extends StatelessWidget {
|
||||||
/// `Trip 2` — the tab this slot belongs to, named the same way the tabs,
|
/// `Trip 2` — the tab this slot belongs to, named the same way the tabs,
|
||||||
/// the hub and his supervisor name it.
|
/// the hub and his supervisor name it.
|
||||||
final String tripLabel;
|
final String tripLabel;
|
||||||
@@ -48,38 +47,9 @@ class TripEmptyState extends StatefulWidget {
|
|||||||
this.onGoOnDuty,
|
this.onGoOnDuty,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
|
||||||
State<TripEmptyState> createState() => _TripEmptyStateState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TripEmptyStateState extends State<TripEmptyState>
|
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
/// Slow enough to read as breathing rather than blinking.
|
|
||||||
///
|
|
||||||
/// 2s out and 2s back. The pulse on the live-pickup banner runs at 1.1s
|
|
||||||
/// because it marks a job happening *now*; this one marks a wait, and a wait
|
|
||||||
/// that flashes at you is an alarm.
|
|
||||||
///
|
|
||||||
/// Initialised lazily on the declaration rather than in `initState` so hot
|
|
||||||
/// reload cannot leave it unset — see the note in `Bottom_page.dart`.
|
|
||||||
late final AnimationController _breath = AnimationController(
|
|
||||||
vsync: this,
|
|
||||||
duration: const Duration(milliseconds: 2000),
|
|
||||||
)..repeat(reverse: true);
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_breath.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final offline = widget.offline;
|
// Grey off duty, brand red waiting.
|
||||||
|
|
||||||
// Grey off duty, brand red waiting. The halo and the chip move together on
|
|
||||||
// this one colour, so the screen carries a single decision rather than
|
|
||||||
// three.
|
|
||||||
final Color accent = offline
|
final Color accent = offline
|
||||||
? ColorConstants.secondaryText
|
? ColorConstants.secondaryText
|
||||||
: ColorConstants.primary;
|
: ColorConstants.primary;
|
||||||
@@ -99,89 +69,82 @@ class _TripEmptyStateState extends State<TripEmptyState>
|
|||||||
// and adding to that squeezed the chip below on a narrow phone.
|
// and adding to that squeezed the chip below on a narrow phone.
|
||||||
padding: EdgeInsets.fromLTRB(0, 36.h, 0, 40.h),
|
padding: EdgeInsets.fromLTRB(0, 36.h, 0, 40.h),
|
||||||
child: MilerEmptyState(
|
child: MilerEmptyState(
|
||||||
// The component draws its own static halo from `icon`; this one is
|
// The component draws its own static halo from `icon`; the offline
|
||||||
// supplied as the illustration instead so it can breathe. The icon is
|
// state supplies the breathing one instead. The icon is still passed
|
||||||
// still passed for the fallback path.
|
// for the fallback path.
|
||||||
icon: offline ? Icons.wifi_off_rounded : Icons.alt_route_rounded,
|
icon: offline ? LucideIcons.wifiOff : LucideIcons.route,
|
||||||
illustration: _BreathingHalo(
|
// ── Waiting for a trip is a picture, not a diagram ──
|
||||||
breath: _breath,
|
//
|
||||||
accent: accent,
|
// The unassigned slot wore a route glyph inside two breathing rings,
|
||||||
icon: offline ? Icons.wifi_off_rounded : Icons.alt_route_rounded,
|
// and under it two sentences plus a "Waiting for your hub" chip —
|
||||||
// Off duty is a settled state, not a wait — so it holds still.
|
// three pieces of furniture explaining an absence. The artwork says
|
||||||
animate: !offline,
|
// the whole thing in one look (a rider sitting on his scooter beside
|
||||||
|
// a map with a question mark on it), so the screen is the picture and
|
||||||
|
// one line. Off duty keeps the halo: it is a *state*, not a wait, and
|
||||||
|
// it still has a button under it worth pointing at.
|
||||||
|
illustration: offline
|
||||||
|
? _Halo(accent: accent, icon: LucideIcons.wifiOff)
|
||||||
|
: Image.asset(
|
||||||
|
'assets/images/trip_not_assigned.png',
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
// A missing asset degrades to nothing, never to the grey
|
||||||
|
// broken-image box in the middle of an empty screen.
|
||||||
|
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
title: offline
|
illustrationSize: offline ? 180 : 208.w,
|
||||||
? 'You are off duty'
|
title: offline ? 'You are off duty' : 'Trip not assigned yet',
|
||||||
: '${widget.tripLabel} not assigned yet',
|
|
||||||
message: offline
|
message: offline
|
||||||
? 'Go on duty to start receiving trips from your hub.'
|
? 'Go on duty to start receiving trips from your hub.'
|
||||||
: 'Your hub assigns trips as customer slots fill up. This one '
|
: null,
|
||||||
'appears here the moment it is ready.',
|
|
||||||
accent: offline ? cta : accent,
|
accent: offline ? cta : accent,
|
||||||
actionLabel: offline ? 'Go on duty' : null,
|
actionLabel: offline ? 'Go on duty' : null,
|
||||||
onAction: offline ? widget.onGoOnDuty : null,
|
onAction: offline ? onGoOnDuty : null,
|
||||||
// The chip goes under the copy, where a CTA would be if there were one
|
|
||||||
// to offer. On duty there is nothing for the rider to do but wait, and
|
|
||||||
// the honest screen says so rather than inventing a button.
|
|
||||||
footer: offline
|
|
||||||
? null
|
|
||||||
: _WaitingChip(breath: _breath, accent: accent),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The icon on two soft rings that swell and settle together.
|
/// The icon on two soft rings.
|
||||||
class _BreathingHalo extends StatelessWidget {
|
///
|
||||||
final Animation<double> breath;
|
/// It used to breathe — the rings swelled and settled on a 4s cycle to say
|
||||||
|
/// "still waiting" on an unassigned trip. That screen is an illustration now,
|
||||||
|
/// so the only caller left is the off-duty state, which is a settled state
|
||||||
|
/// rather than a wait and always held still. A controller repeating forever to
|
||||||
|
/// drive a value multiplied by zero is a rebuild per frame for nothing, so the
|
||||||
|
/// animation is gone and this widget — and its host — are stateless again.
|
||||||
|
class _Halo extends StatelessWidget {
|
||||||
final Color accent;
|
final Color accent;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final bool animate;
|
|
||||||
|
|
||||||
const _BreathingHalo({
|
const _Halo({required this.accent, required this.icon});
|
||||||
required this.breath,
|
|
||||||
required this.accent,
|
|
||||||
required this.icon,
|
|
||||||
required this.animate,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AnimatedBuilder(
|
|
||||||
animation: breath,
|
|
||||||
builder: (context, _) {
|
|
||||||
// 0 → 1 with the ends eased, so the halo pauses at full breath instead
|
|
||||||
// of bouncing off it.
|
|
||||||
final t = animate ? Curves.easeInOut.transform(breath.value) : 0.0;
|
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
child: Stack(
|
child: Stack(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
// Outer ring: the widest travel and the faintest tint, so what
|
// Outer ring: the widest and the faintest, so the shape reads as a
|
||||||
// the eye catches is movement rather than a pulsing disc.
|
// halo rather than as a stack of discs.
|
||||||
Container(
|
Container(
|
||||||
width: 132.w + 16.w * t,
|
width: 132.w,
|
||||||
height: 132.w + 16.w * t,
|
height: 132.w,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: accent.withValues(alpha: 0.05 * (1 - 0.35 * t)),
|
color: accent.withValues(alpha: 0.05),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(
|
Container(
|
||||||
width: 96.w + 8.w * t,
|
width: 96.w,
|
||||||
height: 96.w + 8.w * t,
|
height: 96.w,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: accent.withValues(alpha: 0.09),
|
color: accent.withValues(alpha: 0.09),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// The disc holding the glyph stays put. Something has to be
|
// The glyph's own disc. Its shadow is spread evenly rather than cast
|
||||||
// still or the whole group reads as drifting.
|
// downward: offset down, it threw a dark crescent across the ring
|
||||||
//
|
// underneath and the three circles read as one muddy shape.
|
||||||
// Its shadow is a soft even lift rather than a drop: offset
|
|
||||||
// downward it cast a dark crescent across the ring underneath and
|
|
||||||
// the three circles read as one muddy shape.
|
|
||||||
Container(
|
Container(
|
||||||
width: 64.w,
|
width: 64.w,
|
||||||
height: 64.w,
|
height: 64.w,
|
||||||
@@ -201,64 +164,5 @@ class _BreathingHalo extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `● Waiting for your hub` — the status line, as a pill.
|
|
||||||
///
|
|
||||||
/// It is what turns the screen from a dead end into a state: the rider is not
|
|
||||||
/// being told he has nothing, he is being told the app is still listening.
|
|
||||||
class _WaitingChip extends StatelessWidget {
|
|
||||||
final Animation<double> breath;
|
|
||||||
final Color accent;
|
|
||||||
|
|
||||||
const _WaitingChip({required this.breath, required this.accent});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 8.h),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: accent.withValues(alpha: 0.07),
|
|
||||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
AnimatedBuilder(
|
|
||||||
animation: breath,
|
|
||||||
builder: (context, _) {
|
|
||||||
final t = Curves.easeInOut.transform(breath.value);
|
|
||||||
return Container(
|
|
||||||
width: 7.w,
|
|
||||||
height: 7.w,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
// Never fully out: a dot that disappears reads as a fault,
|
|
||||||
// not a heartbeat.
|
|
||||||
color: accent.withValues(alpha: 0.45 + 0.55 * t),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
SizedBox(width: 8.w),
|
|
||||||
// Flexible, so a rider on the largest system font size gets a
|
|
||||||
// narrower chip rather than a clipped one.
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
'Waiting for your hub',
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: MilerType.micro.copyWith(
|
|
||||||
color: accent,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
|
||||||
|
import 'package:miler/data/service_profile.dart';
|
||||||
import 'package:miler/views/Dashboard/home/trip.dart';
|
import 'package:miler/views/Dashboard/home/trip.dart';
|
||||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||||
@@ -150,7 +152,7 @@ class _TripProgressRailState extends State<TripProgressRail> {
|
|||||||
|
|
||||||
final double viewport = _scroll.position.viewportDimension;
|
final double viewport = _scroll.position.viewportDimension;
|
||||||
final double target =
|
final double target =
|
||||||
((at - 1) * (_node.w + _minGap.w)) - (viewport / 2) + (_node.w / 2);
|
(at * (_node.w + _minGap.w)) - (viewport / 2) + (_node.w / 2);
|
||||||
final double clamped = target.clamp(
|
final double clamped = target.clamp(
|
||||||
0.0,
|
0.0,
|
||||||
_scroll.position.maxScrollExtent,
|
_scroll.position.maxScrollExtent,
|
||||||
@@ -207,17 +209,41 @@ class _TripProgressRailState extends State<TripProgressRail> {
|
|||||||
/// and every stop keeps its number underneath.
|
/// and every stop keeps its number underneath.
|
||||||
Widget _rail() {
|
Widget _rail() {
|
||||||
final total = progress.length;
|
final total = progress.length;
|
||||||
|
final allDoneNow = _doneCount == total;
|
||||||
|
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final double available = constraints.maxWidth;
|
final double available = constraints.maxWidth;
|
||||||
final double gap = _gapFor(available, total);
|
final double gap = _gapFor(available, total + 2);
|
||||||
final double content = (total * _node.w) + ((total - 1) * gap);
|
// Two framing nodes and their two connectors ride with the stops.
|
||||||
|
final double content = ((total + 2) * _node.w) + ((total + 1) * gap);
|
||||||
// +0.5 so a route that lands exactly on the width is not tipped into a
|
// +0.5 so a route that lands exactly on the width is not tipped into a
|
||||||
// scroll view by float error.
|
// scroll view by float error.
|
||||||
final bool fits = content <= available + 0.5;
|
final bool fits = content <= available + 0.5;
|
||||||
|
|
||||||
final children = <Widget>[];
|
final children = <Widget>[
|
||||||
|
// ── A rail with one stop on it was a badge ──
|
||||||
|
//
|
||||||
|
// A rider with a single accepted order met one red circled **1** at
|
||||||
|
// the top of his Deliveries page — a counter, or a notification
|
||||||
|
// badge, but not a route. Nothing about it said *journey*, because a
|
||||||
|
// journey needs somewhere to start.
|
||||||
|
//
|
||||||
|
// So the run is framed by the two places it actually runs between:
|
||||||
|
// where he is standing now, and where the day ends. The stops sit
|
||||||
|
// between them, and even one of them now reads as
|
||||||
|
// "here → this pickup → done" rather than as the number one.
|
||||||
|
_EndNode(
|
||||||
|
icon: LucideIcons.locateFixed,
|
||||||
|
color: ColorConstants.primary,
|
||||||
|
),
|
||||||
|
_connector(
|
||||||
|
progress.first == StopProgress.pending
|
||||||
|
? StopProgress.pending
|
||||||
|
: StopProgress.done,
|
||||||
|
width: gap,
|
||||||
|
),
|
||||||
|
];
|
||||||
for (var i = 0; i < total; i++) {
|
for (var i = 0; i < total; i++) {
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
// A connector is coloured by the stop *behind* it, so the green
|
// A connector is coloured by the stop *behind* it, so the green
|
||||||
@@ -227,6 +253,20 @@ class _TripProgressRailState extends State<TripProgressRail> {
|
|||||||
}
|
}
|
||||||
children.add(_dot(i));
|
children.add(_dot(i));
|
||||||
}
|
}
|
||||||
|
// Where the day ends: a depot on a logistics loop, home on a round that
|
||||||
|
// finishes at the last door. Capability, never screen position — see
|
||||||
|
// [ServiceProfile.endsAtHub].
|
||||||
|
children.add(_connector(progress.last, width: gap));
|
||||||
|
children.add(
|
||||||
|
_EndNode(
|
||||||
|
icon: ServiceProfile.active.endsAtHub
|
||||||
|
? LucideIcons.warehouse
|
||||||
|
: LucideIcons.house,
|
||||||
|
color: allDoneNow
|
||||||
|
? ColorConstants.acceptGreen
|
||||||
|
: ColorConstants.borderStrong,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
final int? at = _currentPosition;
|
final int? at = _currentPosition;
|
||||||
|
|
||||||
@@ -249,9 +289,7 @@ class _TripProgressRailState extends State<TripProgressRail> {
|
|||||||
// Node centre, less half the glyph, so the scooter sits over
|
// Node centre, less half the glyph, so the scooter sits over
|
||||||
// the node rather than beside it.
|
// the node rather than beside it.
|
||||||
left:
|
left:
|
||||||
((at - 1) * (_node.w + gap)) +
|
(at * (_node.w + gap)) + (_node.w / 2) - (_scooter.w / 2),
|
||||||
(_node.w / 2) -
|
|
||||||
(_scooter.w / 2),
|
|
||||||
child: _scooterGlyph(),
|
child: _scooterGlyph(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -260,14 +298,55 @@ class _TripProgressRailState extends State<TripProgressRail> {
|
|||||||
|
|
||||||
if (fits) return layers;
|
if (fits) return layers;
|
||||||
|
|
||||||
|
// ── A scrolling rail has to say it scrolls ──
|
||||||
|
//
|
||||||
|
// A 21-stop round draws about seven nodes on a 390pt phone, and the
|
||||||
|
// eighth was sliced clean down its middle by the viewport edge — a
|
||||||
|
// half circle and a connector stopping in mid air, which reads as a
|
||||||
|
// clipping bug rather than as "there is more route this way". The
|
||||||
|
// rider's own count is off-screen for most of the day, so the one
|
||||||
|
// thing this edge has to do is invite the push that reveals it.
|
||||||
|
//
|
||||||
|
// A fade, not a chevron or a shadow: the content *continues*, it is
|
||||||
|
// not covered by something, and the app spends its edges on the
|
||||||
|
// route rather than on furniture pointing at it.
|
||||||
|
//
|
||||||
|
// The stops are `daylightSurface` — this widget's own ground on both
|
||||||
|
// tabs that draw it — so the ramp dissolves into the page instead of
|
||||||
|
// laying a grey band over it. Only the scrolling branch is masked; a
|
||||||
|
// short route ends where the route ends and has nothing to hint at.
|
||||||
|
//
|
||||||
|
// `dstIn` multiplies the layer's alpha by the gradient's, so the
|
||||||
|
// nodes fade rather than being painted over — the mask has to be
|
||||||
|
// sampled over the whole box for that, hence `bounds` unmodified.
|
||||||
|
const double fade = 24;
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: _railHeight,
|
height: _railHeight,
|
||||||
|
child: ShaderMask(
|
||||||
|
blendMode: BlendMode.dstIn,
|
||||||
|
shaderCallback: (bounds) => LinearGradient(
|
||||||
|
begin: Alignment.centerLeft,
|
||||||
|
end: Alignment.centerRight,
|
||||||
|
colors: const [
|
||||||
|
Color(0x00FFFFFF),
|
||||||
|
Color(0xFFFFFFFF),
|
||||||
|
Color(0xFFFFFFFF),
|
||||||
|
Color(0x00FFFFFF),
|
||||||
|
],
|
||||||
|
stops: [
|
||||||
|
0.0,
|
||||||
|
(fade / bounds.width).clamp(0.0, 0.5),
|
||||||
|
1 - (fade / bounds.width).clamp(0.0, 0.5),
|
||||||
|
1.0,
|
||||||
|
],
|
||||||
|
).createShader(bounds),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
controller: _scroll,
|
controller: _scroll,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
physics: const BouncingScrollPhysics(),
|
physics: const BouncingScrollPhysics(),
|
||||||
child: layers,
|
child: layers,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -279,11 +358,7 @@ class _TripProgressRailState extends State<TripProgressRail> {
|
|||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: _scooter.w,
|
width: _scooter.w,
|
||||||
height: _scooter.w,
|
height: _scooter.w,
|
||||||
child: Icon(
|
child: Icon(LucideIcons.bike, size: 20.sp, color: ColorConstants.primary),
|
||||||
Icons.two_wheeler_rounded,
|
|
||||||
size: 20.sp,
|
|
||||||
color: ColorConstants.primary,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,7 +404,7 @@ class _TripProgressRailState extends State<TripProgressRail> {
|
|||||||
// Fixed against the box: the circle does not grow with the system
|
// Fixed against the box: the circle does not grow with the system
|
||||||
// font scale, so the digits inside it cannot either.
|
// font scale, so the digits inside it cannot either.
|
||||||
fontSize: 14.sp,
|
fontSize: 14.sp,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: -0.3,
|
letterSpacing: -0.3,
|
||||||
height: 1,
|
height: 1,
|
||||||
color: ink,
|
color: ink,
|
||||||
@@ -407,7 +482,7 @@ class _TripProgressRailState extends State<TripProgressRail> {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14.sp,
|
fontSize: 14.sp,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: -0.2,
|
letterSpacing: -0.2,
|
||||||
color: ColorConstants.slateText,
|
color: ColorConstants.slateText,
|
||||||
fontFamily: FontConstants.fontFamily,
|
fontFamily: FontConstants.fontFamily,
|
||||||
@@ -419,7 +494,7 @@ class _TripProgressRailState extends State<TripProgressRail> {
|
|||||||
allDone ? 'All stops done' : '$done of $total done',
|
allDone ? 'All stops done' : '$done of $total done',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12.5.sp,
|
fontSize: 12.5.sp,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: -0.2,
|
letterSpacing: -0.2,
|
||||||
color: allDone
|
color: allDone
|
||||||
? ColorConstants.acceptGreen
|
? ColorConstants.acceptGreen
|
||||||
@@ -455,13 +530,18 @@ class _TripProgressRailState extends State<TripProgressRail> {
|
|||||||
// whole line ellipsises as a unit if the route label is long.
|
// whole line ellipsises as a unit if the route label is long.
|
||||||
Text(
|
Text(
|
||||||
allDone
|
allDone
|
||||||
|
// Where the day ends follows the capability, not the screen.
|
||||||
|
// A milk run finishes at the last door; there is no depot at
|
||||||
|
// the end of it and nothing to carry back to one.
|
||||||
|
? (ServiceProfile.active.endsAtHub
|
||||||
? 'Head back to the hub'
|
? 'Head back to the hub'
|
||||||
|
: 'Round complete')
|
||||||
: _footStatus(total: total, done: done),
|
: _footStatus(total: total, done: done),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11.5.sp,
|
fontSize: 11.5.sp,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w600,
|
||||||
color: allDone
|
color: allDone
|
||||||
? ColorConstants.acceptGreen
|
? ColorConstants.acceptGreen
|
||||||
: ColorConstants.secondaryText,
|
: ColorConstants.secondaryText,
|
||||||
@@ -481,11 +561,48 @@ class _TripProgressRailState extends State<TripProgressRail> {
|
|||||||
if (at != null) 'On stop $at of $total' else '${total - done} to go',
|
if (at != null) 'On stop $at of $total' else '${total - done} to go',
|
||||||
if (skipped > 0) '$skipped skipped',
|
if (skipped > 0) '$skipped skipped',
|
||||||
if (left != null && left > Duration.zero)
|
if (left != null && left > Duration.zero)
|
||||||
'${formatTripDuration(roundTripDuration(left))} left, then hub',
|
// ── "then hub" is a parcel sentence ──
|
||||||
|
//
|
||||||
|
// A logistics day is a loop: leave the hub empty, fill up at customer
|
||||||
|
// doors, return with the consignments. A meal rider finishes at the
|
||||||
|
// last subscriber's door and goes home — telling him his round ends at
|
||||||
|
// a warehouse he has never visited is a journey nobody is making, and
|
||||||
|
// the same fiction the RETURN · HUB row was removed from Home for.
|
||||||
|
ServiceProfile.active.sourceIsKitchen
|
||||||
|
? '${formatTripDuration(roundTripDuration(left))} left'
|
||||||
|
: '${formatTripDuration(roundTripDuration(left))} left, then hub',
|
||||||
];
|
];
|
||||||
return parts.join(' · ');
|
return parts.join(' · ');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One end of the run: where the rider is standing, or where his day finishes.
|
||||||
|
///
|
||||||
|
/// Deliberately not a numbered node — these are not stops he works, they are
|
||||||
|
/// the two ends the stops sit between. Same footprint as a stop node so the
|
||||||
|
/// rail stays on one axis, hollow so a place he does not *do* anything at never
|
||||||
|
/// competes with one he does.
|
||||||
|
class _EndNode extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
const _EndNode({required this.icon, required this.color});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: 34.w,
|
||||||
|
height: 34.w,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: ColorConstants.pureSurface,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: color, width: 2),
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 15.sp, color: color),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Hub accent, shared with the Home trip card.
|
/// Hub accent, shared with the Home trip card.
|
||||||
final Color kRailHubColor = ColorConstants.tertiary;
|
final Color kRailHubColor = ColorConstants.tertiary;
|
||||||
|
|||||||