diff --git a/DESIGN_SYSTEM.md b/DESIGN_SYSTEM.md index 1776475..ca4b030 100644 --- a/DESIGN_SYSTEM.md +++ b/DESIGN_SYSTEM.md @@ -185,18 +185,79 @@ Pinned by: `home_gutter_test` (hero and route share an edge), group), `activity_list_test` (a sheet is `working` or `canvas`, never the warm off-white). +## 6b. The third pass: the glass came out, and forms became bands + +Two changes, made together because the second one is only possible once the +first is done. + +### The glass is gone + +Nothing in `lib/` is translucent any more. What was removed, and why each one +was not worth what it cost: + +| Was | Now | Why | +|---|---|---| +| `milerGlassSurface()` — `BackdropFilter` at sigma 18 on every bar | **deleted** | a full-width blur re-samples everything behind it every frame, on the mid-range Android this ships to, over a map that repaints as it pans. It was already rationed to sigma 18, which is the tell that it was tolerated rather than enjoyed. | +| `glassCard` `#EBFFFFFF` (92% white) | `#FFFFFF` | a translucent card takes its contrast from whatever happens to be behind it — the same address is crisp over the canvas and grey over a map. | +| `glassSheet` `#E0FDFDFE` (88%) | `#FFFFFF` | every point of transparency was contrast taken off an address read one-handed at a doorstep in sun. | +| `glassCardLive` `#0D960019` laid *over* the card | `#FAF2F3`, the same tint flattened | with an opaque surface the overlay would cover the content rather than warm it. A live card is a card with a different fill. | +| `glassRim` `#8CFFFFFF` — a specular highlight | `#E5E7EB`, a hairline | the highlight only read as an edge because the surface under it was see-through. | +| `glassRed` | **deleted** | its only caller was the blur. | + +`ColorConstants.tint(accent, amount, {on})` replaces the +`accent.withValues(alpha: …)` idiom wherever it was painting a *surface*. +It resolves the composite once against a named ground and returns an ordinary +opaque colour. Same appearance where the old code was already over white; +predictable everywhere else. Borders and scrims keep their alpha — those are +lines and dims, not surfaces. + +### Forms are bands, not cards + +`MilerBand` (`app_widgets.dart`). A card is right for a screen showing a *list +of objects* — a stop, a job, a booking — because the margin is what says these +are separate things. It is wrong for a screen that is one long thing: a form, a +review, a receipt. There the page margin buys nothing and costs twice. + +- **Width.** 32–40pt off a 390pt phone, spent on two strips of empty ground + beside text that is mostly addresses. On the verification screen that is the + difference between an address on two lines and one on three. +- **A second boundary.** The gap between two cards already separates them; the + outline says it again. + +So a band runs edge to edge, the page's ground showing between one band and the +next is the only separator, and the margin moves *inside* as padding — air +around words rather than air around a box. `MilerBand.pad` (20) and +`MilerBand.gap` (10) are the two numbers, declared once, so every band's left +edge lines up down the whole flow. + +A band cannot carry state in an outline it does not have, so state goes in the +**fill**, and a finished band adds `rail` — a 3pt accent stripe down its +leading edge. That is the one mark that survives a phone at arm's length in +sun. + +**Which screens took it.** The whole first-mile flow, which is where forms live: +`stop_verify` (collect · hand over · confirm), `shipment_capture`, +`shipment_review`, `collect_payment`, `delivery_proof_page`. Each is now a grey +page (`daylightSurface`) carrying full-bleed white bands — the inverse of the +white-page/tinted-panel arrangement §6 landed on, and taken for the width +rather than for the tone. + +Home, Bookings and Deliveries are **unchanged**. They are lists of objects and +the card is still the right shape there. + ## 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. +- ~~**`GlassCard` still carries three separation mechanisms.**~~ Closed by §6b: + the fill is opaque, and the hairline stays — it is the documented fix for + shadows crushing on a budget LCD, reported twice from a device, and nothing + about going opaque changes that. Still worth a look **on a device**. - **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. +- **No device or text-scale QA** of the new ground. The blur question is moot: + §6b removed every `BackdropFilter` in `lib/`, so there is nothing left to + profile. diff --git a/MILER_API_REQUIREMENTS.md b/MILER_API_REQUIREMENTS.md index d0534ab..f6ff969 100644 --- a/MILER_API_REQUIREMENTS.md +++ b/MILER_API_REQUIREMENTS.md @@ -1,7 +1,7 @@ # Miler App — API requirements for the backend team **From:** Miler rider-app engineering -**Date:** 21 Aug 2026 +**Date:** 21 Aug 2026 · **revised 24 Aug 2026** **Against:** *Doormile Miler App — API reference* (38 `/miler/*` routes) **Base URL:** `https://api.doormile.com/api/v1` @@ -61,6 +61,228 @@ cannot leave the phone until this exists), **13** (populate the sequence), --- +## 0y. Backend reply — 25 Aug, and what the app did with it + +Point by point, with what changed on this side. Where an item needed no code, +that is said rather than left implied. + +| Their answer | App-side | +|---|---| +| **1 · `reached` persists** `Arrived_At_Pickup` + lat/lng + `reachedat` | Consumed. The local ARRIVED store is **kept for now** — see below. | +| **2 · `step: 0` is not a bug; gate on `sequencedat`** | **Fixed, and it was a real bug on our side.** | +| **3 · pickup-flow endpoints live**, `PATCH /addresses` built | Already wired; §3 of `MILER_LOGISTICS_API.md` describes it. | +| **4 · `pickup_proof` accepts a `bookingid`** | **Wired.** The parcel photo now leaves the phone. | +| **5 · Spaces rotation held** (jupiter still ships the key) | Understood. See the ask below. | +| **6 · Ordering not server-enforced** — do we want it? | **Yes, please.** Reasoning below. | +| **7 · Error codes present** | Consumed; nothing to do. | + +### 2 · `sequencedat` — the bug was ours + +The contract was read correctly and implemented correctly: `RouteOrder` gates on +`sequencedat` and treats `step: 0` with a null stamp as *no route, fall back*, +exactly as specified. + +It never ran, because **`ApiConfig.pickupFromBooking` was dropping the field.** +The adapter mapped `step`, `stoptype`, `etaminutes`, `cumulativekms` and +`cumulativeeta` — and not the one that says whether any of them mean anything. +So every adapted row looked unsequenced and the app fell back to nearest-first +on routes the hub had actually solved. + +Carried through now, and pinned by `route_order_test` so it cannot be dropped +again. **No backend action needed, and no `bookingno` to send you** — the rows +you saw with `step: 0` + `sequencedat: null` were correct and the app was +mishandling the correct answer. + +### 1 · Why the local ARRIVED store is still there + +It is redundant now and it is not yet removable, for one reason: your own +verification note says the `Miler_Assigned → Arrived_At_Pickup` flip is the one +branch that could not be exercised live — booking 179 never got assigned. + +The store costs nothing while it waits. It is the weakest local record in the +app: any server answer outranks it, and it is dropped the moment the stop is +collected. So during the rollout it is a superset — it covers a build meeting an +older deployment, and it goes quiet the instant `reached` starts persisting. + +**Removing it is one constant and one call site.** Confirm the flip on a real +assigned booking and it goes in the same sitting. + +### 6 · Yes, enforce the ordering + +`addresses` → `parcel` → `payment` → `pickup-complete` is a client convention +today, and the app follows it deliberately — the note in +`MILER_LOGISTICS_API.md` §4 states each dependency. Please make it a server +rule with `INVALID_STATE`. + +Not because we expect to violate it. Because a convention only one client knows +is a convention that breaks the first time a second client — or a retry, or a +background replay — gets the order wrong, and the failure would be silent: a +consignment routed and priced from an address that arrived too late, with +nothing on either side saying so. + +--- + +## 0x. What we still need from you + +Two asks, one small and one that is costing requests today. + +### A · A per-day series on `GET /miler/earnings` — P2 + +The Earnings chart draws seven bars. The response carries six **totals for the +period asked for** and no series, so the app was reading a `breakdown` array +that is not on the contract — it was always null, and the chart drew an empty +week while the rider had ridden all of it. + +It is fixed on our side by asking **seven `period=daily&date=…` calls +concurrently** and building the week from them. That works, and it is bounded, +and it is seven requests for one chart. + +**Ask:** `breakdown: [{ day, kms }]` on `period=weekly`. The app already has the +fast path for it — the seven calls are skipped the moment that field appears, +with no further change here. + +### B · Per-stop `riderkms` on `GET /miler/bookings` — P2 + +Activity now shows the rider his distance for the day. The only source is +per-stop distance, which the app reads from `compliance.actualkm` / +`riderkms` — present on some rows and absent on most, so the figure reads as a +dash for riders who have certainly ridden. + +**Ask:** return the distance the server already computes per stop on the booking +row, under whichever of those two names you prefer. The app reads both, plus its +own `actualkms` spelling, so no coordination is needed on naming. + +### C · Nothing else + +The ARRIVED → PICKED → ACTIVE ladder, the pickup flow, the pricing call, the +upload route and the error codes are all contracted and consumed. If item 1's +flip verifies, this list is A and B. + +--- + +## 0z. Superseded — the two asks that were open before the 25 Aug reply + +**Both are answered.** Kept because the reasoning under each is still the record +of why the app is shaped the way it is, and because §1's workaround is still in +the build. Read §0y first; this is the request that prompted it. + +## 0z. Open, as of 25 Aug — the two that still shape the rider's day + +Everything else on the ARRIVED → PICKED → ACTIVE flow is settled. These two are +not, and both are already worked around on the app side rather than waiting on +you — so nothing is blocked, but both workarounds are costs we would rather not +keep paying. + +### 1 · `POST /miler/bookings/:id/reached` does not persist the arrival + +**Today.** It answers `200 {"success": true}` and the booking stays on +`Miler_Assigned`. The arrival is never recorded, so the hub cannot see that a +rider is standing at a pickup. + +**Request body the app already sends:** + +```jsonc +{ "latitude": 11.0168, "longitude": 76.9558 } +``` + +**Please make it write:** the ARRIVED state, the latitude/longitude received, +and an arrival timestamp. + +**What the app does meanwhile.** ARRIVED is treated as a **rider-owned rung** +and kept in a local store (`arrived_order_ids`), because the rung existed only +as a field on an in-memory row and the very next queue poll — which the arrival +sheet itself triggers — rebuilt that row from a server still saying +`Miler_Assigned` and put it back on ACCEPTED. From the rider's side, marking +arrived did nothing. + +That store is deliberately the **weakest** local record in the app: anything the +server does know about a stop outranks it, and it is dropped the moment the stop +is collected. **Delete it the day this endpoint persists** — a local record that +outranks the server is a liability once the server has the answer. It is one +constant and one call site; say the word and it goes. + +### 2 · `GET /miler/bookings` returns `step: 0` on every live row + +The transport is there and the app consumes all five fields. The data is not: +every live booking arrives with `step: 0`, so the hub-planned route order cannot +be followed and the app falls back to sorting nearest-first. A rider works an +optimised route by guessing at it. + +**Expected on each row:** + +```jsonc +{ "step": 1, "stoptype": "...", "etaminutes": 10, + "cumulativekms": 2.5, "cumulativeeta": 15 } +``` + +This is request 13 below, restated because it is the largest remaining gap on +the logistics line and the one with no app-side workaround worth having. + +### 3 · Nothing else is needed for this flow + +The ARRIVED → PICKED → ACTIVE ladder needs no other backend change, unless +something has moved in the `pickup-complete` / consignment-state contract that +§1–§3 of `MILER_LOGISTICS_API.md` does not already describe. + +**For the record, what shipped on the app side alongside this:** ARRIVED kept +locally; PICKED held after `pickup-complete` instead of mirroring the +compatibility-mode release; ACTIVE only once the rider presses **Start +delivery**; geofence enforcement restored at 10 m with GPS-accuracy and +stale-fix handling and a no-fix refusal; and tests pinning the rung ladder. + +--- + +## 0a. Second delivery — what the app consumed on 24 Aug + +The backend's follow-up batch (stop typing, COD, pre-pickup skip, earnings +counts, profile fields, reject-by-body) is **in and wired**. What changed on +this side: + +| Shipped | Where it lands in the app | +|---|---| +| `stoptype` + `step` on `GET /miler/bookings` | Stop typing is authoritative now — the app no longer infers a stop's leg from its shape. The mixed pickup/delivery route is reachable. | +| `codamount` + `paymentmode` | Cash-to-collect is read from the booking instead of a field the payload never carried, so a COD stop states the real figure at the door. | +| `POST /bookings/:id/skip` | The pre-pickup skip. The app was posting a **booking** id to `/consignments/:id/skip` — the only skip route that existed — which either 404'd or skipped whichever consignment happened to hold that number. That is now the right route with the right id, and the stop stays resumable. | +| `cancelled_stops` + `total_stops` | The success rate has a denominator. It was `completed_stops` alone, which read 100% on a day with three cancellations. | +| `email` + `address` on `PUT /profile` | Both fields the edit screen has always collected now leave the device. `EMAIL_IN_USE` (409) is branched on by code. | +| `reason` in the reject body | Sent in the body; the query-string duplicate is dropped. | + +**Still not shipped, and still shaping the app:** `step` is `0` on every live +row (request 13), so the admin route order the app is built to follow does not +exist in the payload and it falls back to its own ordering. That one is the +difference between the app following the hub's plan and inventing a plan, and +it is now written up on its own — **`MILER_ROUTE_ORDER_REQUEST.md`** — because +it is the single largest gap left between what the console believes a rider is +doing and what he is actually doing. + +Short version: the console decides the order, the rider follows it, and the app +already works that way. But `step` is never written, and it disappears from +`/miler/assignments` the moment a stop is collected — which is the moment the +delivery leg starts. So every delivery in production today is ordered by the +app, not by the hub, and neither side can currently tell. + +--- + +## 0c. Third delivery — answered 24 Aug, consumed the same day + +Every open question came back. What the app did with each: + +| Answer | What changed here | +|---|---| +| **Sequencing is automatic**, `sequencedat` is the authority signal, `step` survives the pickup leg | `RouteOrder` reads the stamp as the authority and accepts a positive `step` alongside it. `step: 0` + null stamp is now a *stated* three-case condition rather than an unexplained fallback. | +| **POD upload — `POST /miler/uploads/sign`** | Wired end to end: sign, `PUT` the bytes with the returned headers verbatim (the `x-amz-acl` is part of the signature, and the bearer token must not travel to the object store), send the public URL as `photourl`. A failed upload never blocks a hand-over — the parcel is in the customer's hands whatever the network did, and the local copy is kept. | +| **Skip: `attemptcount` is truth, ceiling of 3, no auto-RTO** | The app already parked rather than closed a skipped stop; it now reads the count off the response and, on the third, tells the rider the hub has it — the difference between a fourth attempt and a phone call. | +| **No `tripid`/`slotid`, ever** | The day-part split is documented client behaviour now rather than a guess standing in for a field. Recorded on `TripSlots`, which is the class that would be deleted if that changed. | +| **Delivery OTP is fully server-side** | Nothing to build: the app never claimed to verify a code, and it already branches on `OTP_INVALID` / `OTP_REQUIRED` by code. "OTP verified" is only ever drawn off a 200. | +| **`ridercharges` is the client price, not rider pay; `bonuspoints` unused** | The reward-points tile is **withheld** rather than showing a permanent zero — that reads as *you have earned nothing*, not as *not built yet*. It returns automatically the moment the figure is non-zero. No money figure anywhere in the app is presented as what a rider earned. | +| **`reached` persists behind the flag** | Already handled: the app reads the transition rather than the 200, so it is correct with the flag off and with it on. | + +Left on our side: the console status mappings (`arrived_at_pickup → arrived`, +`collected_by_miler → picked`) before the flag is flipped. That is console work, +not rider-app work. + +--- + ## 0b. Summary table | # | Request | Priority | Status | @@ -74,11 +296,20 @@ cannot leave the phone until this exists), **13** (populate the sequence), | 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 | +| 13 | Route sequence — field shipped, **never populated** | **P0** | ⚠️ **Half** — `step` is on both endpoints but is `0` everywhere, `sequencedat` null. **Written up on its own: `MILER_ROUTE_ORDER_REQUEST.md`** | | 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 | +| 17 | What `/admin/bookings` returns in `status` | **P1** | ✅ Answered 22 Aug — frozen booking status, plus `consignmentstatus` for the live one | +| 18 | Per-stop type + step ordering on `/miler/bookings` | **P0** | ✅ Shipped 24 Aug — consumed | +| 19 | `codamount` on the booking | **P0** | ✅ Shipped 24 Aug — consumed | +| 20 | A pre-pickup skip route | **P1** | ✅ Shipped 24 Aug — consumed; fixed a wrong-id post on our side | +| 21 | `cancelled_stops` / `total_stops` on earnings | **P2** | ✅ Shipped 24 Aug — consumed | +| 22 | `email` / `address` on `PUT /profile` | **P2** | ✅ Shipped 24 Aug — consumed | +| 23 | A trip/slot id, if the console owns the split into runs | **P2** | ✅ Answered 24 Aug — there is none; the day-part split is ours by agreement | +| 4 | A file-upload route for proof of delivery | **P1** | ✅ Shipped 24 Aug — signed URL, wired end to end | +| 13 | Route sequence — populated, with `sequencedat` as the authority | **P0** | ✅ Answered 24 Aug — automatic on every assignment; consumed | +| 24 | A rider payout rate card | **P2** | ⏳ **Open** — `ridercharges` is the client price; no rider figure is shown until there is one | --- @@ -493,7 +724,7 @@ sides.** `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`: +- The console already renders it — its rider-detail page (`riders.js`): `riderLogsdata?.battery ? \`${battery}%\` : 'N/A'`. **What to check on your side:** the console reads it from *rider logs*, so it @@ -787,3 +1018,36 @@ separate rung only once the flag is on. 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? + +6. **Who owns the split into Trip 1 / Trip 2 / Trip 3?** The app groups a + rider's stops into up to three runs by *day part* — morning, afternoon, + evening — because nothing in the payload names a run. If the console assigns + runs, we should be following its ids rather than guessing, and a rider's + "Trip 2" and the hub's would then be the same object. Either answer is + workable; we need to know which: + + - **The console owns it** → return `tripid` (and `slotid`, if separate) on + every row of `GET /miler/bookings`, stable for the day. The app follows it + and drops the day-part split entirely. + - **Nobody owns it** → say so, and the day-part split stays as the documented + client behaviour rather than an unlabelled guess. + +7. **Is `step` ever written today?** Request 13 says the field exists and is `0` + on every live row. Before we spend more time on the fallback: is sequencing + generated automatically when assignments are created, or does an operator + have to trigger a route/optimizer action? If it is manual, the app should + probably say *unsequenced* rather than silently ordering the stops itself. + +8. **Where should a re-quoted price go?** A logistics rider re-prices a shipment + at the door. `POST /bookings/:id/payment` records what was *collected*, and + `bookingserviceoptions.estimatedprice` is the customer's original figure and + is not rider-writable — so the new quote has nowhere to land and the booking + keeps an estimate nobody honoured. A rider-writable price field on the + booking, or an accepted field on `parcel`, closes it. + +9. **Delivery OTP.** `deliver` takes an `otp` and the tenant flag + `requiredeliveryotp` gates it, but there is no delivery-OTP column on + consignments — so with the flag on there is nothing to check the code + against. Is the column planned? Until it exists the app records that a code + was *presented*, not that it was correct, and we would rather not draw a + verification the backend cannot perform. diff --git a/MILER_FOR_STITCH.md b/MILER_FOR_STITCH.md new file mode 100644 index 0000000..da519db --- /dev/null +++ b/MILER_FOR_STITCH.md @@ -0,0 +1,227 @@ +# Miler — a design brief + +A one-page description of the app, for handing to a design tool. Everything here +is what the app *is*; the long engineering document is `ABOUT_MILER.md`. + +--- + +## 1. What it is + +**Miler** is the rider app for **Doormile**, a delivery company in Coimbatore, +India. The office assigns work; this is what the person on the motorbike holds. + +One rider, one phone, one shift: sign in, go on duty, collect parcels or meals, +ride to doors, hand them over, and report each step as it happens. + +**Who uses it:** a delivery rider, one-handed, outdoors, in direct sun, often +wearing gloves, often in a hurry, phone clamped to a handlebar. Nothing on any +screen should need to be studied. Every screen answers one question, big. + +**The one rule the whole app hangs off:** *one order = one bag.* Five orders from +a kitchen means five bags means five deliveries. Counts are always stated the +same way — `5 orders · 5 bags` — because a rider can check that against a shelf. + +**Two kinds of work, one app.** Which one a rider sees is decided at login. + +| | Meals ("milk run") | Parcels (logistics) | +|---|---|---| +| The job | load at a kitchen, then a round of drops | collect from a customer, take it to the depot | +| Work tab is called | Deliveries | Bookings | +| Cash at the door | never | sometimes | +| Ends the day | at the last customer's door | back at the hub | + +--- + +## 2. Brand + +**Deep burgundy `#800020`.** Used sparingly and always meaningfully: the app +bar, the primary action, the selected state, and the one figure a screen is +answering with. Never as decoration. + +*(The shipping app currently uses `#960019`, a hair brighter. Design with +`#800020`.)* + +### Palette + +| Role | Hex | +|---|---| +| **Brand** — bars, primary buttons, selected state | `#800020` | +| Brand tint — selection wash, icon backgrounds | `#800020` at 10% | +| **Success** — delivered, collected, done, money | `#00472C` | +| Success tint — status chips | `#00472C` at 10% | +| **Attention** — skipped, late, needs a second visit | `#8E5A00` | +| **Danger** — cancelled, destructive | `#BA1A1A` | +| Page background | `#F8F9FA` | +| Card | `#FFFFFF` | +| Card border | `#E5E7EB` (1px) | +| Tonal block *inside* a card | `#F3F4F5` | +| Heading / figure text | `#191C1D` | +| Body / supporting text | `#584141` | +| Labels, timestamps, captions | `#6B6B6B` | +| Text on burgundy or green | `#FFFFFF` | + +**Colour is never the only signal.** Every state carries a word *and* an icon +*and* a tint. A rider reading a scratched screen in sunlight must not have to +tell two greens apart. + +### Type + +**Inter**, or Poppins. Headings 600–700 with slightly tight letter spacing; +body 400–500; small labels 500 with wide letter spacing. Numbers a screen is +about (minutes away, parcels, money) are set at heading weight and size. + +| Use | Size | +|---|---| +| The screen's answer (a count, an ETA) | 30–40 | +| Screen title | 22–28 | +| Card heading (a customer's name) | 16–18 | +| Body | 14–16 | +| Caption / timestamp | 12–13 | +| Eyebrow label (`DELIVER TO`) | 9–10, uppercase, letter-spaced | + +### Shape, depth, spacing + +- **Big corners.** Cards 24–28. Blocks inside a card 16–18. Chips, buttons and + avatars fully rounded. Never a square corner. +- **Flat depth.** A card is white, a 1px `#E5E7EB` border, and a very soft + shadow (0 4 20, 4% black). No heavy shadows, no glass, no blur, no gradients. +- **Rhythm.** 8 / 16 / 32. 14–16 margin down each side of the page, 16 padding + inside a card. Nothing interactive touches the screen edge. +- **Buttons** are 56 tall, full width, fully rounded, white text on burgundy. +- **Icon containers** are circles or soft squares in a 10% tint of the icon's + own colour. +- **Status chips** are a light tint of the status colour with darker text of the + same hue. + +--- + +## 3. The flow + +``` + sign in → go on duty + │ + ▼ + HOME ── today's route, accept the work ──┐ + │ │ + ▼ │ + MAP ── ride there, hand over ────────────┤ + │ │ + ▼ │ + DELIVERIES ── what is still in his hands ──┘ + │ + ▼ + ACTIVITY ── what he did today ── DELIVERY DETAILS (one record) + + ACCOUNT ── earnings, profile, help +``` + +Four tabs at the bottom, always: **Home · Deliveries · Activity · Account.** + +A stop's life, in order: + +``` +assigned → accepted → arrived → picked up → out for delivery → delivered +└──────────── HOME ────────────┘ └────── DELIVERIES ──────────┘ +``` + +--- + +## 4. The screens + +### Sign in +Phone number, then a 4-digit PIN. Brand-filled screen, logo, one big button. + +### Home — *"what am I doing today?"* +The rider's route for the day. + +- App bar: **Home**, and an on-duty switch. +- A summary line: how many stops are left, how far, how long. +- **Trip 1 · Trip 2 · Trip 3** — a segmented control; a rider gets up to three + runs a day and each shows its own progress (`Done`, `Active`, `40%`). +- The route: stops grouped under the kitchen or pickup point they belong to, + each with a number, a name, an area, and a bag label. +- The rider ticks the stops he accepts; a bar at the bottom commits them. +- Tapping a stop opens the **stop detail sheet**. + +### Stop detail sheet (bottom sheet) +Everything about one stop. + +- A numbered disc, the customer's name, `Stop 3 of 8`. +- A small map of the leg, with `2.0 km away · 6 min ride` written across the + bottom of it. +- Two figures side by side: **DISTANCE** and **EST. ARRIVAL** (the arrival in + burgundy, large). +- `DELIVER TO` + the full address in a tonal block. +- `TASK DETAILS` — a card: *Deliver 1 parcel · Photo of the parcel*, with a call + button on the right. +- Tonal tiles: order id, contact, bag, weight, booked slot. + +### Map screen — *"get me there, then let me close it"* +A full-screen map with the route drawn on it, and a sheet over the bottom 45%. + +- Big: **6 min**, then `2.0 km away · Arrive by 3:05 PM`, and a green + **Picked up** chip. +- A three-step rail: **Collected → On the way → Delivered**, in burgundy. +- The customer: initials avatar, name, order reference, a green call button. +- `DELIVER TO` and the address. +- One control, and it is a **slide**, not a button: + *slide to start delivery* → sets off and opens Google Maps; + then *slide to deliver* → photo (optional) → done. + +### Deliveries — *"what is in my hands right now?"* +The queue of parcels already collected. + +- A route rail across the top: `Trip 1 · 0 of 21 done`. +- **NOW** — one big card: the stop being worked, with the only button on screen. +- **UP NEXT** — the next stop, quieter. +- **LATER** — one line each. +- **SKIPPED** sits above them all, with a way back in. +- A bar at the foot: `4 orders in hand · Start round`. + +### Activity — *"what did I do today?"* +The shift log. + +- A white pill floating over the burgundy bar: **28** *delivered today*. +- A segmented filter: **All · Active · Completed · Cancelled**, with a sliding + white pill. +- Grouped by trip: a green tick disc, `Trip 1`, `28 delivered`. +- Each stop is a card: an icon tile, the customer's name, the time on the right, + the route line, and `Delivered · Gandhipuram · 1h 44m` — *Delivered* in green. +- Tapping one opens the record. + +### Delivery details — *"prove what happened"* +One finished stop. + +- App bar: **Delivery details**, with the order reference on the right. +- Hero card: a green disc, **Delivered**, *To dharaneesh*; on the right the ETA, + the actual time, and the day. +- Tonal tiles: **TOTAL DELIVERY TIME**, **PARCELS**, and — parcels only — + **ARRIVED AT HUB**. +- **Journey**: a horizontal timeline — Assigned → Arrived → Out → Delivered, + each with an icon and a time, the line green behind the rider and grey ahead. + A **View full log** link opens the detailed vertical version. +- Under it, a tonal card: *Delivered to customer*, the address, `3m at the door`. +- **Proof of Delivery**: the photograph, full width, with a *View full* button. +- Three folded rows at the bottom: Delivery details · Payment · Order details. + +### Account +Profile photo and name, a strip of figures (rewards, bookings, help), then +grouped cards: **Money** (earnings), **Account** (edit profile, documents), +**Support** (help, about, sign out). + +--- + +## 5. Rules to design by + +1. **One question per screen, answered big.** The count, the ETA, the outcome. +2. **One primary action visible at a time.** Never two buttons of equal weight. +3. **Irreversible actions slide; everything else taps.** Handing over a parcel + and setting off under your own name are slides. +4. **The exception is the only thing that gets colour.** A day of 25 delivered + stops must not be 25 green words with nothing left for the one that failed. +5. **Never invent data.** A missing time, distance or photo is simply absent — + never a zero, never a placeholder, never a greyed-out "pending". +6. **Empty states explain, they do not apologise.** "No work assigned yet" with + a way to refresh. +7. **Legible at arm's length, in sun, at 2× system text size.** Text can wrap or + shrink; it can never be clipped. diff --git a/MILER_LOGISTICS_API.md b/MILER_LOGISTICS_API.md new file mode 100644 index 0000000..0a89804 --- /dev/null +++ b/MILER_LOGISTICS_API.md @@ -0,0 +1,437 @@ +# Miler — the logistics line's API contract + +**From:** Miler rider-app engineering +**Date:** 25 Aug 2026 +**Base URL:** `https://api.doormile.com/api/v1` +**Scope:** the **logistics (parcel) line only** — `ServiceProfile.parcel`. +The meal line (`ServiceProfile.milkMan`) runs the same screens with different +capabilities and calls a strict subset of this; where the two diverge it is +called out. + +This is a companion to `MILER_API_REQUIREMENTS.md`, not a replacement. That +document is organised by *request* — what is broken and what we are asking for. +This one is organised by *call order*: what the logistics flow actually sends, +in the sequence it sends it, and which orderings are contractual rather than +incidental. Read this one to implement or verify a handler; read that one for +the open asks. + +Everything below is traced from the shipped app — `lib/data/miler_api.dart`, +`lib/providers/pickuplog/pickuplog_provider.dart`, +`lib/controllers/pickups_controller.dart` and the pickup flow under +`lib/views/Dashboard/pickups/`. + +--- + +## 1. What makes the logistics line different + +A meal run delivers something that already exists. **The logistics line +creates the shipment at the door.** The rider is the first person who stands in +front of the sender, so he is the first person who can establish the four facts +the consignment will be routed, priced and billed on: + +| Fact | Where it comes from | What depends on it | +|---|---|---| +| FROM / TO addresses + pincodes | the rider asks the customer | routing hub, pricing zone | +| Measured weight | the rider's scale | chargeable weight | +| Category + service type | the rider asks | the pricing rule that applies | +| Money taken | the rider's hand | the payment record, the COD ledger | + +Everything in §3 follows from that: a booking arrives half-specified and leaves +the door as a consignment with a tracking number. + +Capabilities that gate this flow (`lib/data/service_profile.dart`): + +``` +needsVerification true → the proof-of-work page runs +capturesShipmentAddresses true → the shipment desk runs +initiatesShipment true → the review page raises the order +``` + +All three are `false` on the meal line, which is why a milk run reaches none of +§3.2 – §3.7. + +--- + +## 2. Cross-cutting contract + +These apply to every call below and are not repeated per endpoint. + +### 2.1 Auth + +`Authorization: Bearer ` from `POST /miler/verify-pin`. The **one** +exception is `POST /pricing/check` (§3.3), which is unauthenticated. + +The bearer token must never be forwarded to the object store — see §3.8. + +### 2.2 Idempotency + +Sent as `Idempotency-Key: ::` on every write that +moves money or state: + +``` +payment::20260825 +pickup-complete::20260825 +``` + +The key is **derived, not random**, so a retry after a dropped acknowledgement +produces the same key and replays the first result. A genuine second attempt +tomorrow gets a new key and is allowed through. + +`IDEMPOTENCY_IN_PROGRESS` is not surfaced to the rider. The app waits and +re-asks twice before treating it as a failure — telling a rider his payment +failed while it is in the act of succeeding is the worst answer available. + +### 2.3 Error codes + +Every 4xx must carry a stable machine-readable `code`. The app branches on +`ApiResult.code` and **never** on message prose. Codes currently consumed: + +| Code | Meaning the app acts on | +|---|---| +| `INVALID_STATE` | the resource is not on a rung this call can move it from | +| `IDEMPOTENCY_IN_PROGRESS` | the first attempt is still running — wait, re-ask | +| `EMAIL_IN_USE` | profile edit conflict (409) | + +New codes are welcome; new *messages* used as signals are not. + +### 2.4 Coordinates + +`latitude` / `longitude` on a write always mean **where the rider is standing +at that moment**, not the booking's stored pin. The server computes rider +kilometres by haversine from these and writes them onto the earnings record, so +sending the booking's own coordinates silently zeroes the rider's distance for +that leg. + +--- + +## 3. The flow, in call order + +The whole logistics stop, from the rider tapping *I've arrived* to a shipment +existing on the hub's screen: + +``` + 3.1 POST /miler/bookings/:id/reached he is at the door + ────── proof of work, on the phone ───────────────────────────── + 3.2 (local) parcel count · condition · weight · code · photo + 3.3 POST /pricing/check what it costs + 3.4 PATCH /miler/bookings/:id/addresses FROM and TO ← must be first + 3.5 POST /miler/bookings/:id/parcel measured weight + 3.6 POST /miler/bookings/:id/payment the money ← must precede 3.7 + 3.7 POST /miler/bookings/:id/pickup-complete the shipment exists + ────── the consignment is now the subject ────────────────────── + 3.8 POST /miler/uploads/sign proof photo ← see §5.1 +``` + +### 3.1 Reached — `POST /miler/bookings/:bookingid/reached` + +```jsonc +{ "latitude": 11.0168, "longitude": 76.9558 } // both optional, both sent +``` + +Records arrival at the pickup address. Guarded on the device against a double +press (`MutationGuard`), so a duplicate is a network replay rather than a +second intent. + +> **Open:** this is a deployed no-op on production — see request 15 in +> `MILER_API_REQUIREMENTS.md`. The app still sends it. + +### 3.2 The proof of work — no network call + +`StopVerificationPage` collects, for a pickup leg: parcel count actually taken, +packaging condition (+ a mandatory note when it is not *Sealed & intact*), total +weight, the pickup code, and one photo of the parcels. Nothing here is posted on +its own. It feeds §3.5 (the weight) and §5.1 (the photo, which on this path is +not uploaded at all). + +A count **below** the booked quantity forces a note. That discrepancy is the +single most common cause of a first-mile dispute weeks later, and it is only +knowable at the door. + +### 3.3 The price — `POST /pricing/check` + +**Unauthenticated.** Called live from the shipment desk as the rider types, so +it must stay cheap. + +```jsonc +{ + "weight": 2.5, // required, kg, measured not booked + "service_type": "Normal", // "Normal" | "Express" + "pickup_pincode": "641012", // zone is derived server-side from the pair + "delivery_pincode": "641018", + "category": "General" // optional; see the list below +} +``` + +`category` ∈ `General · Documents · Electronics · Clothing · Fragile · Medical · +Automotive · Food`. + +Response (`data`): + +```jsonc +{ + "found": true, + "zone": "Local", // Local | Regional | National + "service_type": "Normal", + "weight": 2.5, + "currency": "INR", + "results": [ + { "category": "General", "category_label": "General", + "min_price": 150, "max_price": 180 } + ] +} +``` + +Two contract points that matter: + +- **A band, not a number.** The table prices a weight slab in a zone, so it + answers a range. The app quotes `min_price` — that is the figure the customer + was shown when the booking was raised, and quoting the top of a band at a + doorstep is how a rider ends up arguing about money. +- **`found: false` is not zero.** It means no rule covers this + weight/zone/category combination. The app shows "this cannot be priced here" + and refuses to continue. Do not answer `0` for an unpriceable combination — + a zero renders as a free shipment. + +The rider does **not** send `zone`. He has just captured two addresses and has +no business deciding what a zone is. + +### 3.4 The addresses — `PATCH /miler/bookings/:bookingid/addresses` + +**This must land before §3.7.** `pickup-complete` builds the consignment — its +routing hub *and* its pricing zone — from these values, and the handler refuses +an address change once that conversion has happened. There is exactly one window +and this is it. + +```jsonc +{ + "pickupaddress": "14 Cross Cut Road, Gandhipuram, Coimbatore", + "pickuppincode": "641012", + "pickuplatitude": 11.0168, + "pickuplongitude": 76.9558, + "deliveryaddress": "22 Race Course Road, Coimbatore", + "deliverypincode": "641018", + "deliverylatitude": 11.0043, + "deliverylongitude": 76.9695, + "deliverycity": "Coimbatore" +} +``` + +Every field is optional and **only non-empty values are applied**. This is a +correction, never a wipe: a booking that arrived with a good pickup address and +a vague destination must keep the good half. + +**Failure here stops the flow.** It is the one step in §3 the app refuses to +continue past, because the alternative is a shipment routed and priced from an +address the rider has just been told is wrong, with neither he nor the customer +ever seeing the discrepancy. + +### 3.5 The parcels — `POST /miler/bookings/:bookingid/parcel` + +```jsonc +{ + "parcels": [ + { "weight": 1.25, "length": 0, "width": 0, "height": 0 }, + { "weight": 1.25, "length": 0, "width": 0, "height": 0 } + ] +} +``` + +The rider weighs the **consignment**, not each box, so the total is split evenly +across the collected count. The chargeable total is correct; the per-parcel +figures are a distribution rather than a measurement. Dimensions are sent as +zero — nothing in the flow asks a rider to measure a box, and a made-up number +is worse than an absent one. + +`pickup-complete` recomputes chargeable weight from whatever this submitted, so +this is the last moment a measurement can be attached to the shipment at all. + +**Failure here does not stop the flow.** The stop completes and bills on the +customer's booked estimate instead of the measured figure. Blocking a rider at +a doorstep over a billing detail is the worse trade. + +### 3.6 The money — `POST /miler/bookings/:bookingid/payment` + +```jsonc +{ "amount": 150, "paymentmode": "Cash", "transactionref": "" } +``` + +`paymentmode` ∈ `Cash · UPI · Card · Wallet`. `amount` must be > 0 — the app +skips the call entirely for a prepaid or zero-rated shipment rather than +sending a zero. + +**This must precede §3.7.** `pickup-complete` converts the booking into a +consignment, and a payment recorded against a booking that has already been +converted has nothing to attach to. Money first, every time. + +The amount is the quote from §3.3, carried through on the stop's +`collectionamt` so the figure the rider showed the customer and the figure the +payment screen asks for cannot drift apart. + +> **Miler is the carrier, not the retailer.** This cash belongs to the shipper. +> The app says so on the payment screen and the rider deposits it at the hub — +> the ledger this call writes should reflect custody, not revenue. + +### 3.7 The pivot — `POST /miler/bookings/:bookingid/pickup-complete` + +```jsonc +{ "latitude": 11.0168, "longitude": 76.9558 } +``` + +Converts the booking into a consignment, recomputes chargeable weight from +§3.5, mints a tracking number, and decides routing: a shared 3-digit pincode +prefix between pickup and delivery means hyperlocal and the consignment stays in +this rider's hands; otherwise it routes via the hub. + +Response must carry the tracking number. The app reads either spelling and shows +it on the success screen: + +```jsonc +{ "tracking_no": "DM2608250042" } // "trackingno" also accepted +``` + +The app calls this through `PickupsController.updatePickedupStatus`, not +directly, so the **geofence** check, the rider-kilometre calculation and the +punctuality bonus all still run. A geofence refusal is not a failure: nothing +was sent, and the rider is told how far off he is. + +**Failure here is the one place the app is pessimistic.** Every other status +write in the app is optimistic — a rider who watches a completed stop bounce +back stops trusting the button. Not this one: playing "order created" over a +failed create would send a rider away believing a shipment exists with the +customer's money in his pocket and nothing on the hub's screen. + +### 3.8 The proof photo — `POST /miler/uploads/sign` → `PUT ` + +Two steps. Step one asks for somewhere to put the image: + +```jsonc +{ "purpose": "pickup_proof", "contentType": "image/jpeg", "consignmentid": 4211 } +``` + +`purpose` ∈ `pickup_proof · delivery_proof · receiver_signature · support`. + +```jsonc +{ + "uploadurl": "https://…?X-Amz-Signature=…", // expires in 10 minutes + "url": "https://cdn.doormile.com/proofs/…", + "headers": { "x-amz-acl": "private", "Content-Type": "image/jpeg" } +} +``` + +Step two `PUT`s the bytes to `uploadurl` with **exactly** the headers returned +and nothing else. `x-amz-acl` is part of what was signed, so adding a header of +our own invalidates the signature and the store answers 403. In particular the +bearer token must not be sent to the object store. + +A signature that has expired is **re-signed**, not retried against the old URL. + +--- + +## 4. Ordering invariants, stated once + +These are the four the app depends on. Three of them are enforced by the +handlers today; they are written down because a future refactor that reorders +them breaks silently rather than loudly. + +1. **`addresses` before `pickup-complete`** — routing and zone are built from + them and the handler refuses them afterwards. (§3.4) +2. **`parcel` before `pickup-complete`** — chargeable weight is recomputed from + it, and after conversion there is nothing to attach a measurement to. (§3.5) +3. **`payment` before `pickup-complete`** — a payment cannot attach to a booking + that has become a consignment. (§3.6) +4. **`pickup-complete` before anything consignment-keyed** — the consignment id + does not exist until it returns. + +--- + +## 5. Open, and specific to this line + +The general asks live in `MILER_API_REQUIREMENTS.md`. These three shape the +logistics flow in particular. + +### 5.1 P0 — pickup proof does not use the signed-upload route + +There are **two** upload paths in this app and only one of them is the contract. + +| Path | Used by | How | +|---|---|---| +| `MilerApi.uploadProof` → `/miler/uploads/sign` | delivery proof (`delivery_actions.dart`) | server-signed URL, §3.8 | +| `PickupsController.uploadProofImage` | pickup proof: Home's bulk pick, the crate photo | **client-side, straight into the `doormile` Spaces bucket** | + +The second one holds a DigitalOcean Spaces access/secret pair in the client. It +is passed by `--dart-define` today rather than being a source literal, but that +only stops the *next* build embedding it: the pair is in this repository's +history and in every APK shipped before the change, it is read-write on the +whole bucket, and it therefore reads and deletes every rider's proof photo for +every tenant. It has to be **rotated**, and the upload has to move behind +`/uploads/sign` like delivery proof already is. + +Separately, the single-stop logistics path does not upload at all: the +verification page takes a photo of the parcels and `updatePickedupStatus` sends +`proofImage: ''`. A disputed first-mile collection has the rider's word and a +count, and no picture. + +**Asks:** + +1. Rotate the Spaces credential pair. This is not conditional on anything below. +2. Confirm `/uploads/sign` accepts `purpose: "pickup_proof"` keyed on a + **`bookingid`** — the photo is taken before §3.7, so no consignment id + exists yet. `consignmentid` is the only resource key the sign call documents + today. +3. With (2) answered, the app moves both pickup paths onto §3.8 and + `uploadProofImage` is deleted. + +### 5.2 P0 — `step` is `0` on every booking row + +`GET /miler/bookings` returns `step`, `stoptype`, `etaminutes`, `cumulativekms` +and `cumulativeeta`, and the app consumes all five. `step` arrives as `0` on +every live row, so the admin's route order cannot be followed and the app falls +back to sorting nearest-first. This is request 13 in the main document and it is +the largest single gap on this line: a rider works a hub-planned route by +guessing at it. + +### 5.3 P2 — `vehicle-required` has no way in + +`POST /miler/bookings/:id/vehicle-required` exists and is wired +(`UpdatePickupProvider.requireVehicle`) but is unreachable — there is no control +anywhere in the app for "this doesn't fit on a bike". That is a design question +about where a rider says it, not a backend gap. Listed so the endpoint is not +assumed dead and removed. + +--- + +## 6. Field-name appendix + +The wire uses lowercase, unseparated names. The app's own maps use camelCase and +translate at the adapter boundary; these are the names on the wire. + +| Wire | Type | Where | +|---|---|---| +| `bookingid` | int | path param, §3.1 – §3.7 | +| `consignmentid` | int | after §3.7; `/uploads/sign`, `/consignments/*` | +| `pickupaddress` `pickuppincode` `pickuplatitude` `pickuplongitude` | string / string / num / num | §3.4 | +| `deliveryaddress` `deliverypincode` `deliverylatitude` `deliverylongitude` `deliverycity` | string / string / num / num / string | §3.4 | +| `parcels[].weight` `.length` `.width` `.height` | num | §3.5 | +| `amount` `paymentmode` `transactionref` | num / enum / string | §3.6 | +| `latitude` `longitude` | num | §3.1, §3.7 — **rider's position** | +| `tracking_no` \| `trackingno` | string | §3.7 response | +| `weight` `service_type` `pickup_pincode` `delivery_pincode` `category` | num / enum / string / string / enum | §3.3 request | +| `found` `zone` `currency` `results[].min_price` `.max_price` | bool / enum / string / num / num | §3.3 response | +| `purpose` `contentType` | enum / string | §3.8 request | +| `uploadurl` `url` `headers` | string / string / object | §3.8 response | +| `step` `stoptype` `etaminutes` `cumulativekms` `cumulativeeta` | int / enum / int / num / int | `GET /miler/bookings` row | + +--- + +## 7. What this line does *not* call + +Recorded so a handler is not written for a caller that does not exist: + +- `POST /miler/deliveries/start` — never existed, never call it. `pickup-complete` + decides routing itself. +- `POST /miler/consignments/:id/start-delivery` — the **release**, and only + reachable when a logistics consignment stays in the rider's hands + (hyperlocal). A shipment routed via the hub leaves his custody at §3.7 and he + never delivers it. +- Everything under the meal line's collect-a-crate path. A milk run reaches + §3.1 and then its own confirmation sheet; §3.2 – §3.8 do not run. diff --git a/MILER_ROUTE_ORDER_REQUEST.md b/MILER_ROUTE_ORDER_REQUEST.md new file mode 100644 index 0000000..d60eded --- /dev/null +++ b/MILER_ROUTE_ORDER_REQUEST.md @@ -0,0 +1,158 @@ +# P0 — The hub's route order never reaches the rider + +**From:** Miler rider-app engineering · 24 Aug 2026 +**To:** Doormile backend +**Endpoints:** `GET /miler/bookings`, `GET /miler/assignments` +**Related:** *API requirements* §11b (request 13), open questions 6 and 7 + +--- + +## The rule we are building to + +> **The console decides the order. The rider follows it.** + +If operations has solved a route, the app must work the stops in that exact +order — both legs, pickup and delivery — and must never re-sort by what is +closest to the rider. Re-optimising a planned route is not a small difference: +it changes the arrival windows customers were promised, and it makes the hub's +own screen a fiction. + +This is already how the app behaves. `lib/data/route_order.dart` is the single +place that answers it, and its rule is: + +| The payload says | The app does | The rider is told | +|---|---|---| +| any stop carries a sequence | works them in that order, exactly | **Hub route** | +| no sequence, but booked times | orders by booked time | *No route assigned — ordered by booked time* | +| no sequence, no times | keeps the backend's own array order | *No route assigned — shown in the order they came through* | +| nothing at all, rider has GPS | nearest-first | *No route assigned — ordered by what is closest to you* | + +Nearest-first is the **last** of four, it is labelled as a guess wherever it +appears, and one sequenced stop is enough to switch the whole set into hub +order. + +--- + +## What is happening instead + +**Every live row comes back `step: 0`.** Verified in production, rider Rajan A +(`userid 38`), 21–24 Aug: + +``` +GET /miler/bookings → 29 rows · step = 0 on all 29 +GET /miler/assignments → 12 rows · step = 0 on all 12, sequencedat = null +``` + +`step` is on the contract and is present in the payload. It is simply never +written. So the first branch of the table above has never once been taken on a +real device, and every rider in production is working a route the app ordered, +while the console believes he is working the route it planned. + +Neither side is currently able to notice. That is the part we would most like +to close. + +### Why it bites hardest on the delivery leg + +`GET /miler/assignments` is — correctly, and you have confirmed this — the +**active** queue: `Assigned` / `Accepted` only. A booking leaves that queue the +moment it is collected. + +The delivery leg begins the moment it is collected. + +So even if `step` were populated on assignments tomorrow, the sequence would +vanish at exactly the point the rider starts delivering, and the delivery +half of his day would still be ordered by the app. **The sequence has to be on +`GET /miler/bookings`**, which is the only list that covers all of a rider's +work. + +--- + +## What we need + +### 1. Populate `step` on every routed stop + +On **both** `GET /miler/bookings` and `GET /miler/assignments`: + +```jsonc +{ + "bookingid": 59, + "stoptype": "delivery", + "step": 4, // 1-based position in the solved route + "sequencedat": "2026-08-24 07:12:00", // when the route was solved, IST + "tripid": 2 // see §3 +} +``` + +- **`step` is 1-based.** `0` keeps its current meaning — *this stop is not in a + solved route* — and the app already sends those to the end of the list. +- **Stable for the day.** A stop's step must not change between two polls + unless the route was genuinely re-solved. The app re-sorts on every fetch; + a step that drifts makes the list reshuffle under the rider's thumb. +- **Unique within a rider's route**, so two stops cannot claim position 4. +- **Survives the leg change.** The step a booking had as a pickup, or the step + its delivery has, must still be on the row after `pickup-complete` — that is + the case that is broken today. + +### 2. `sequencedat`, preserved + +Null means *never solved*. A timestamp means *solved then*. The app uses it for +one thing only: telling a rider **"no route assigned"** honestly instead of +implying the hub planned an order it did not. It is currently null on every row +even where a step exists. + +### 3. Confirm who owns Trip 1 / Trip 2 / Trip 3 + +A rider's day is split into up to three runs. Today the app derives that split +**client-side, from the day part** — morning / afternoon / evening — because +nothing in the payload names a run. + +That is a guess, and it is the wrong kind: a rider's "Trip 2" and the hub's +"Trip 2" are not necessarily the same set of stops, and neither screen can tell. + +Two workable answers, and we need to know which: + +- **The console owns runs** → return `tripid` (and `slotid` if they differ) on + every row of `GET /miler/bookings`, stable for the day. We follow it and + delete the day-part split entirely. +- **Nobody owns runs** → say so, and the day-part split stays as documented + client behaviour rather than an unlabelled invention. + +--- + +## Two questions we cannot answer from this side + +1. **Is sequencing automatic or manual?** Is a route solved when assignments + are created, or does an operator have to trigger a route/optimizer action? + If it is manual, then `step: 0` is often the *correct* answer, and the app + should say **"no route assigned"** plainly rather than quietly ordering the + stops itself — which is a different piece of work from populating the field. + +2. **What happens on a mid-day re-solve?** If operations re-sequences a route + while a rider is part-way through it, do completed stops keep their old + steps? We will follow whatever you send; we need to know whether to expect + the numbers to move. + +--- + +## How to tell it is fixed + +No app release is needed for any of this — the app reads these fields today. + +1. `GET /miler/bookings` for a rider with a solved route returns `step ≥ 1` on + the routed stops and a non-null `sequencedat`. +2. The same booking still carries its `step` **after** `pickup-complete`. +3. Two consecutive polls return the same steps. +4. On the rider's phone, the queue heading reads **Hub route** rather than + *Nearest first* — that string is driven directly by which branch of the + table above was taken, so it is a one-glance check that the sequence + actually arrived. + +--- + +## What we are not asking for + +- Not a routing engine. If the console already stores an order, exposing it is + enough; we are not asking anyone to solve TSP. +- Not a new endpoint. Two fields on two existing list responses. +- Not a change to `/miler/assignments`' scoping. Active-queue-only is right — + it is precisely why `/miler/bookings` has to carry the sequence. diff --git a/assets/images/ChatGPT Image Aug 24, 2026, 11_00_26 AM.png b/assets/images/ChatGPT Image Aug 24, 2026, 11_00_26 AM.png new file mode 100644 index 0000000..926a3b4 Binary files /dev/null and b/assets/images/ChatGPT Image Aug 24, 2026, 11_00_26 AM.png differ diff --git a/assets/images/caught_up.png b/assets/images/caught_up.png index 583b60f..7c7604a 100644 Binary files a/assets/images/caught_up.png and b/assets/images/caught_up.png differ diff --git a/assets/images/no_bookings_available.png b/assets/images/no_bookings_available.png index d93b5bc..926a3b4 100644 Binary files a/assets/images/no_bookings_available.png and b/assets/images/no_bookings_available.png differ diff --git a/assets/images/no_bookings_available1.png b/assets/images/no_bookings_available1.png deleted file mode 100644 index c321a43..0000000 Binary files a/assets/images/no_bookings_available1.png and /dev/null differ diff --git a/assets/images/onboarding_img_1.png b/assets/images/onboard_1.png similarity index 100% rename from assets/images/onboarding_img_1.png rename to assets/images/onboard_1.png diff --git a/assets/images/onboarding_img_2.png b/assets/images/onboard_2.png similarity index 100% rename from assets/images/onboarding_img_2.png rename to assets/images/onboard_2.png diff --git a/assets/images/onboard_3.png b/assets/images/onboard_3.png new file mode 100644 index 0000000..aaff57a Binary files /dev/null and b/assets/images/onboard_3.png differ diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 71eae79..2b38540 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -4,15 +4,6 @@ PODS: - Flutter - flutter_tts (0.0.1): - Flutter - - Google-Maps-iOS-Utils (6.1.0): - - GoogleMaps (~> 9.0) - - google_maps_flutter_ios (0.0.1): - - Flutter - - Google-Maps-iOS-Utils (< 7.0, >= 5.0) - - GoogleMaps (< 10.0, >= 8.4) - - GoogleMaps (9.4.0): - - GoogleMaps/Maps (= 9.4.0) - - GoogleMaps/Maps (9.4.0) - permission_handler_apple (9.3.0): - Flutter - sms_autofill (0.0.1): @@ -24,16 +15,10 @@ DEPENDENCIES: - Flutter (from `Flutter`) - flutter_foreground_task (from `.symlinks/plugins/flutter_foreground_task/ios`) - flutter_tts (from `.symlinks/plugins/flutter_tts/ios`) - - google_maps_flutter_ios (from `.symlinks/plugins/google_maps_flutter_ios/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - sms_autofill (from `.symlinks/plugins/sms_autofill/ios`) - vibration (from `.symlinks/plugins/vibration/ios`) -SPEC REPOS: - trunk: - - Google-Maps-iOS-Utils - - GoogleMaps - EXTERNAL SOURCES: Flutter: :path: Flutter @@ -41,8 +26,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_foreground_task/ios" flutter_tts: :path: ".symlinks/plugins/flutter_tts/ios" - google_maps_flutter_ios: - :path: ".symlinks/plugins/google_maps_flutter_ios/ios" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" sms_autofill: @@ -54,9 +37,6 @@ SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89 flutter_tts: b88dbc8655d3dc961bc4a796e4e16a4cc1795833 - Google-Maps-iOS-Utils: 0a484b05ed21d88c9f9ebbacb007956edd508a96 - google_maps_flutter_ios: 0291eb2aa252298a769b04d075e4a9d747ff7264 - GoogleMaps: 0608099d4870cac8754bdba9b6953db543432438 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d sms_autofill: b36b2147535657fea83d7f3898d7831de70bd8e4 vibration: 69774ad57825b11c951ee4c46155f455d7a592ce diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 09c816f..fa05c1b 100644 --- a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -108,6 +108,15 @@ "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", "kind" : "remoteSourceControl", diff --git a/lib/Models/stop_status.dart b/lib/Models/stop_status.dart index 451afab..86ee77e 100644 --- a/lib/Models/stop_status.dart +++ b/lib/Models/stop_status.dart @@ -65,12 +65,57 @@ StopStatus stopStatusFromRaw(dynamic raw) { case 'miler_assigned': case 'pending': return StopStatus.assigned; + // ── The backend's own word for accepted ── + // + // Confirmed by the backend team: `pickup_scheduled` is what a booking sits + // on once the rider has accepted it. It parsed as [StopStatus.unknown] — + // so the rung the pickup UI reads had nothing in it, and any *other* field + // that did parse won by default. + case 'pickup_scheduled': + case 'pickupscheduled': case 'accepted': return StopStatus.accepted; case 'active': return StopStatus.active; case 'arrived': + // ── The server's own spellings for the same rung ── + // + // `Arrived_At_Pickup` is what `POST /miler/bookings/:id/reached` now + // persists, and `At_Customer` is the undocumented variant the backend was + // observed sending before it. Neither parsed: both fell to + // [StopStatus.unknown], so the rung the rider had just reported would have + // been dropped the moment the backend started returning it — the same + // symptom the local compatibility store exists to paper over, arriving by + // a different route. + // + // `ApiConfig.legacyStatusFromNew` already folds this into `arrived` for + // rows that come through the booking adapter. This is the other door: a raw + // status read straight off a row, which is what `_fetchQueues` does. + // + // **`At_Customer` is deliberately not here.** `ApiConfig` maps it to + // *arrived at pickup* while the `'at customer'` case further down this + // switch maps it to [StopStatus.deliveryArrived] — two readings of one word + // that cannot both be right. Folding it in on a guess would silently move + // stops between the pickup and delivery halves of the day, which is worse + // than leaving one undocumented spelling unparsed. Worth settling with the + // backend; not worth settling here. + case 'arrived_at_pickup': + case 'arrivedatpickup': return StopStatus.arrived; + // ── The two words that mean the pickup is done ── + // + // Also confirmed by the backend team, and also unparsed until now: + // `converted_to_consignment` is what the booking becomes when + // `pickup-complete` converts it, and `picked_up` is the same fact said + // plainly. Both are the **pickup milestone**, and both must outrank + // whatever the *delivery* lifecycle has moved on to — see + // [MilkRun.stageOf], which is where that precedence lives. + // + // This is the other half of "Picked showed as Active": with these + // unparsed, `active` was the only word on the row the app could read. + case 'converted_to_consignment': + case 'convertedtoconsignment': + case 'picked_up': case 'picked': case 'picked up': case 'pickuped': diff --git a/lib/background/foreground_service.dart b/lib/background/foreground_service.dart index 19501a5..116c5d6 100644 --- a/lib/background/foreground_service.dart +++ b/lib/background/foreground_service.dart @@ -1,345 +1,345 @@ -import 'dart:async'; -import 'dart:isolate'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_foreground_task/flutter_foreground_task.dart'; -import 'dart:math' as math; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:miler/views/helpers/constants/apiconstants.dart'; -import 'package:miler/providers/Riderlog/riderlog_provider.dart'; -import 'package:miler/background/backgroundservice.dart'; -import 'package:geolocator/geolocator.dart'; -import 'package:miler/utils/kalman_filter.dart'; -import 'package:miler/utils/mqtt_service.dart'; -import 'package:miler/views/helpers/constants/mqtt_constants.dart'; -import 'package:battery_plus/battery_plus.dart'; -import 'package:connectivity_plus/connectivity_plus.dart'; -import 'dart:io'; -import 'package:miler/helpers/http_overrides.dart'; - -class _BackgroundRiderLog { - static MilerKalmanFilter? _kf; - static DateTime? _lastUpdateTime; - - static Future> _ensureLatLng() async { - Map result = { - 'lat': '0', - 'lng': '0', - 'raw_lat': '0', - 'raw_lng': '0', - 'speed': '0', - 'heading': '0', - 'velocity_lat': '0', - 'velocity_lng': '0', - 'status': 'unknown', - 'accuracy': '0', - }; - try { - // 1. Check if location services are enabled - final serviceEnabled = await Geolocator.isLocationServiceEnabled(); - if (!serviceEnabled) { - debugPrint('[BG_RIDER_LOG] Location services are disabled.'); - result['status'] = 'disabled'; - return result; - } - - // 2. Check permissions - LocationPermission permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.denied) { - debugPrint('[BG_RIDER_LOG] Location permission denied.'); - result['status'] = 'denied'; - return result; - } - if (permission == LocationPermission.deniedForever) { - debugPrint('[BG_RIDER_LOG] Location permission denied forever.'); - result['status'] = 'denied_forever'; - return result; - } - - result['status'] = 'enabled'; - - // 3. Get position (using non-deprecated LocationSettings + explicit timeout) - final pos = await Geolocator.getCurrentPosition( - locationSettings: const LocationSettings( - accuracy: LocationAccuracy.high, - ), - ); - final now = DateTime.now(); - double outLat = pos.latitude; - double outLng = pos.longitude; - double speed = pos.speed; - double heading = pos.heading; - - // Decompose velocity for Kalman - final double headingRadians = heading * (math.pi / 180.0); - final double velocityLng = speed * math.sin(headingRadians); - final double velocityLat = speed * math.cos(headingRadians); - - if (_kf == null) { - _kf = MilerKalmanFilter(lat: outLat, lng: outLng); - } else { - final double dt = _lastUpdateTime != null - ? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0 - : 30.0; // Default background interval - _kf!.predict(dt); - _kf!.update(outLat, outLng); - outLat = _kf!.x[0]; - outLng = _kf!.x[1]; - } - _lastUpdateTime = now; - - return { - 'lat': outLat.toStringAsFixed(6), - 'lng': outLng.toStringAsFixed(6), - 'raw_lat': pos.latitude.toStringAsFixed(6), - 'raw_lng': pos.longitude.toStringAsFixed(6), - 'speed': speed.toStringAsFixed(2), - 'heading': heading.toStringAsFixed(2), - 'velocity_lat': velocityLat.toStringAsFixed(4), - 'velocity_lng': velocityLng.toStringAsFixed(4), - 'status': 'enabled', - 'accuracy': pos.accuracy.toStringAsFixed(1), - }; - } catch (e) { - debugPrint('[BG_RIDER_LOG] Error getting location: $e'); - return result; - } - } - - static String _two(int n) => n.toString().padLeft(2, '0'); - static String _formatDateTimeFull(DateTime dt) { - final y = dt.year.toString(); - final m = _two(dt.month); - final d = _two(dt.day); - final hh = _two(dt.hour); - final mm = _two(dt.minute); - final ss = _two(dt.second); - return "$y-$m-$d $hh:$mm:$ss"; - } - - static String _formatTime(DateTime dt) { - final hh = _two(dt.hour); - final mm = _two(dt.minute); - final ss = _two(dt.second); - return "$hh:$mm:$ss"; - } - - static Future createLoginNow() async { - try { - final prefs = await SharedPreferences.getInstance(); - final int onduty = prefs.getInt('onduty') ?? 0; - if (onduty != 1) { - return; - } - final int? userid = prefs.getInt('userId') ?? prefs.getInt('userid'); - final int? partnerid = - prefs.getInt('partnerId') ?? prefs.getInt('partnerid'); - final int? shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid'); - if ((userid ?? 0) == 0) return; - - // Prefer explicit username, then fallback to stored full name or first/last - String? username = prefs.getString('username'); - username ??= prefs.getString('user_name'); - if (username == null || username.trim().isEmpty) { - final first = prefs.getString('firstname') ?? ''; - final last = prefs.getString('lastname') ?? ''; - final combined = ('$first $last').trim(); - if (combined.isNotEmpty) { - username = combined; - } - } - - // ✅ Check if there are active pickup to set status - final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false; - final String riderStatus = hasActivePickups ? 'active' : 'idle'; - - final now = DateTime.now(); - final iso = _formatDateTimeFull(now); - final loginTime = _formatTime(now); - final loc = await _ensureLatLng(); - - final int? tenantid = prefs.getInt('tenantid'); - final int? locationid = prefs.getInt('locationid'); - final int? applocationid = prefs.getInt('applocationid'); - final String? userfcmtoken = prefs.getString('userfcmtoken'); - - final int? logid = prefs.getInt('logId') ?? prefs.getInt('logid'); - final String orderId = prefs.getString('current_riding_order_id') ?? ''; - - final payload = { - "logid": logid ?? 0, - "userid": userid, - "partnerid": partnerid, - "shiftid": shiftid, - "logdate": iso, - "login": loginTime, - "latitude": loc['lat'] ?? '0', - "longitude": loc['lng'] ?? '0', - "raw_latitude": loc['raw_lat'] ?? '0', - "raw_longitude": loc['raw_lng'] ?? '0', - "velocity_lat": loc['velocity_lat'] ?? '0', - "velocity_lng": loc['velocity_lng'] ?? '0', - "speed": loc['speed'] ?? '0', - "heading": loc['heading'] ?? '0', - "onduty": 1, - "status": riderStatus, - "contactno": prefs.getString('contactno') ?? '', - "tenantid": tenantid ?? 0, - "locationid": locationid ?? 0, - "applocationid": applocationid ?? 0, - "userfcmtoken": userfcmtoken ?? '', - "username": (username ?? '').trim(), - "orderid": orderId, - }; - - final firstName = prefs.getString('firstname') ?? ''; - final lastName = prefs.getString('lastname') ?? ''; - if (firstName.trim().isNotEmpty) { - payload['firstname'] = firstName.trim(); - } - if (lastName.trim().isNotEmpty) { - payload['lastname'] = lastName.trim(); - } - - final base = ApiConstants.mainRoute == 'live' - ? ApiConstants.createRiderLogLive - : ApiConstants.createRiderLogDev; - - final provider = CreateRiderLogProvider(); - final resp = await provider.createRiderLog(payload); - - if (resp == null || resp.isEmpty) return; - final det = (resp['details'] is Map) - ? (resp['details'] as Map) - : resp; - final newLogId = - int.tryParse('${det['logid'] ?? 0}') ?? (det['logid'] as int? ?? 0); - await prefs.setInt('logid', newLogId); - - // ✅ MQTT BACKGROUND PUBLISH ( Lane Split ) - final mqttService = MilerMqttService(); - if (!mqttService.isConnected) { - // Use a slightly different client ID for background to avoid kicking the main one off - await mqttService.connect(); - } - - if (mqttService.isConnected) { - // Gather Telemetry - final battery = Battery(); - final int batteryLevel = await battery.batteryLevel; - final BatteryState batteryState = await battery.batteryState; - final isCharging = - batteryState == BatteryState.charging || - batteryState == BatteryState.full; - - final connectivity = await Connectivity().checkConnectivity(); - final String connType = connectivity.isNotEmpty - ? connectivity.first.toString().split('.').last - : 'none'; - - // 1. Direct Telemetry (Feeding the /full API) - mqttService.publish('battery', '$batteryLevel%'); - mqttService.publish('charging', isCharging ? 'yes' : 'no'); - mqttService.publish('speed', loc['speed'] ?? '0'); - mqttService.publish('connection', connType); - mqttService.publish('accuracy', loc['accuracy'] ?? '0'); - - // 2. Alert if Location is Off - final String locStatus = loc['status'] ?? 'unknown'; - if (locStatus != 'enabled') { - mqttService.publish('alerts', { - 'userid': userid, - 'username': (username ?? '').trim(), - 'event': 'location_turned_off', - 'error_type': locStatus, - 'battery': '$batteryLevel%', - 'is_charging': isCharging, - 'connection': connType, - 'logdate': iso, - }); - } - - // 3. Low Battery Alert - if (batteryLevel < 15 && !isCharging) { - mqttService.publish('alerts', { - 'userid': userid, - 'username': (username ?? '').trim(), - 'event': 'low_battery_warning', - 'battery': '$batteryLevel%', - 'logdate': iso, - }); - } - - // 4. Poor GPS Accuracy Alert - final double accuracy = double.tryParse(loc['accuracy'] ?? '0') ?? 0; - if (accuracy > 30) { - mqttService.publish('alerts', { - 'userid': userid, - 'username': (username ?? '').trim(), - 'event': 'poor_gps_signal', - 'accuracy': '${accuracy.toStringAsFixed(1)}m', - 'logdate': iso, - }); - } - - // 5. Lane: Status - mqttService.updateStatus( - riderStatus == 'active' ? 'Active' : MqttConstants.statusOnline, - ); - - // 6. Lane: Periodic Log (Comprehensive Snapshot) - mqttService.publishLog('rider_periodic_log', { - 'userid': userid, - 'username': username, - 'logdate': iso, - 'latitude': loc['lat'] ?? '0', - 'longitude': loc['lng'] ?? '0', - 'speed': loc['speed'] ?? '0', - 'heading': loc['heading'] ?? '0', - 'accuracy': loc['accuracy'] ?? '0', - 'status': riderStatus, - 'orderid': orderId, - 'battery': '$batteryLevel%', - 'is_charging': isCharging, - 'connection': connType, - 'location_service': locStatus, - 'is_background': true, - }); - } - } catch (e) { - // ignore background errors - } - } -} - -class RiderLogTaskHandler extends TaskHandler { - Timer? _timer; // not used; plugin provides repeat callback, but keep safety - - @override - Future onStart(DateTime timestamp, SendPort? sendPort) async { - // No-op - } - - @override - Future onRepeatEvent(DateTime timestamp, SendPort? sendPort) async { - // 1. Rider Log (existing) - await _BackgroundRiderLog.createLoginNow(); - - // 2. Pickup Log (new) - await BackgroundCollectionLog.processActivePickups(); - - // 3. Auto Shift End (new) - await BackgroundCollectionLog.checkShiftEnd(); - } - - @override - Future onDestroy(DateTime timestamp, SendPort? sendPort) async { - _timer?.cancel(); - _timer = null; - } -} - -@pragma('vm:entry-point') -void riderLogCallback() { - HttpOverrides.global = MyHttpOverrides(); - FlutterForegroundTask.setTaskHandler(RiderLogTaskHandler()); -} +import 'dart:async'; +import 'dart:io'; +import 'package:miler/helpers/http_overrides.dart'; +import 'dart:isolate'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; +import 'dart:math' as math; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:miler/views/helpers/constants/apiconstants.dart'; +import 'package:miler/providers/Riderlog/riderlog_provider.dart'; +import 'package:miler/background/backgroundservice.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:miler/utils/kalman_filter.dart'; +import 'package:miler/utils/mqtt_service.dart'; +import 'package:miler/views/helpers/constants/mqtt_constants.dart'; +import 'package:battery_plus/battery_plus.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; + +class _BackgroundRiderLog { + static MilerKalmanFilter? _kf; + static DateTime? _lastUpdateTime; + + static Future> _ensureLatLng() async { + Map result = { + 'lat': '0', + 'lng': '0', + 'raw_lat': '0', + 'raw_lng': '0', + 'speed': '0', + 'heading': '0', + 'velocity_lat': '0', + 'velocity_lng': '0', + 'status': 'unknown', + 'accuracy': '0', + }; + try { + // 1. Check if location services are enabled + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + debugPrint('[BG_RIDER_LOG] Location services are disabled.'); + result['status'] = 'disabled'; + return result; + } + + // 2. Check permissions + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + debugPrint('[BG_RIDER_LOG] Location permission denied.'); + result['status'] = 'denied'; + return result; + } + if (permission == LocationPermission.deniedForever) { + debugPrint('[BG_RIDER_LOG] Location permission denied forever.'); + result['status'] = 'denied_forever'; + return result; + } + + result['status'] = 'enabled'; + + // 3. Get position (using non-deprecated LocationSettings + explicit timeout) + final pos = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ); + final now = DateTime.now(); + double outLat = pos.latitude; + double outLng = pos.longitude; + double speed = pos.speed; + double heading = pos.heading; + + // Decompose velocity for Kalman + final double headingRadians = heading * (math.pi / 180.0); + final double velocityLng = speed * math.sin(headingRadians); + final double velocityLat = speed * math.cos(headingRadians); + + if (_kf == null) { + _kf = MilerKalmanFilter(lat: outLat, lng: outLng); + } else { + final double dt = _lastUpdateTime != null + ? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0 + : 30.0; // Default background interval + _kf!.predict(dt); + _kf!.update(outLat, outLng); + outLat = _kf!.x[0]; + outLng = _kf!.x[1]; + } + _lastUpdateTime = now; + + return { + 'lat': outLat.toStringAsFixed(6), + 'lng': outLng.toStringAsFixed(6), + 'raw_lat': pos.latitude.toStringAsFixed(6), + 'raw_lng': pos.longitude.toStringAsFixed(6), + 'speed': speed.toStringAsFixed(2), + 'heading': heading.toStringAsFixed(2), + 'velocity_lat': velocityLat.toStringAsFixed(4), + 'velocity_lng': velocityLng.toStringAsFixed(4), + 'status': 'enabled', + 'accuracy': pos.accuracy.toStringAsFixed(1), + }; + } catch (e) { + debugPrint('[BG_RIDER_LOG] Error getting location: $e'); + return result; + } + } + + static String _two(int n) => n.toString().padLeft(2, '0'); + static String _formatDateTimeFull(DateTime dt) { + final y = dt.year.toString(); + final m = _two(dt.month); + final d = _two(dt.day); + final hh = _two(dt.hour); + final mm = _two(dt.minute); + final ss = _two(dt.second); + return "$y-$m-$d $hh:$mm:$ss"; + } + + static String _formatTime(DateTime dt) { + final hh = _two(dt.hour); + final mm = _two(dt.minute); + final ss = _two(dt.second); + return "$hh:$mm:$ss"; + } + + static Future createLoginNow() async { + try { + final prefs = await SharedPreferences.getInstance(); + final int onduty = prefs.getInt('onduty') ?? 0; + if (onduty != 1) { + return; + } + final int? userid = prefs.getInt('userId') ?? prefs.getInt('userid'); + final int? partnerid = + prefs.getInt('partnerId') ?? prefs.getInt('partnerid'); + final int? shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid'); + if ((userid ?? 0) == 0) return; + + // Prefer explicit username, then fallback to stored full name or first/last + String? username = prefs.getString('username'); + username ??= prefs.getString('user_name'); + if (username == null || username.trim().isEmpty) { + final first = prefs.getString('firstname') ?? ''; + final last = prefs.getString('lastname') ?? ''; + final combined = ('$first $last').trim(); + if (combined.isNotEmpty) { + username = combined; + } + } + + // ✅ Check if there are active pickup to set status + final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false; + final String riderStatus = hasActivePickups ? 'active' : 'idle'; + + final now = DateTime.now(); + final iso = _formatDateTimeFull(now); + final loginTime = _formatTime(now); + final loc = await _ensureLatLng(); + + final int? tenantid = prefs.getInt('tenantid'); + final int? locationid = prefs.getInt('locationid'); + final int? applocationid = prefs.getInt('applocationid'); + final String? userfcmtoken = prefs.getString('userfcmtoken'); + + final int? logid = prefs.getInt('logId') ?? prefs.getInt('logid'); + final String orderId = prefs.getString('current_riding_order_id') ?? ''; + + final payload = { + "logid": logid ?? 0, + "userid": userid, + "partnerid": partnerid, + "shiftid": shiftid, + "logdate": iso, + "login": loginTime, + "latitude": loc['lat'] ?? '0', + "longitude": loc['lng'] ?? '0', + "raw_latitude": loc['raw_lat'] ?? '0', + "raw_longitude": loc['raw_lng'] ?? '0', + "velocity_lat": loc['velocity_lat'] ?? '0', + "velocity_lng": loc['velocity_lng'] ?? '0', + "speed": loc['speed'] ?? '0', + "heading": loc['heading'] ?? '0', + "onduty": 1, + "status": riderStatus, + "contactno": prefs.getString('contactno') ?? '', + "tenantid": tenantid ?? 0, + "locationid": locationid ?? 0, + "applocationid": applocationid ?? 0, + "userfcmtoken": userfcmtoken ?? '', + "username": (username ?? '').trim(), + "orderid": orderId, + }; + + final firstName = prefs.getString('firstname') ?? ''; + final lastName = prefs.getString('lastname') ?? ''; + if (firstName.trim().isNotEmpty) { + payload['firstname'] = firstName.trim(); + } + if (lastName.trim().isNotEmpty) { + payload['lastname'] = lastName.trim(); + } + + final base = ApiConstants.mainRoute == 'live' + ? ApiConstants.createRiderLogLive + : ApiConstants.createRiderLogDev; + + final provider = CreateRiderLogProvider(); + final resp = await provider.createRiderLog(payload); + + if (resp == null || resp.isEmpty) return; + final det = (resp['details'] is Map) + ? (resp['details'] as Map) + : resp; + final newLogId = + int.tryParse('${det['logid'] ?? 0}') ?? (det['logid'] as int? ?? 0); + await prefs.setInt('logid', newLogId); + + // ✅ MQTT BACKGROUND PUBLISH ( Lane Split ) + final mqttService = MilerMqttService(); + if (!mqttService.isConnected) { + // Use a slightly different client ID for background to avoid kicking the main one off + await mqttService.connect(); + } + + if (mqttService.isConnected) { + // Gather Telemetry + final battery = Battery(); + final int batteryLevel = await battery.batteryLevel; + final BatteryState batteryState = await battery.batteryState; + final isCharging = + batteryState == BatteryState.charging || + batteryState == BatteryState.full; + + final connectivity = await Connectivity().checkConnectivity(); + final String connType = connectivity.isNotEmpty + ? connectivity.first.toString().split('.').last + : 'none'; + + // 1. Direct Telemetry (Feeding the /full API) + mqttService.publish('battery', '$batteryLevel%'); + mqttService.publish('charging', isCharging ? 'yes' : 'no'); + mqttService.publish('speed', loc['speed'] ?? '0'); + mqttService.publish('connection', connType); + mqttService.publish('accuracy', loc['accuracy'] ?? '0'); + + // 2. Alert if Location is Off + final String locStatus = loc['status'] ?? 'unknown'; + if (locStatus != 'enabled') { + mqttService.publish('alerts', { + 'userid': userid, + 'username': (username ?? '').trim(), + 'event': 'location_turned_off', + 'error_type': locStatus, + 'battery': '$batteryLevel%', + 'is_charging': isCharging, + 'connection': connType, + 'logdate': iso, + }); + } + + // 3. Low Battery Alert + if (batteryLevel < 15 && !isCharging) { + mqttService.publish('alerts', { + 'userid': userid, + 'username': (username ?? '').trim(), + 'event': 'low_battery_warning', + 'battery': '$batteryLevel%', + 'logdate': iso, + }); + } + + // 4. Poor GPS Accuracy Alert + final double accuracy = double.tryParse(loc['accuracy'] ?? '0') ?? 0; + if (accuracy > 30) { + mqttService.publish('alerts', { + 'userid': userid, + 'username': (username ?? '').trim(), + 'event': 'poor_gps_signal', + 'accuracy': '${accuracy.toStringAsFixed(1)}m', + 'logdate': iso, + }); + } + + // 5. Lane: Status + mqttService.updateStatus( + riderStatus == 'active' ? 'Active' : MqttConstants.statusOnline, + ); + + // 6. Lane: Periodic Log (Comprehensive Snapshot) + mqttService.publishLog('rider_periodic_log', { + 'userid': userid, + 'username': username, + 'logdate': iso, + 'latitude': loc['lat'] ?? '0', + 'longitude': loc['lng'] ?? '0', + 'speed': loc['speed'] ?? '0', + 'heading': loc['heading'] ?? '0', + 'accuracy': loc['accuracy'] ?? '0', + 'status': riderStatus, + 'orderid': orderId, + 'battery': '$batteryLevel%', + 'is_charging': isCharging, + 'connection': connType, + 'location_service': locStatus, + 'is_background': true, + }); + } + } catch (e) { + // ignore background errors + } + } +} + +class RiderLogTaskHandler extends TaskHandler { + Timer? _timer; // not used; plugin provides repeat callback, but keep safety + + @override + Future onStart(DateTime timestamp, SendPort? sendPort) async { + // No-op + } + + @override + Future onRepeatEvent(DateTime timestamp, SendPort? sendPort) async { + // 1. Rider Log (existing) + await _BackgroundRiderLog.createLoginNow(); + + // 2. Pickup Log (new) + await BackgroundCollectionLog.processActivePickups(); + + // 3. Auto Shift End (new) + await BackgroundCollectionLog.checkShiftEnd(); + } + + @override + Future onDestroy(DateTime timestamp, SendPort? sendPort) async { + _timer?.cancel(); + _timer = null; + } +} + +@pragma('vm:entry-point') +void riderLogCallback() { + HttpOverrides.global = MyHttpOverrides(); + FlutterForegroundTask.setTaskHandler(RiderLogTaskHandler()); +} diff --git a/lib/controllers/auth.dart b/lib/controllers/auth.dart index d2e645f..88f5f54 100644 --- a/lib/controllers/auth.dart +++ b/lib/controllers/auth.dart @@ -215,7 +215,7 @@ class AuthController extends GetxController { if (decoded is Map) reason = (decoded['message'] ?? '').toString(); } catch (_) {} if (reason.trim().isEmpty) { - reason = 'Could not set your MPIN. Please contact your hub manager.'; + reason = 'Could not set your MPIN. Please contact your manager.'; } _showBottomSheet(title: 'MPIN not changed', message: reason); return false; @@ -368,7 +368,7 @@ class AuthController extends GetxController { ); lastPinFailure = 'Your MPIN was accepted, but the server did not return a session. ' - 'Please report this to the hub — it is not your PIN.'; + 'Please report this to your office — it is not your PIN.'; _showBottomSheet( title: 'Could not start session', message: lastPinFailure!, diff --git a/lib/controllers/pickups_controller.dart b/lib/controllers/pickups_controller.dart index 40d96a6..0556a32 100644 --- a/lib/controllers/pickups_controller.dart +++ b/lib/controllers/pickups_controller.dart @@ -25,42 +25,62 @@ import 'package:miler/views/helpers/widgets/app_widgets.dart'; /// ── PROXIMITY ENFORCEMENT IS OFF ── /// -/// Turned off deliberately on 2026-08-19, at the founder's call, because it was -/// refusing real work: riders pressing **Picked up** at a counter were told -/// "You're 4.2 km from this stop", and a rider who cannot record what he has -/// physically done has no way round it. Correctness of the fence matters less -/// than a rider being able to work. +/// Off again on 2026-08-25, the same day it was turned on, at the founder's +/// call. Nothing was found wrong with it — this is a decision about when to +/// switch it on, not a retraction of the 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. +/// It is off for **every** status — arrived, picked, picked up, delivery +/// arrived, delivered, cancelled — 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. /// /// ── What it costs while it is off ── /// -/// This is the one control that decided whether the app was telling the truth +/// This is the one control that decides whether the app is 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. /// +/// ── What is ready for the day it goes back on ── +/// +/// Recorded here because the work is done and the flag is the only thing +/// holding it, so nobody has to rediscover any of it. The fence was off from +/// 2026-08-19 because it refused real work — riders pressing **Picked up** at a +/// counter were told "You're 4.2 km from this stop" — and that was a +/// measurement problem rather than a strictness one. Three causes, all fixed: +/// +/// • **The fix was not worth measuring with.** Home handed the fence a +/// `LocationAccuracy.low` position — a ~1 km hint on Android — or a cached +/// one of any age. See [_freshFix] and [kGeofenceFixMaxAge]. +/// • **The phone's own error was charged to the rider.** The comparison is +/// `distance - accuracy > radius`, so a rider 12 m out on a ±20 m fix is not +/// refused for a precision the hardware never provided. +/// • **There were three radii.** A configured `pickupradius` defaulting to +/// 100 here, a hardcoded 500 in Home's bulk gate, and no agreement between +/// them. There is one now: [kGeofenceRadiusMeters], set to 10. +/// +/// All three are live in the code below and simply do not run while this is +/// false. The fourth cause is real and none of this fixes it: **the booking's +/// own coordinates are often wrong**, because they come from wherever the +/// customer dropped a pin. A stop carrying *no* coordinates is allowed through +/// — that is the hub's data, not something a rider can resolve from a doorstep +/// — but a stop carrying wrong ones will still refuse him. +/// +/// ── Turning it on ── +/// +/// flutter run --dart-define=ENFORCE_GEOFENCE=true +/// flutter build apk --dart-define=ENFORCE_GEOFENCE=true +/// +/// Or flip the default. Ten metres is tight — at or inside consumer GPS +/// accuracy — so if the first reports are riders blocked at doors, raise +/// [kGeofenceRadiusMeters] before reaching for this switch again. +/// /// 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 ── +/// ── The history, so none of the old mistakes come 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 @@ -76,6 +96,56 @@ const bool kGeofenceEnforced = bool.fromEnvironment( /// The name every call site reads. Derived, so there is one switch and not two. const bool kBypassGeofenceForTesting = !kGeofenceEnforced; +/// ───────────────────────────────────────────────────────────────────────── +/// HOW CLOSE IS "AT THE STOP" — 10 metres +/// +/// A product decision, taken deliberately and tight: the rider must be *at the +/// door*, not on the street outside it, before he can mark a stop arrived, +/// picked or delivered. +/// +/// ── Why this is a constant and no longer the server's `pickupradius` ── +/// +/// The fence used to read `pickupradius` out of prefs, which login writes from +/// the profile and defaults to 100. That made the strictness of the app's one +/// honesty control a per-tenant configuration value nobody on this side could +/// see, and it silently disagreed with the second gate on Home, which was +/// hardcoded to 500. Three numbers for one rule. This is the rule. +/// +/// ── What 10 metres actually demands, stated plainly ── +/// +/// This is at or inside the accuracy of consumer GPS. A phone reports a fix +/// with an error radius, and 5–15 m in the open is normal while 30–50 m +/// between buildings is ordinary rather than exceptional. A fence smaller than +/// the error it is measured with will refuse a rider who is genuinely standing +/// at the door — which is the exact failure that got this whole control turned +/// off once before, and the reason two things below are not optional: +/// +/// • **The fix must be worth 10 m.** [LocationAccuracy.best], not the `low` +/// the callers were passing — `low` is a ~1 km hint on Android and against +/// a 10 m fence it is not a measurement, it is a coin toss. See +/// [_freshFix]. +/// +/// • **The phone's own error is credited to the rider.** The check is +/// `distance - accuracy > radius`, not `distance > radius`: a rider 12 m +/// away on a fix that says ±20 m has not been shown to be outside the +/// fence, and refusing him is asserting a precision the hardware did not +/// provide. He is refused when the *phone* says he is outside, not when the +/// arithmetic does. +/// +/// Together those keep 10 m meaning "at the door" without it meaning "when the +/// satellites are kind". If riders still report being blocked at a door, this +/// number is the knob — raise it here, in one place, rather than turning the +/// fence off again. +const double kGeofenceRadiusMeters = 10; + +/// How stale a cached fix may be before the fence refuses to measure with it. +/// +/// A last-known position is instant and free and can be an hour old. Against a +/// 100 m fence that was survivable; against 10 m it is how a rider marks a +/// delivery arrived from the previous street because that is where the phone +/// last looked. +const Duration kGeofenceFixMaxAge = Duration(seconds: 30); + class PickupsController extends GetxController with ConnectivityControllerMixin { MilerKalmanFilter? _kf; @@ -260,6 +330,66 @@ class PickupsController extends GetxController } // ---------------- Location helpers ---------------- + /// The error radius, in metres, of the fix [_ensureLatLng] last obtained. + /// + /// Read by [_checkGeofence], which credits it to the rider — see + /// [kGeofenceRadiusMeters]. Starts at zero so a fence measured before any fix + /// has been taken is strict rather than accidentally generous. + double _lastFixAccuracy = 0; + + /// A position good enough to measure a [kGeofenceRadiusMeters] fence with. + /// + /// ── What this replaced, and why it had to go ── + /// + /// The old ladder was: last-known first, then `high` for 4s, then `low` for + /// 2s. Every rung of it is wrong against a 10 m fence. + /// + /// • **Last-known first** returns instantly and can be an hour old. The + /// fence would then be measured from wherever the phone last happened to + /// look — reliably the previous stop, on a round. + /// • **`low` as a fallback** is a ~1 km hint on Android. Against 10 m that + /// is not a degraded measurement, it is noise being treated as evidence, + /// and it fails in both directions: it blocks a rider at the door and + /// waves through one two streets away. + /// + /// So: ask for the best fix the hardware will give, wait long enough for the + /// GPS to actually settle, and fall back to a cached one **only** if it is + /// fresher than [kGeofenceFixMaxAge]. Whatever comes back carries its own + /// accuracy into [_lastFixAccuracy], so the fence knows how much to trust it. + Future _freshFix() async { + Position? pos; + try { + pos = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.best, + // 8s, not 4. A cold GPS under a roofline needs the time, and the + // rider is standing still at a door — this is the one moment in his + // round where a few seconds buys something. + timeLimit: Duration(seconds: 8), + ), + ); + } catch (_) { + // No live fix. A recent cached one is a measurement; a stale one is not. + try { + final cached = await Geolocator.getLastKnownPosition(); + final age = cached == null + ? null + : DateTime.now().difference(cached.timestamp); + if (cached != null && age != null && age <= kGeofenceFixMaxAge) { + pos = cached; + } else if (cached != null) { + debugPrint( + '[GEOFENCE] cached fix is ${age?.inSeconds}s old — too stale to ' + 'measure a ${kGeofenceRadiusMeters.toStringAsFixed(0)}m fence', + ); + } + } catch (_) {} + } + + _lastFixAccuracy = pos?.accuracy ?? 0; + return pos; + } + Future> _ensureLatLng(String lat, String lng) async { String outLat = lat; String outLng = lng; @@ -268,16 +398,34 @@ class PickupsController extends GetxController final needsFetch = (lat == '0' || lat.isEmpty || lng == '0' || lng.isEmpty); - // Fast path: if we have valid coordinates, use them immediately - if (!needsFetch) return {'lat': outLat, 'lng': outLng}; + // ── The two shortcuts below are disabled while the fence is on ── + // + // Every caller of this method feeds its answer to [_checkGeofence] and + // then puts the same pair on the payload. Both shortcuts hand back a + // position of unknown provenance: the first trusts whatever the screen + // passed in — Home passes a `LocationAccuracy.low` fix, which is a ~1 km + // hint — and the second reuses a cached value with no age on it at all. + // Neither carries an accuracy, so [_lastFixAccuracy] would be stale too + // and the fence would measure a 10 m rule with a number it cannot + // characterise. + // + // With the fence enforced this takes one real fix per status write. That + // is a few seconds, once, at a door the rider is standing still at — and + // it is the whole basis on which the app is about to refuse or allow his + // press. With the fence off the shortcuts stand: nothing is being decided + // on the answer, it is telemetry. + if (kBypassGeofenceForTesting) { + // Fast path: if we have valid coordinates, use them immediately + if (!needsFetch) return {'lat': outLat, 'lng': outLng}; - // Reuse recently cached coordinates first if fresh (e.g. within 30s) - // For now just check if they exist to save time - if (currentLat.value.isNotEmpty && - currentLat.value != '0' && - currentLng.value.isNotEmpty && - currentLng.value != '0') { - return {'lat': currentLat.value, 'lng': currentLng.value}; + // Reuse recently cached coordinates first if fresh (e.g. within 30s) + // For now just check if they exist to save time + if (currentLat.value.isNotEmpty && + currentLat.value != '0' && + currentLng.value.isNotEmpty && + currentLng.value != '0') { + return {'lat': currentLat.value, 'lng': currentLng.value}; + } } final serviceEnabled = await Geolocator.isLocationServiceEnabled(); @@ -292,32 +440,7 @@ class PickupsController extends GetxController return {'lat': outLat, 'lng': outLng}; } - Position? pos; - - // 1. Try Last Known Position (Instant) - try { - pos = await Geolocator.getLastKnownPosition(); - } catch (_) {} - - // 2. If no last known, try current with a single balanced timeout - // Reduced complicated retry logic to one solid attempt - if (pos == null) { - try { - pos = await Geolocator.getCurrentPosition( - desiredAccuracy: - LocationAccuracy.high, // Better accuracy for pickup - timeLimit: const Duration(seconds: 4), - ); - } catch (_) { - // Fallback to low accuracy if high fails quickly - try { - pos = await Geolocator.getCurrentPosition( - desiredAccuracy: LocationAccuracy.low, - timeLimit: const Duration(seconds: 2), - ); - } catch (_) {} - } - } + final pos = await _freshFix(); if (pos != null) { final now = DateTime.now(); @@ -484,7 +607,27 @@ class PickupsController extends GetxController if (!ok) { debugPrint('[UPDATE][PICKED][FAILED] resp=${jsonEncode(resp)}'); + // ── The reason, kept, because the rider was told nothing ── + // + // Verified on production 27 Aug 2026, booking 207: + // `POST /miler/bookings/207/pickup-complete` → **500** + // `{"message":"failed to convert booking to consignment"}`. + // + // The app was right to refuse the move — `_advanceStop` leaves an + // unconfirmed pivot on Home rather than pushing it across the boundary + // — but it did that *silently*. The rider slid, the sheet closed, and + // the stop simply stayed where it was with nothing on screen. He slid + // again, and again, because nothing told him the hub had refused. + // + // The server's own sentence is the honest thing to show him, so it is + // kept here for the caller to read straight after its `await`, on the + // same terms as [lastBlockedReason]. + final why = (resp?['message'] ?? '').toString().trim(); + lastPickupRefusal = why.isEmpty + ? 'Your office could not record this pickup. Try again in a moment.' + : why; } else { + lastPickupRefusal = null; // ── What the stop actually became, in the server's words ── // // The pivot has two legitimate outcomes and the caller must stamp the @@ -1051,14 +1194,13 @@ class PickupsController extends GetxController } // ---------------- Geofencing helpers ---------------- - Future _getPickupRadius() async { - try { - final prefs = await SharedPreferences.getInstance(); - return prefs.getInt('pickupradius') ?? 100; - } catch (_) { - return 100; - } - } + // + // The radius is [kGeofenceRadiusMeters] and nothing else. It read the + // server's `pickupradius` out of prefs, which meant the app's one honesty + // control was a per-tenant number nobody here could see — and it disagreed + // with Home's own hardcoded 500. `pickupradius` is still stored at login; + // it simply no longer decides this. + double get _geofenceRadius => kGeofenceRadiusMeters; // Helper method to show snackbar reliably in both debug and release builds // @@ -1138,6 +1280,13 @@ class PickupsController extends GetxController /// a caller can read it straight after its `await`. String? lastBlockedReason; + /// Why the last `pickup-complete` was refused, in the server's own words. + /// + /// Null after a successful pivot. Set only when the call came back not-OK, + /// so a caller can say what happened instead of leaving the rider sliding at + /// a stop the hub keeps rejecting. See [updatePickedStatus]. + String? lastPickupRefusal; + Future _checkGeofence( double targetLat, double targetLng, @@ -1175,26 +1324,44 @@ class PickupsController extends GetxController currentLat.abs() <= 90 && currentLng.abs() <= 180; - if (!hasValidTarget || !hasValidCurrent) { - // If coordinates are missing or invalid, show error and block - if (kDebugMode) { - debugPrint( - '[GEOFENCE] Missing or invalid coordinates for $action. Target: ($targetLat, $targetLng), Current: ($currentLat, $currentLng)', - ); - } - - // Show warning snackbar using helper method - _showErrorSnackbar( - 'Location Warning', - 'Missing coordinates. Proceeding with update.', - bgColor: ColorConstants.warning, - seconds: 3, + // ── Two ways to have no coordinates, and only one of them is the rider's ── + // + // This used to treat both the same and wave both through: "Missing + // coordinates. Proceeding with update." That is the bypass that makes a + // fence decorative — turn location off and every rung opens — and it was + // survivable only because the fence itself was off. + // + // **No target.** The booking carries no pin. That is the hub's data, the + // rider cannot fix it from a doorstep, and blocking him leaves the stop + // unworkable by anyone. Allowed, and logged, exactly as before. + if (!hasValidTarget) { + debugPrint( + '[GEOFENCE] $action allowed: the stop carries no coordinates ' + '($targetLat, $targetLng), so proximity cannot be checked. This is a ' + 'data gap on the booking, not a rider who is somewhere else.', ); - return true; // Allow update to proceed despite missing coords + return true; + } + + // **No fix.** Location is off, permission is denied, or the GPS did not + // settle in time. This one the rider *can* fix, and it is the difference + // between a fence and a suggestion — so it is refused, and the message + // says which of the three to go and change. + if (!hasValidCurrent) { + debugPrint( + '[GEOFENCE] $action refused: no usable fix ' + '($currentLat, $currentLng)', + ); + lastBlockedReason = + 'Your phone could not find your location, so this stop cannot be ' + 'marked ${action.toLowerCase()}. Turn location on, allow it for ' + 'Miler, and step outside if you can.'; + _showErrorSnackbar('Location Error', lastBlockedReason!, seconds: 5); + return false; } try { - final radius = await _getPickupRadius(); + final radius = _geofenceRadius; final distance = Geolocator.distanceBetween( targetLat, targetLng, @@ -1202,16 +1369,30 @@ class PickupsController extends GetxController currentLng, ); final distanceKm = distance / 1000.0; - final radiusKm = radius / 1000.0; final distanceMeters = distance; - if (kDebugMode) { - debugPrint( - '[GEOFENCE] Action: $action | Target: ($targetLat, $targetLng) | Current: ($currentLat, $currentLng) | Distance: ${distanceMeters.toStringAsFixed(1)}m (${distanceKm.toStringAsFixed(3)}km) | Radius: ${radius}m (${radiusKm.toStringAsFixed(3)}km)', - ); - } + // ── The phone's own error is credited to the rider ── + // + // A fix carries an accuracy in metres, and at a 10 m fence that number + // is the same size as the thing being measured. Comparing a raw distance + // against 10 m asserts a precision the hardware did not provide, and the + // rider standing at the door on a ±25 m fix is the one it refuses. + // + // So the fence is measured against the *nearest point the phone allows*: + // 12 m away on a ±20 m fix has not been shown to be outside it. He is + // blocked when the phone says he is outside, not when the arithmetic + // does. See [kGeofenceRadiusMeters]. + final slack = _lastFixAccuracy; + final effective = (distance - slack).clamp(0.0, double.infinity); - if (distance > radius) { + debugPrint( + '[GEOFENCE] $action | target ($targetLat, $targetLng) ' + '| rider ($currentLat, $currentLng) ' + '| ${distanceMeters.toStringAsFixed(1)}m ±${slack.toStringAsFixed(0)}m ' + '→ ${effective.toStringAsFixed(1)}m vs ${radius.toStringAsFixed(0)}m', + ); + + if (effective > radius) { // ── One sentence, in metres he can act on ── // // Was three lines of "Distance: 4213 m (4.21 km) / Required: Within @@ -1221,7 +1402,8 @@ class PickupsController extends GetxController ? '${distanceKm.toStringAsFixed(1)} km' : '${distanceMeters.toStringAsFixed(0)} m'; lastBlockedReason = - "You're $away from this stop — get within $radius m to mark it " + "You're $away from this stop — get within " + '${radius.toStringAsFixed(0)} m to mark it ' '${action.toLowerCase()}'; _showErrorSnackbar('Location Error', lastBlockedReason!, seconds: 5); return false; @@ -1601,6 +1783,18 @@ class PickupsController extends GetxController /// `reached` that writes `Arrived_At_Pickup`. String? lastArrivalNotice; + /// Set when the server **refused** the arrival, as opposed to never hearing + /// it. Null on success and on a network failure. + /// + /// ── The distinction the caller needs ── + /// + /// `updateArrivedStatus` answers false for both a 4xx and a dead network, and + /// those want opposite handling: a rider with no signal at a kitchen door must + /// carry on, and a rider whose arrival the server rejected on a business rule + /// must not be shown as arrived. `ApiResult.status` is 0 when nothing came + /// back and the HTTP code when something did, which is the whole test. + String? lastArrivalRefusal; + Future updateArrivedStatus({ required int pickupId, required int orderHeaderId, @@ -1659,9 +1853,18 @@ class PickupsController extends GetxController arrivedShimmer.value = false; if (!ok) { + // A code at or above 400 is the server answering. Anything else — 0 + // most often — is the request never getting there. + final code = int.tryParse('${resp?['code'] ?? 0}') ?? 0; + lastArrivalRefusal = code >= 400 + ? (resp?['message']?.toString().trim().isNotEmpty == true + ? resp!['message'].toString() + : 'Your office would not accept this arrival.') + : null; debugPrint('[UPDATE][ARRIVED][FAILED] resp=${jsonEncode(resp)}'); return false; } + lastArrivalRefusal = null; // ── Succeeded, but did the hub record it? ── // @@ -1678,7 +1881,7 @@ class PickupsController extends GetxController arrivalConfirmedByServer.value = resp?['confirmed'] == true; if (!arrivalConfirmedByServer.value) { lastArrivalNotice = - 'Marked arrived on your phone. Your hub has not recorded it — ' + 'Marked arrived on your phone. Your office has not recorded it — ' 'their system is not accepting arrivals yet.'; debugPrint( '[UPDATE][ARRIVED][UNCONFIRMED] server said ' diff --git a/lib/controllers/riderkm.dart b/lib/controllers/riderkm.dart index a133214..278cab2 100644 --- a/lib/controllers/riderkm.dart +++ b/lib/controllers/riderkm.dart @@ -1,31 +1,159 @@ -import 'dart:convert'; -import 'package:http/http.dart' as http; -import 'package:miler/Models/summary/riderweeklykms.dart'; -import 'package:miler/data/miler_api.dart'; -import 'package:miler/views/helpers/constants/apiconstants.dart'; - -class RiderWeeklyKmController { - final String baseUrl = ApiConstants.summaryriderkmLive; - - /// `GET /miler/earnings?period=weekly` → `data:{ total_kms, breakdown }`. - /// - /// [userId] is unused: the token identifies the rider, and asking for someone - /// else's kilometres is not a thing the endpoint offers. Kept on the - /// signature because the provider above passes it and the call sites read - /// better for naming whose distance they mean. - Future> getRiderWeeklyKms(int userId) async { - final res = await MilerApi.earnings(period: 'weekly'); - if (!res.ok) { - throw Exception('Failed to fetch (code: ${res.status})'); - } - final breakdown = res.map['breakdown']; - final details = (breakdown is List ? breakdown : const []) - .whereType() - .map((e) => RiderWeeklyKms.fromJson(Map.from(e))) - .toList(); - return { - 'details': details, - 'total_kms': (res.map['total_kms'] ?? 0).toDouble(), - }; - } -} +import 'package:miler/Models/summary/riderweeklykms.dart'; +import 'package:miler/data/miler_api.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE WEEK'S KILOMETRES +/// +/// ── Why the chart was empty ── +/// +/// This read `data.breakdown` off `GET /miler/earnings?period=weekly` and +/// mapped it into the seven bars. That field is not on the contract: the +/// earnings response carries `completed_stops`, `cancelled_stops`, +/// `total_stops`, `total_kms`, `total_earnings` and `total_bonus` — six +/// **totals for the period asked for**, and no per-day series at all. +/// +/// So `breakdown` was always null, the list was always empty, and the chart +/// drew seven bars of nothing while the rider had ridden all week. It failed +/// silently because an absent key and an empty week look identical downstream. +/// +/// ── What it does instead ── +/// +/// The endpoint answers for *a* period, and it takes a `date`. A week is seven +/// days, so the week is seven daily calls — asked concurrently, so the page +/// waits for the slowest one rather than the sum of seven. +/// +/// That is more requests than one, and it is the honest cost of a contract that +/// has no series on it. It is bounded (seven, on a screen opened occasionally +/// rather than polled), and it is built from the same figure the totals row +/// prints, so the bars and the total cannot disagree. +/// +/// **The fast path stays.** If `breakdown` ever ships, it is used and the seven +/// calls are skipped — that check is two lines and it is what makes this +/// removable later without touching the page. +/// ───────────────────────────────────────────────────────────────────────── +class RiderWeeklyKmController { + /// `Mon` … `Sun`. Written out rather than taken from `intl` because these + /// strings are matched against the server's own day names elsewhere, and a + /// locale-aware formatter would introduce a mismatch that comparison cannot + /// survive. + static const List _days = [ + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat', + 'Sun', + ]; + + static String _iso(DateTime d) => + '${d.year}-${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; + + static double _num(dynamic v) { + if (v is num) return v.toDouble(); + return double.tryParse('${v ?? ''}') ?? 0; + } + + /// Reads a weekly `breakdown` into the chart's series, or returns null when + /// there is nothing usable in it. + /// + /// ── Null is the whole contract here ── + /// + /// Anything short of a series the chart can draw has to fall through to the + /// seven daily calls, because the alternative is what this page shipped for + /// months: an empty list drawn as seven empty bars, indistinguishable from a + /// week with no riding in it. Absent, null, not a list, empty, entries that + /// are not maps, entries with no day or no parseable distance — all of them + /// are "no series", and none of them is a chart. + /// + /// ── The day label is normalised ── + /// + /// The chart labels its bars with the first three characters of `day`, which + /// works for `Mon` and produces `202` for `2026-08-19`. The backend's example + /// uses the ISO form, so an ISO date is converted to the weekday it names and + /// anything else is passed through — a server that sends `Monday`, `Mon` or + /// `mon` already works, and one that sends something unrecognisable is + /// unusable rather than silently mislabelled. + static List? _readBreakdown(dynamic raw) { + if (raw is! List || raw.isEmpty) return null; + + final rows = []; + for (final entry in raw) { + if (entry is! Map) return null; + final day = _dayLabel(entry['day']); + if (day.isEmpty) return null; + final kms = entry['kms']; + if (kms != null && kms is! num && double.tryParse('$kms') == null) { + return null; + } + rows.add(RiderWeeklyKms(day: day, kms: _num(kms))); + } + return rows.isEmpty ? null : rows; + } + + /// `2026-08-19` → `Tue`. Any other non-empty string is returned as it came. + static String _dayLabel(dynamic raw) { + final s = raw?.toString().trim() ?? ''; + if (s.isEmpty) return ''; + final parsed = DateTime.tryParse(s); + if (parsed != null) return _days[parsed.weekday - 1]; + return s; + } + + /// The last seven days, oldest first, plus the week's total. + /// + /// [userId] is unused: the token identifies the rider, and asking for someone + /// else's kilometres is not a thing the endpoint offers. Kept on the + /// signature because the call sites read better for naming whose distance + /// they mean. + Future> getRiderWeeklyKms(int userId) async { + final weekly = await MilerApi.earnings(period: 'weekly'); + if (!weekly.ok) { + throw Exception('Failed to fetch (code: ${weekly.status})'); + } + + final weekTotal = _num(weekly.map['total_kms']); + + // ── The fast path ── + // + // If the server returns a per-day series, take it and spend no further + // requests. Nothing below runs. + final fast = _readBreakdown(weekly.map['breakdown']); + if (fast != null) { + return {'details': fast, 'total_kms': weekTotal}; + } + + // Seven days ending today, asked at once. + final today = DateTime.now(); + final dates = [ + for (var back = 6; back >= 0; back--) + DateTime(today.year, today.month, today.day - back), + ]; + + final results = await Future.wait( + dates.map((d) => MilerApi.earnings(period: 'daily', date: _iso(d))), + ); + + final details = []; + var summed = 0.0; + for (final (i, res) in results.indexed) { + // A day that failed is a day with no figure, not a zero worth charting + // against the others — but the bar still has to exist or the week is six + // days long and the labels slide. Zero, and the total below is what + // corrects for it. + final km = res.ok ? _num(res.map['total_kms']) : 0.0; + summed += km; + details.add(RiderWeeklyKms(day: _days[dates[i].weekday - 1], kms: km)); + } + + return { + 'details': details, + // The weekly total is the server's own where it has one — the seven daily + // figures are a reconstruction, and a reconstruction should not overrule + // the number the backend computed. It stands in only when the weekly call + // reported nothing. + 'total_kms': weekTotal > 0 ? weekTotal : summed, + }; + } +} diff --git a/lib/data/accepted_store.dart b/lib/data/accepted_store.dart index 5040e1c..e688bbb 100644 --- a/lib/data/accepted_store.dart +++ b/lib/data/accepted_store.dart @@ -66,6 +66,7 @@ Future migrateLegacyStores() async { _kRejectedOrderIdsKeyBase, _kCompletedBookingsKeyBase, _kSkippedBookingsKeyBase, + _kArrivedOrderIdsKeyBase, _kCollectedOrderIdsKeyBase, _kConsignmentIdsKeyBase, _kOutForDeliveryKeyBase, @@ -111,6 +112,7 @@ Future clearScopedStores() async { _kRejectedOrderIdsKeyBase, _kCompletedBookingsKeyBase, _kSkippedBookingsKeyBase, + _kArrivedOrderIdsKeyBase, _kCollectedOrderIdsKeyBase, _kConsignmentIdsKeyBase, _kOutForDeliveryKeyBase, @@ -529,6 +531,72 @@ Future addAcceptedBookings(List> bookings) async { // `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. +// ───────────────────────────────────────────────────────────────────────── +// ARRIVED — the rider is standing at the source +// +// ── Why this has to be stored at all ── +// +// Every other rung the rider walks is confirmed by the server and comes back +// on the next poll, so the app never had to remember it. Arrival does not: +// `POST /miler/bookings/:id/reached` answers 200 and leaves the booking on +// `Miler_Assigned` (verified in production, see MILER_API_REQUIREMENTS.md +// request 15). So the rung existed only as a field on an in-memory row, and +// the very next `_fetchQueues` — which the arrival sheet itself triggers — +// rebuilt that row from the server and put it back on ACCEPTED. +// +// The rider's report was being overwritten roughly one second after he made +// it. That is the whole of the "mark as arrived does nothing" bug: the write +// was fine, the rung was fine, and nothing kept it. +// +// So arrival is kept here, on the same footing as [_kCollectedOrderIdsKeyBase] +// and [_kOutForDeliveryKeyBase] — a local mirror of a rung the queue endpoints +// cannot yet carry. **Delete this store the day `reached` persists**, and not +// before: a local record that outranks the server is a liability the moment +// the server has the answer. +// +// It is dropped as soon as the stop moves on, so it can never outrank a rung +// the server *does* know about. +const String _kArrivedOrderIdsKeyBase = 'arrived_order_ids'; + +/// Order ids the rider has marked arrived and not yet collected. +Future> getArrivedOrderIds() async { + final prefs = await SharedPreferences.getInstance(); + return (prefs.getStringList(await _scopedKey(_kArrivedOrderIdsKeyBase)) ?? []) + .toSet(); +} + +/// Records an arrival. Called with every stop at the counter he walked up to. +Future addArrivedOrderIds(List 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(_kArrivedOrderIdsKeyBase)) ?? []) + .toSet(); + existing.addAll(clean); + await prefs.setStringList( + await _scopedKey(_kArrivedOrderIdsKeyBase), + existing.toList(), + ); +} + +/// Drops ids the moment they leave the arrived rung — collected, skipped, +/// cancelled or rejected. Without this the set outlives the stop and yesterday's +/// arrival pins today's row to ARRIVED. +Future removeArrivedOrderIds(List 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(_kArrivedOrderIdsKeyBase)) ?? []) + .where((id) => !drop.contains(id)) + .toList(); + await prefs.setStringList( + await _scopedKey(_kArrivedOrderIdsKeyBase), + remaining, + ); +} + const String _kCollectedOrderIdsKeyBase = 'collected_order_ids'; /// Order ids the rider has loaded and is carrying. diff --git a/lib/data/api_config.dart b/lib/data/api_config.dart index 52bd24d..dbafc39 100644 --- a/lib/data/api_config.dart +++ b/lib/data/api_config.dart @@ -458,6 +458,48 @@ class ApiConfig { // position zero. 'step': pick(sequenceFieldNames) ?? 0, + // ── The stamp that says whether `step` means anything ── + // + // Dropped here until now, which made the whole sequencing contract + // unreadable: [RouteOrder.isSequenced] looks for this key and the adapter + // never wrote it, so every adapted row looked unsequenced and the app + // fell back to nearest-first on routes the hub had actually solved. + // + // The backend's rule, confirmed 25 Aug: **`sequencedat` is the + // authority, not `step`.** Non-null → a route was assigned, follow `step` + // exactly. Null → no route, and the fallback is correct. `step: 0` with a + // null stamp is not a bug: it is a rider holding fewer than two active + // stops, or a stop without coordinates — neither of which is a route. + 'sequencedat': pick(RouteOrder.sequencedAtKeys), + + // ── The arrival, as the backend records it ── + // + // Confirmed by the backend team and shipped with their redeploy: + // `/reached` writes an arrival **event**, and `GET /miler/bookings` + // returns it on the row. There is no `Arrived_At_Pickup` booking status + // and there never was — the status stays `Pickup_Scheduled` and the + // stamp beside it is what says he is there. + // + // Carried through here so the rung can be rebuilt from server data alone + // after a refresh or a restart, which is the thing the local arrival + // record exists to stand in for. See [riderStageOf]. + 'reachedat': pick(const [ + 'reachedat', + 'reachedAt', + 'reached_at', + 'arrivedat', + ]), + 'arrivallatitude': pick(const [ + 'arrivallatitude', + 'arrivalLatitude', + 'arrival_latitude', + ]), + 'arrivallongitude': pick(const [ + 'arrivallongitude', + 'arrivalLongitude', + 'arrival_longitude', + ]), + // ── Which leg this stop is ── // // Also live, also previously hardcoded: every row came through as diff --git a/lib/data/consignment_state.dart b/lib/data/consignment_state.dart index ff89632..5027bdb 100644 --- a/lib/data/consignment_state.dart +++ b/lib/data/consignment_state.dart @@ -121,12 +121,40 @@ extension ConsignmentStateX on ConsignmentState { /// 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. + /// + /// ── `Created` is not one of these, and treating it as one stranded riders ── + /// + /// It was listed here, and it is the wrong half of the network. The backend's + /// own contract for the pivot is: + /// + /// ``` + /// hyperlocal pickup-complete → Out_for_Delivery (compatibility mode) + /// → Collected_By_Miler (flag on) + /// hub-routed pickup-complete → Created + next_action: inward_at_hub + /// ``` + /// + /// So `Created` means *the consignment exists and nothing has happened to it + /// yet* — the parcel is *in the rider's own hands*, waiting either on his + /// release or on him carrying it to the hub. The hub has never seen it. The + /// rider slid **Start ride**, and the app answered "This parcel is with the + /// hub — it will be delivered from there, not by you" about a bag on his own + /// back, with no way forward from that screen. + /// + /// Genuine hub custody starts at [ConsignmentState.inwardedAtHub] — the state + /// whose name says the hub took it in. See [awaitsHubInward] for `Created`. bool get awaitsHub => - this == ConsignmentState.created || this == ConsignmentState.inwardedAtHub || this == ConsignmentState.tripsheetLoaded || this == ConsignmentState.inTransit; + /// Converted, and not yet moved anywhere by anyone. + /// + /// The parcel is with **this rider**. On a hyperlocal run this is a state the + /// release moves out of; on a hub-routed one his next act is to inward it at + /// the hub, which is not something this app does yet. Either way it is not a + /// reason to tell him the parcel is somebody else's — see [awaitsHub]. + bool get awaitsHubInward => this == ConsignmentState.created; + /// After a successful `skip`, whether the stop is **still the rider's /// problem**. /// @@ -167,6 +195,13 @@ enum DeliverGate { /// A real hub-side hold. Block, and say so. awaitingHub, + /// `Created` — converted, never released, never inwarded. The parcel is in + /// this rider's hands, so the hub-hold wording is a lie; but it is not + /// `Out_for_Delivery` either, so `deliver` will refuse it. Blocked, with the + /// one sentence that is actually true about it. See + /// [ConsignmentStateX.awaitsHubInward]. + awaitingInward, + /// Closed some other way (cancelled, returned). Not deliverable, not an /// error the rider caused. closed, @@ -306,6 +341,7 @@ class ConsignmentGate { if (state.isDelivered) return DeliverGate.alreadyDelivered; if (state.needsRelease) return DeliverGate.needsRelease; if (state.awaitsHub) return DeliverGate.awaitingHub; + if (state.awaitsHubInward) return DeliverGate.awaitingInward; if (state.isClosed) return DeliverGate.closed; return DeliverGate.unknown; } diff --git a/lib/data/lifecycle.dart b/lib/data/lifecycle.dart index 471b7f1..30f6e2b 100644 --- a/lib/data/lifecycle.dart +++ b/lib/data/lifecycle.dart @@ -165,7 +165,26 @@ abstract final class MilerLifecycle { 'booking_status', ]); final parsed = BookingStatus.parse(raw); - final confirmed = parsed == BookingStatus.arrivedAtPickup; + + // ── Arrival is a timestamp, not a status ── + // + // This asked whether the booking's `status` had become + // `Arrived_At_Pickup`, and answered *unconfirmed* forever — because the + // backend team has since confirmed there is **no Arrived rung in the + // booking-status lifecycle at all**. It goes + // `pickup_scheduled → converted_to_consignment → …`, and arrival is + // recorded beside it as `reachedat`. + // + // So the app was demanding evidence of a transition the backend never + // claimed to make, and logging a gap every time it did not get it. The + // proof of arrival is the arrival stamp coming back; the status echoing + // `Miler_Assigned` or `pickup_scheduled` is correct and expected. + final stamp = _str(res.data, const [ + 'reachedat', + 'reached_at', + 'arrivedat', + ]); + final confirmed = stamp.isNotEmpty; return StateTransition( outcome: confirmed @@ -173,9 +192,11 @@ abstract final class MilerLifecycle { : TransitionOutcome.unconfirmed, bookingStatus: parsed, consignmentState: ConsignmentState.unknown, - evidence: raw.isEmpty - ? 'the response named no status at all' - : 'the response reported status="$raw"', + evidence: confirmed + ? 'the response stamped the arrival at "$stamp"' + : raw.isEmpty + ? 'the response carried no arrival stamp and named no status' + : 'the response carried no arrival stamp; status="$raw"', ); } diff --git a/lib/data/miler_api.dart b/lib/data/miler_api.dart index 6a35822..7df8ce1 100644 --- a/lib/data/miler_api.dart +++ b/lib/data/miler_api.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; @@ -363,11 +364,22 @@ class MilerApi { static Future getProfile() => _send('GET', '/miler/profile'); + /// ── Email and address are saveable now ── + /// + /// The edit screen has always collected both and only `displayname` could be + /// sent, so the other two lived on the device and were lost on a reinstall. + /// The handler takes them as of this contract. + /// + /// **Email is unique across users.** A collision comes back `409` with code + /// `EMAIL_IN_USE`; sending the address the rider already has is a no-op + /// rather than a conflict. Branch on [ApiResult.code], never the message. static Future updateProfile({ String? displayName, String? profilePhotoUrl, String? defaultVehicleType, String? phone, + String? email, + String? address, }) => _send( 'PUT', '/miler/profile', @@ -376,6 +388,8 @@ class MilerApi { if (profilePhotoUrl != null) 'profilephotourl': profilePhotoUrl, if (defaultVehicleType != null) 'defaultvehicletype': defaultVehicleType, if (phone != null) 'phone': phone, + if (email != null) 'email': email, + if (address != null) 'address': address, }, ); @@ -827,6 +841,157 @@ class MilerApi { ), ); + /// ── Skipping a stop the rider has **not** collected yet ── + /// + /// `POST /miler/consignments/:id/skip` keys on a consignment, and a booking + /// he has not picked up does not have one — so the pre-pickup case had no + /// route at all and the app recorded it locally, where the hub could not see + /// it. This is that route. + /// + /// It keeps the booking **assigned and resumable** (`resumable: true` comes + /// back on success), which is the whole difference from + /// [cancelBooking]: cancel gives the booking up and releases it for + /// reassignment; this says *not now*. + /// + /// Which of the three to call, in one line each: + /// + /// ``` + /// not collected yet → skipBooking (this) resumable + /// already collected → skipConsignment attemptcount++ + /// giving it up → cancelBooking released + /// ``` + static Future skipBooking( + Object bookingId, { + required String reason, + double? lat, + double? lon, + }) => _guarded( + 'skip-booking:$bookingId', + () => _send( + 'POST', + '/miler/bookings/$bookingId/skip', + idempotencyKey: _idempotencyKey('skip-booking', bookingId), + body: { + 'reason': reason, + if (lat != null) 'lat': lat, + if (lon != null) 'lon': lon, + }, + ), + ); + + // ═══════════════════════════════════════════════════════════════════════ + // PROOF UPLOAD — a signed URL, then a direct PUT + // + // The photograph a rider takes at a door had nowhere to go: `deliver` takes + // `photourl`, which wants a URL, and nothing on the contract accepted an + // upload — so proof lived on the phone and died with the next reinstall. + // + // Two steps, and the second does not touch this API at all: + // + // 1. POST /miler/uploads/sign → { uploadurl, url, method, headers } + // 2. PUT the bytes to `uploadurl` with **exactly** those headers + // 3. send `url` as `photourl` on deliver + // + // The object-store credentials stay server-side, which is the point of the + // signed-URL shape: the app never holds a key. + // ═══════════════════════════════════════════════════════════════════════ + + /// What a proof upload is for. The server picks the bucket path from this, + /// so it is not free text. + static const String proofDelivery = 'delivery_proof'; + static const String proofPickup = 'pickup_proof'; + static const String proofSignature = 'receiver_signature'; + static const String proofSupport = 'support'; + + /// Step 1 — asks for somewhere to put an image. + /// + /// The signature expires in ten minutes, so a failed upload is re-*signed* + /// rather than retried against the old URL. + static Future signUpload({ + required String purpose, + String contentType = 'image/jpeg', + Object? consignmentId, + }) => _send( + 'POST', + '/miler/uploads/sign', + body: { + 'purpose': purpose, + 'contentType': contentType, + if (consignmentId != null) 'consignmentid': consignmentId, + }, + ); + + /// Steps 1–3 in one call: sign, PUT the bytes, hand back the public URL. + /// + /// Returns `null` when the photograph could not be uploaded, which is a + /// **survivable** answer and never a reason to block a hand-over — the + /// delivery is the thing that matters and `deliver` accepts an empty + /// `photourl`. The caller records the delivery either way and keeps the + /// local copy, so nothing is lost that was not already only local. + /// + /// ── Why the headers are sent back verbatim ── + /// + /// `x-amz-acl` is part of what was signed. Dropping it, or adding a header + /// of our own, invalidates the signature and the store answers 403 — so the + /// map returned by the sign call is used as-is rather than merged with + /// anything this app thinks a request should carry. In particular the + /// bearer token must **not** go to the object store. + static Future uploadProof( + File file, { + required String purpose, + Object? consignmentId, + }) async { + if (!file.existsSync()) return null; + + final contentType = file.path.toLowerCase().endsWith('.png') + ? 'image/png' + : 'image/jpeg'; + + final signed = await signUpload( + purpose: purpose, + contentType: contentType, + consignmentId: consignmentId, + ); + if (!signed.ok) { + debugPrint('[UPLOAD] sign failed: ${signed.status} ${signed.message}'); + return null; + } + + final data = signed.data; + final map = data is Map ? Map.from(data) : null; + final uploadUrl = (map?['uploadurl'] ?? map?['uploadUrl'] ?? '') + .toString() + .trim(); + final publicUrl = (map?['url'] ?? '').toString().trim(); + if (uploadUrl.isEmpty || publicUrl.isEmpty) { + debugPrint('[UPLOAD] sign returned no url pair'); + return null; + } + + final headers = {}; + final raw = map?['headers']; + if (raw is Map) { + raw.forEach((k, v) => headers['$k'] = '$v'); + } + // The store needs a content type even if the signer did not name one. + headers.putIfAbsent('Content-Type', () => contentType); + + try { + final res = await http + .put( + Uri.parse(uploadUrl), + headers: headers, + body: await file.readAsBytes(), + ) + .timeout(const Duration(seconds: 30)); + if (res.statusCode >= 200 && res.statusCode < 300) return publicUrl; + debugPrint('[UPLOAD] PUT ${res.statusCode} — ${res.body}'); + } catch (e) { + debugPrint('[UPLOAD] PUT failed: $e'); + } + return null; + } + /// Bumps `attemptcount` rather than failing the consignment — a skip is a /// return visit, not an outcome. static Future skipConsignment( @@ -926,6 +1091,19 @@ class MilerApi { static Future getStatus() => _send('GET', '/miler/status'); + /// The pickup locations a tenant runs — the counters, branches or kitchens + /// the rider collects from. + /// + /// `GET /admin/tenants/:tenantid/locations`. **Note the path is under + /// `/admin`, not `/miler`**, which is the one thing worth knowing about it: + /// every other route this class calls is on the rider surface, and whether a + /// rider's bearer token is accepted here is the backend's decision, not + /// ours. A 401 or 403 is therefore an ordinary outcome and not a bug — + /// [PickupLocations] treats it as "no names available" and the app carries + /// on with whatever the booking row said. + static Future tenantLocations(Object tenantId) => + _send('GET', '/admin/tenants/$tenantId/locations'); + /// A JSON **array**, even for one entry — the handler decodes a list. static Future postConsignmentLogs( List entries, @@ -974,12 +1152,20 @@ class MilerApi { /// rather than scattered as `logGap` calls so there is one list to hand the /// backend team. See the write-up in ABOUT_MILER.md §8. static const List missingFromBackend = [ - 'per-stop type (pickup | delivery) and step ordering on GET /miler/bookings', - 'COD amount on the booking object', - 'a booking-level resume after skip (skip lives on the consignment only)', + // ── Delivered since this list was written, and removed from it ── + // + // Four of the seven entries here were shipped by the backend and the app + // now reads them, so they are gone rather than kept as history: + // + // per-stop `stoptype` + `step` on GET /miler/bookings → read by + // [ApiConfig.adaptBooking]; the mixed route is reachable + // `codamount` / `paymentmode` on the booking → read + // a booking-level skip → [skipBooking] + // `cancelled_stops` / `total_stops` on earnings → read + // + // What is left is what is still genuinely absent. 'a real notifications table with read state', 'anything that writes bonuspoints', - '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 @@ -989,6 +1175,27 @@ class MilerApi { // 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)', + // Proof of delivery is a local file path today: `photourl` wants a URL and + // nothing in the contract accepts an upload, so the photograph the rider + // takes never leaves his phone. See [ProofStore]. + 'an upload route for the proof-of-delivery photograph', + // ── Answered 24 Aug 2026, and off this list ── + // + // the route sequence → automatic on every assignment; `sequencedat` + // is the authority signal and `step` stays on the row through the + // pickup leg. `step: 0` with a null stamp now means one of three + // stated things, none of which is a route. See [RouteOrder]. + // a trip / slot id → there is none, and none is planned. The + // day-part split is client behaviour by agreement. See [TripSlots]. + // an upload route → shipped: `POST /miler/uploads/sign`, and the + // proof photo reaches the hub. See [uploadProof]. + // + // What is left is what is still genuinely absent. + // A rider payout rate. `ridercharges` holds the *client's* order price, + // not the rider's pay, and `bonuspoints` is unused — confirmed by the + // backend, with a real rate-card named as a separate build. Nothing in + // this app may present either figure as what a rider earned. + 'a rider payout rate — ridercharges is the client price, not rider pay', ]; } diff --git a/lib/data/milk_run.dart b/lib/data/milk_run.dart index 4292f9f..cf1e3a8 100644 --- a/lib/data/milk_run.dart +++ b/lib/data/milk_run.dart @@ -1,5 +1,6 @@ import 'package:miler/Models/stop_status.dart'; import 'package:miler/views/Dashboard/pickups/stop_type.dart'; +import 'package:miler/data/pickup_locations.dart'; import 'package:miler/data/service_profile.dart'; /// ───────────────────────────────────────────────────────────────────────── @@ -136,14 +137,33 @@ class MilkRun { /// prevent, produced by the two halves of the decision disagreeing. /// /// One key list, one answer. - static String sourceNameOf(Map stop) => - (stop['sourcename'] ?? - stop['SourceName'] ?? - stop['kitchenname'] ?? - stop['KitchenName'] ?? - '') - .toString() - .trim(); + /// + /// ── The tenant's own location list wins ── + /// + /// The booking row's `sourcename` is filled by the backend from + /// `providercompany` / `providerlocation`, and what lands there is often + /// whoever is on the account rather than the counter — the route card's + /// heading, the biggest type on Home, read `Sudharsan`. + /// + /// The booking does carry the location's **id**, and the tenant's locations + /// are a list with proper names on them, so the name is joined rather than + /// read off the row. See [PickupLocations]: when the table is empty — not + /// loaded yet, or the rider's token is not admitted to the admin route — + /// this falls through to exactly what it returned before. + static String sourceNameOf(Map stop) { + final resolved = PickupLocations.nameFor( + stop['sourceid'] ?? stop['kitchenid'] ?? stop['pickuplocationid'], + ); + if (resolved.isNotEmpty) return resolved; + + return (stop['sourcename'] ?? + stop['SourceName'] ?? + stop['kitchenname'] ?? + stop['KitchenName'] ?? + '') + .toString() + .trim(); + } /// True when two stops are collected from the same counter. static bool sameSource(Map a, Map b) => @@ -300,8 +320,8 @@ class MilkRun { .trim(); return name.isEmpty ? 'customer' : name; } - final kitchen = sourceNameOf(stop); - if (kitchen.isNotEmpty) return kitchen; + final place = pickupPlaceOf(stop); + if (place.isNotEmpty) return place; // ── The word "pickup" is never a place ── // @@ -313,11 +333,31 @@ class MilkRun { // 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. + // address. Only when it has no address either does the label fall back to + // a word, and then it is at least a capitalised noun. See [pickupPlaceOf]. + return 'Pickup'; + } + + /// **Where the rider is collecting from**, or `''` when the payload says. + /// + /// ── Why this is not the customer's name ── + /// + /// The route card's heading is the biggest type on Home, and on a group of + /// one order it was the *customer* — so a rider planning his next collection + /// read `Sudharsan`, which is a person, not somewhere he can ride to. The + /// place was on the card the whole time, one line down, in grey. + /// + /// The order of preference is the order of usefulness at a kerb: + /// + /// 1. the counter's own name — what is written on the sign + /// 2. the neighbourhood off the pickup address — what he steers by + /// + /// Split out of [navigationLabel] so a caller can tell "no place in this + /// payload" from the word *Pickup*, which reads as a name and is not one. + static String pickupPlaceOf(Map stop) { + final kitchen = sourceNameOf(stop); + if (kitchen.isNotEmpty) return kitchen; + for (final key in const ['pickupaddress', 'PickupAddress']) { final raw = (stop[key] ?? '').toString().trim(); if (raw.isEmpty) continue; @@ -333,7 +373,7 @@ class MilkRun { return p.replaceFirst(RegExp(r'^[0-9][0-9/\-]*\s+'), ''); } } - return 'Pickup'; + return ''; } /// Stops still owing the rider a collection at a given counter. @@ -387,6 +427,23 @@ class MilkRun { required Set collectedIds, Set outForDeliveryIds = const {}, Set deliveredIds = const {}, + + /// Stops the rider has reported arriving at. + /// + /// ── Why arrival is a set and not a status ── + /// + /// The backend team confirmed there is no Arrived rung in the booking + /// lifecycle: `/reached` records the arrival as a **timestamp** + /// (`reachedat`) beside a status that stays `pickup_scheduled`. And + /// `GET /miler/bookings` does not return that stamp — see + /// `getArrivedOrderIds` — so there is no server field to reconstruct + /// arrival from after a refresh. + /// + /// It therefore arrives the same way the other two rider-owned facts do, + /// as a set of ids, and it is subject to the same precedence: it speaks + /// only where nothing further along has happened. Delete this parameter, + /// not the rung, on the day the stamp appears on a booking row. + Set arrivedIds = const {}, }) { final id = idOf(stop); final reported = stopStatusOf(stop); @@ -422,7 +479,15 @@ class MilkRun { reported == StopStatus.outForDelivery) { return StopStatus.picked; } - if (reported == StopStatus.arrived) return StopStatus.arrived; + // ── Arrived, from either source ── + // + // Below everything above it, deliberately: the pickup milestone outranks + // the arrival, and the delivery rungs outrank both. A rider's own arrival + // record can never walk a stop the hub has moved on backwards — the same + // rule the local store obeys everywhere else in the app. + if (reported == StopStatus.arrived || arrivedIds.contains(id)) { + return StopStatus.arrived; + } if (acceptedIds.contains(id) || reported == StopStatus.accepted) { return StopStatus.accepted; } diff --git a/lib/data/pickup_locations.dart b/lib/data/pickup_locations.dart new file mode 100644 index 0000000..6db1c64 --- /dev/null +++ b/lib/data/pickup_locations.dart @@ -0,0 +1,185 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/api_config.dart'; +import 'package:miler/data/miler_api.dart'; +import 'package:miler/data/service_profile.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// WHAT THE PICKUP IS CALLED, FROM THE TENANT'S OWN LOCATION LIST +/// +/// ── The name on the card was a person ── +/// +/// The route card's heading is the biggest type on Home, and it read +/// `Sudharsan` — a contact person — above a distance and an ETA to a place +/// that name does not identify. It comes from `sourcename` on the booking row, +/// which the backend fills from `providercompany` / `providerlocation`, and +/// what lands there is whoever is on the account rather than the counter. +/// +/// The app cannot tell the two apart. `Sudharsan` and `Sri Balaji Stores` are +/// both just strings, so there is no rule that fixes the bad ones without +/// breaking every stop where the field is right. +/// +/// ── The join that does answer it ── +/// +/// The booking already carries the location's **id** (`pickuplocationid` / +/// `sourceid`), and the tenant's locations are a list with proper names on +/// them: +/// +/// ``` +/// GET /admin/tenants/:tenantid/locations +/// stop.pickuplocationid ──▶ location.name +/// ``` +/// +/// So the name is looked up rather than read off the booking. One request per +/// session for the whole tenant, held in memory, and every screen that names a +/// pickup reads it through [MilkRun.sourceNameOf]. +/// +/// ── Why every failure here is silent ── +/// +/// The route is under `/admin`, not `/miler`. Whether a rider's token is +/// accepted on it is the backend's decision and not something this app should +/// depend on. A 401, a 403, a shape this build cannot read, a dead network — +/// all resolve to an empty table, and an empty table means the booking's own +/// `sourcename` is used exactly as it is today. **Nothing on this path may +/// ever stop a rider working.** +/// ───────────────────────────────────────────────────────────────────────── +abstract final class PickupLocations { + /// `locationid` → the name a rider can read off a sign. Empty until + /// [ensureLoaded] has run and the backend has answered. + static Map _byId = const {}; + + /// In-flight load, so a screen rebuilding mid-fetch joins the request that + /// is already running instead of starting a second one. + static Future? _loading; + + /// True once a load has completed, however it went. A tenant with no + /// locations and a tenant whose locations we were refused look the same from + /// here, and both mean "stop asking". + static bool _settled = false; + + /// The name for a location id, or `''` when this build has none. + /// + /// Synchronous on purpose: it is read from `build`, and a name that arrives + /// one frame late is better than a widget tree that has to await. + static String nameFor(Object? locationId) { + final id = locationId?.toString().trim() ?? ''; + if (id.isEmpty || id == '0') return ''; + return _byId[id] ?? ''; + } + + /// True when the table holds anything at all. + static bool get isLoaded => _byId.isNotEmpty; + + /// Loads the rider's tenant's locations, once. + /// + /// Safe to call on every queue fetch — after the first completed attempt it + /// returns immediately. [force] re-asks, for a rider who has just changed + /// tenant. + static Future ensureLoaded({bool force = false}) { + if (force) { + _settled = false; + _loading = null; + } + if (_settled) return Future.value(); + return _loading ??= _load(); + } + + static Future _load() async { + try { + final prefs = await SharedPreferences.getInstance(); + final tenantId = prefs.getInt(TenantController.kTenantId) ?? 0; + if (tenantId <= 0) { + // No tenant on this device yet — not a failure, just too early. Left + // unsettled so the next fetch tries again once the login has landed. + _loading = null; + return; + } + + final res = await MilerApi.tenantLocations(tenantId); + if (!res.ok) { + // The expected outcome if riders are not admitted to `/admin`. Logged + // as a gap rather than an error: it is a question for the backend, and + // the app is already correct without it. + ApiConfig.logGap( + 'admin/tenants/:id/locations', + 'tenant $tenantId locations came back ${res.status} ${res.message}. ' + 'Pickup headings will use the booking row\'s own `sourcename`, ' + 'which is a contact person on some rows. If riders are meant to ' + 'read this route, it needs to accept a miler token.', + ); + _settled = true; + return; + } + + final table = {}; + for (final row in res.list) { + if (row is! Map) continue; + final m = row.map((k, v) => MapEntry(k.toString(), v)); + final id = _first(m, const [ + 'pickuplocationid', + 'pickupLocationId', + 'locationid', + 'locationId', + 'location_id', + 'id', + ]); + final name = _first(m, const [ + 'locationname', + 'locationName', + 'location_name', + 'name', + 'branchname', + 'branchName', + 'storename', + 'storeName', + 'kitchenname', + 'kitchenName', + 'title', + ]); + if (id.isEmpty || name.isEmpty) continue; + table[id] = name; + } + + _byId = table; + _settled = true; + debugPrint('[LOCATIONS] tenant $tenantId → ${table.length} named'); + if (table.isEmpty && kDebugMode) { + // The one thing that makes a shape mismatch diagnosable without + // another round trip. Debug-only: a response body is not something to + // write into a release log. + debugPrint('[LOCATIONS] no id/name pair recognised in: ${res.raw}'); + } + } catch (e) { + debugPrint('[LOCATIONS] could not load: $e'); + _settled = true; + } finally { + _loading = null; + } + } + + static String _first(Map m, List keys) { + for (final k in keys) { + final v = m[k]; + if (v == null || v is Map || v is List) continue; + final s = v.toString().trim(); + if (s.isNotEmpty && s.toLowerCase() != 'null' && s != '0') return s; + } + return ''; + } + + /// Drops the table. For sign-out and for tests. + @visibleForTesting + static void reset() { + _byId = const {}; + _settled = false; + _loading = null; + } + + /// Seeds the table directly, for tests that must not touch the network. + @visibleForTesting + static void seed(Map byId) { + _byId = Map.from(byId); + _settled = true; + } +} diff --git a/lib/data/rider_stage.dart b/lib/data/rider_stage.dart new file mode 100644 index 0000000..6a94c23 --- /dev/null +++ b/lib/data/rider_stage.dart @@ -0,0 +1,244 @@ +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/data/consignment_state.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE RIDER-FACING LIFECYCLE +/// +/// Six stages, in the order a rider walks them: +/// +/// ``` +/// Pending → Accepted → Arrived → Picked → Active → Delivered +/// ↘ Cancelled +/// ``` +/// +/// ── Why this is not the backend's status ── +/// +/// The backend's booking lifecycle has five rungs and no arrival in it: +/// +/// ``` +/// pending / miler_assigned the offer +/// pickup_scheduled the rider accepted +/// converted_to_consignment the pickup completed +/// active / out_for_delivery the delivery is under way +/// delivered done +/// ``` +/// +/// Arrival is not on that list because it is not a booking status — it is an +/// **event**, persisted by `POST /miler/bookings/:id/reached` as a timestamp +/// beside a status that does not move. Asking the backend to add an `Arrived` +/// rung would be asking it to model an event as a state; the app is the side +/// that owes the richer workflow, and this is where it is owed. +/// +/// So a stage is derived from *facts*, and which fact answers depends on which +/// half of the day the order is in: +/// +/// ``` +/// before the pickup the BOOKING's status, plus `reachedat` beside it +/// after the pickup the CONSIGNMENT's status +/// ``` +/// +/// The booking settles on `Converted_To_Consignment` and stops moving, so the +/// later half cannot be read off it — every stop would freeze at Picked. The +/// rider's own records remain as a fallback for a row the queue has not caught +/// up with, and for a deployment that does not return `reachedat` yet. +/// +/// ── One rule: progress never runs backwards ── +/// +/// The list is walked from the far end. The first evidence found wins, so a +/// stale local record can never pull a stop back down the ladder: an arrival +/// note cannot un-pick a collected parcel, a collection cannot un-deliver an +/// order. This is the same precedence the pickup-surface resolver +/// ([MilkRun.stageOf]) applies within its own half of the day, stated once for +/// the whole of it. +/// +/// ── And it does not replace the pickup rung ── +/// +/// [MilkRun.stageOf] answers *how far did the pickup get* and is what a pickup +/// card reads — which is why a stop whose delivery is under way still shows +/// **Picked** there. This answers *where is this order in the rider's day*, for +/// the surfaces that need the whole arc: a record, a timeline, a status line. +/// Both are derived, neither is stored, and the two are allowed to differ +/// because they are answering different questions about the same order. +/// ───────────────────────────────────────────────────────────────────────── +enum RiderStage { + /// Offered, or assigned and not yet taken. + pending, + + /// `pickup_scheduled` — the rider accepted it. + accepted, + + /// He is at the source. The booking's status is still `Pickup_Scheduled`; + /// what says he is there is [hasArrivalStamp] — `reachedat` on the row. + arrived, + + /// The consignment exists at `Collected_By_Miler`, or is somewhere in the + /// hub's half of the network. The pickup is complete and **stays** complete: + /// nothing after this un-picks it. + picked, + + /// `Out_for_Delivery` — the rider pressed **Start delivery** and the round is + /// his. Deliberately not the hub's rungs; see [riderStageOf]. + active, + + /// Terminal. + delivered, + + /// Terminal, and not a failure of the rider's: a cancelled or rejected stop. + cancelled; + + /// What the rider is shown. + String get label => switch (this) { + RiderStage.pending => 'Pending', + RiderStage.accepted => 'Accepted', + RiderStage.arrived => 'Arrived', + RiderStage.picked => 'Picked', + RiderStage.active => 'Out for delivery', + RiderStage.delivered => 'Delivered', + RiderStage.cancelled => 'Cancelled', + }; + + /// How far along the arc this is, for comparing two readings of one order. + /// [cancelled] sits outside the run and is deliberately last. + int get rank => switch (this) { + RiderStage.pending => 0, + RiderStage.accepted => 1, + RiderStage.arrived => 2, + RiderStage.picked => 3, + RiderStage.active => 4, + RiderStage.delivered => 5, + RiderStage.cancelled => 6, + }; + + /// True once the pickup half is behind him, whatever the delivery is doing. + /// This is the fact a pickup surface renders **Picked** from. + bool get pickupComplete => + rank >= RiderStage.picked.rank && this != RiderStage.cancelled; +} + +/// Reads one order's stage from the evidence available about it. +/// +/// [reported] is the booking's own status, already parsed. The three sets are +/// the rider's own records, passed in rather than read here so this stays pure +/// and so a caller can answer for a stop it holds no records for. +RiderStage riderStageOf( + Map stop, { + Set arrivedIds = const {}, + Set collectedIds = const {}, + Set outForDeliveryIds = const {}, + Set deliveredIds = const {}, + String? id, +}) { + final key = id ?? (stop['orderid'] ?? stop['pickupid'] ?? '').toString(); + final reported = stopStatusOf(stop); + + // ══════════════════════════════════════════════════════════════════════ + // POST-PICKUP · the consignment is the authority + // + // Once `pickup-complete` has run there is a consignment, and it — not the + // booking — is what the rest of the day happens to. The booking's own status + // settles on `Converted_To_Consignment` and stops moving, so reading the + // later half of the lifecycle off it would freeze every stop at Picked. + // + // Checked first, and from the far end, so newer evidence always wins over + // older: a stale arrival note cannot un-pick a collected parcel and a stale + // collection cannot un-deliver an order. + // ══════════════════════════════════════════════════════════════════════ + final consignment = consignmentStateFromRaw(stop['consignmentstatus']); + if (consignment != ConsignmentState.unknown) { + switch (consignment) { + case ConsignmentState.delivered: + return RiderStage.delivered; + case ConsignmentState.cancelled: + case ConsignmentState.returnedToSender: + case ConsignmentState.rtoInitiated: + case ConsignmentState.missing: + case ConsignmentState.damaged: + return RiderStage.cancelled; + case ConsignmentState.outForDelivery: + return RiderStage.active; + // ── The hub half of the network is NOT this rider being active ── + // + // A cross-city parcel goes `Collected_By_Miler → Inwarded_at_Hub → + // Tripsheet_Loaded → In_Transit` and is delivered by somebody else + // entirely. Reading any of those as *Active* would tell a rider he is out + // delivering a parcel he handed to a hub yesterday — and would put a + // delivery control on a stop he cannot act on at all. + // + // His own involvement ended at the collection, so that is the stage he + // sees: **Picked**, and nothing further. + case ConsignmentState.collectedByMiler: + case ConsignmentState.created: + case ConsignmentState.inwardedAtHub: + case ConsignmentState.tripsheetLoaded: + case ConsignmentState.inTransit: + return RiderStage.picked; + case ConsignmentState.unknown: + break; + } + } + + // The rider's own records, for a row whose consignment status has not caught + // up yet — a stop collected seconds ago, or a queue a poll behind him. + if (reported.isCancelled || reported.isRejected) return RiderStage.cancelled; + if (deliveredIds.contains(key) || reported == StopStatus.delivered) { + return RiderStage.delivered; + } + if (outForDeliveryIds.contains(key) || + reported == StopStatus.outForDelivery || + reported == StopStatus.deliveryArrived || + reported == StopStatus.active) { + return RiderStage.active; + } + if (collectedIds.contains(key) || reported.isPicked) return RiderStage.picked; + + // ══════════════════════════════════════════════════════════════════════ + // PRE-PICKUP · the booking status, plus the arrival event beside it + // + // `Pickup_Scheduled` with no stamp is Accepted; the same status with a stamp + // is Arrived. That is the whole of the backend's arrival contract, and it is + // why there is no `Arrived_At_Pickup` to look for. + // ══════════════════════════════════════════════════════════════════════ + if (hasArrivalStamp(stop) || + arrivedIds.contains(key) || + reported == StopStatus.arrived) { + return RiderStage.arrived; + } + if (reported == StopStatus.accepted) return RiderStage.accepted; + return RiderStage.pending; +} + +/// Whether the server says this stop has been arrived at. +/// +/// ── The one field, read defensively ── +/// +/// `reachedat` is a timestamp the backend writes and returns. It is read for +/// *presence*, not for its value: what the stage needs to know is whether an +/// arrival happened, and any non-empty stamp answers that. A malformed one is +/// still a stamp — the backend does not write the field for a booking nobody +/// arrived at — so it is accepted rather than parsed and discarded. +/// +/// The two obvious lies are refused: an empty string, and the literal `null` +/// that arrives when a JSON null has been stringified somewhere upstream. +/// +/// ── This is what retires the local record ── +/// +/// While a deployment does not return the field, this answers false and the +/// arrival set carries the rung — exactly as it does today. The moment a row +/// carries a stamp, the server's answer is used and the local set is not +/// consulted for it. Nothing has to be switched over: the rollout is per-row, +/// and the set can be deleted once no row is missing the stamp. +bool hasArrivalStamp(Map stop) { + for (final key in const [ + 'reachedat', + 'reachedAt', + 'reached_at', + 'arrivedat', + ]) { + final raw = stop[key]; + if (raw == null) continue; + final s = raw.toString().trim(); + if (s.isEmpty || s.toLowerCase() == 'null') continue; + return true; + } + return false; +} diff --git a/lib/data/route_order.dart b/lib/data/route_order.dart index 562006f..5a77ecf 100644 --- a/lib/data/route_order.dart +++ b/lib/data/route_order.dart @@ -67,7 +67,7 @@ extension RouteOrderSourceX on RouteOrderSource { /// 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.adminSequence => 'Assigned route', RouteOrderSource.bookedTime => 'By booked time', RouteOrderSource.backendOrder => 'As assigned', RouteOrderSource.proximity => 'Nearest first', @@ -76,7 +76,8 @@ extension RouteOrderSourceX on RouteOrderSource { /// 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.adminSequence => + 'Ordered by the route your office assigned.', RouteOrderSource.bookedTime => 'No route assigned — ordered by booked time.', RouteOrderSource.backendOrder => @@ -122,9 +123,43 @@ abstract final class RouteOrder { return 0; } + /// Field names that have carried the moment the route was solved. + static const List sequencedAtKeys = [ + 'sequencedat', + 'sequencedAt', + 'sequenced_at', + 'routesequencedat', + ]; + + /// True when this stop carries a solve timestamp. + /// + /// ── The authority signal, confirmed by the backend ── + /// + /// `sequencedat` is what says a route exists: **non-null → follow `step` + /// exactly; null → no route was assigned, use a fallback.** Sequencing is + /// automatic on every assignment — there is no operator action to wait for — + /// so `step: 0` with a null stamp means one of exactly three things: the + /// rider has fewer than two active stops, a stop is missing coordinates, or + /// the row predates the fix. None of those is a route to follow. + static bool isSequenced(Map stop) { + for (final key in sequencedAtKeys) { + final raw = stop[key]; + if (raw == null) continue; + if (raw.toString().trim().isEmpty) continue; + return true; + } + return false; + } + /// True when the hub has solved an order for at least one of these stops. + /// + /// The stamp is the authority and a positive `step` is accepted alongside + /// it: a deployment that populates one without the other is still telling + /// the app it has a route, and refusing to follow a numbered sequence + /// because its timestamp is missing would be reading the contract against + /// the rider. Either is enough; neither means no route. static bool hasAdminSequence(Iterable> stops) => - stops.any((s) => sequenceOf(s) > 0); + stops.any((s) => isSequenced(s) || sequenceOf(s) > 0); /// Puts [stops] in the order they are to be worked, and says which rule it /// used. diff --git a/lib/data/service_day.dart b/lib/data/service_day.dart index cd2f35f..f38ac3b 100644 --- a/lib/data/service_day.dart +++ b/lib/data/service_day.dart @@ -31,6 +31,13 @@ /// 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. /// ───────────────────────────────────────────────────────────────────────── +library; + +import 'package:flutter/foundation.dart' show debugPrint; + +import 'package:miler/views/Dashboard/activity/activity_format.dart' + show parseStamp; + 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. @@ -51,11 +58,37 @@ abstract final class 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. + /// + /// ── Why this list is the one [happenedAt] uses ── + /// + /// It used to read `completedat, skippedat, deliveredat, updatedat, + /// createdat`, and only the first two of those are fields the backend + /// actually sends. A booking finished yesterday comes back from + /// `GET /miler/bookings` carrying `pickedtime` and `updatedon` — neither of + /// which was looked at — so [of] returned `''`, [belongsToToday] took the + /// undated branch and kept it, and yesterday's work sat among this morning's + /// on a screen whose entire premise is that it shows today. + /// + /// The filter was never the problem: it was asking the row a question in + /// field names the row does not speak. So the key order is now the same one + /// [happenedAt] sorts by — the app's own stamps first, then what the API + /// really carries — because "when did this happen" and "which day does this + /// belong to" are one fact, and reading it out of two different lists is how + /// they came to disagree. static const List timeKeys = [ + // Written by the app at the moment the rider acted: the most accurate, and + // the only ones present on a stop the backend has not caught up with. 'completedat', 'skippedat', + // What the backend sends. + 'pickedtime', + 'picked_time', + 'deliverytime', 'deliveredat', + 'updatedon', + 'modifiedon', 'updatedat', + 'expected_pickup_time', 'createdat', ]; @@ -69,27 +102,134 @@ abstract final class ServiceDay { 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); + // ── Through [parseStamp], and *not* through `toLocal()` ── + // + // Doormile timestamps are IST wall-clock in naive Postgres columns, and + // some come back with a trailing `Z` they never had. A bare + // `DateTime.tryParse` believes the marker, `toLocal()` then shifts it by + // +5:30, and a stop finished at 23:50 lands on the *next* calendar day — + // so the last stop of an evening shift vanished from Activity the moment + // it was completed, and reappeared as tomorrow's. [parseStamp] strips + // the false marker and reads the digits the backend meant, which is the + // day the rider would name. + final t = parseStamp(row[k]); + if (t != null) return stamp(t); } return ''; } /// Whether [row] belongs to the service day in progress. /// - /// ── A row with no date is kept, and kept quietly ── + /// ── A row that cannot prove it is today's is not today's ── /// - /// 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 row, {String? now}) { + /// This used to return `true` for a row carrying no usable stamp, on the + /// reasoning that the API returned it for *this* session so the missing field + /// was a data-quality problem rather than evidence of age. That reasoning was + /// wrong in the one direction that matters: `GET /miler/bookings` returns the + /// rider's whole open set, so "the API returned it" says nothing about when + /// the work happened. The branch was not a safety net — it was the hole + /// yesterday's finished bookings came through, and Activity's entire premise + /// is that everything on it happened today. + /// + /// So the burden of proof sits with the row. A stamp that resolves to today + /// is admitted; anything else — yesterday's, tomorrow's, or nothing at all — + /// is not. This is a **view filter only**: nothing is deleted from either + /// local store or from the backend, and [of] still answers for any caller + /// that legitimately wants history. + /// + /// The undated case is genuinely rare now that [timeKeys] reads the fields + /// the API actually sends, and it is a defect worth seeing rather than + /// absorbing — so it is reported through [onUndated] rather than dropped in + /// silence. See [logUndated], which is what Activity passes. + static bool belongsToToday( + Map row, { + String? now, + void Function(Map row)? onUndated, + }) { final day = of(row); - if (day.isEmpty) return true; + if (day.isEmpty) { + onUndated?.call(row); + return false; + } return day == (now ?? today); } + + /// The standard [belongsToToday] undated reporter: names the row, says which + /// fields were looked for, and stays out of release logs. + /// + /// Deliberately not a `throw` and not a user-visible badge. A rider can do + /// nothing about a backend row with no timestamp on it, and the old `Undated` + /// chip put an internal defect on a screen he reads at a glance. This is for + /// whoever is holding the console. + static void logUndated(Map row) { + final id = (row['orderid'] ?? row['pickupid'] ?? '?').toString(); + debugPrint( + '[SERVICEDAY] undated row excluded from today — order $id ' + 'carries none of $dayKeys or $timeKeys', + ); + } +} + +/// ───────────────────────────────────────────────────────────────────────── +/// THE DAY TURNING OVER UNDER A SCREEN THAT IS ALREADY OPEN +/// +/// [ServiceDay] answers "which day is this record" at read time, which is the +/// right shape for the data and not sufficient for a *screen*. Activity filters +/// against `ServiceDay.today` as read at fetch time, so a rider who leaves the +/// tab open through 23:59 — or backgrounds the app on it and picks the phone up +/// at six — is looking at a list filtered against a day that has ended. Nothing +/// is wrong in the store; the pixels are stale. +/// +/// This is the small amount of state that notices. It is deliberately a plain +/// object with an injectable clock rather than logic inside a `State`: the +/// cases worth testing are all "the wall clock moved while nobody was looking", +/// and those are impossible to write against a real midnight. +/// ───────────────────────────────────────────────────────────────────────── +class ServiceDayRollover { + /// Reads the wall clock. Injectable so a test can move it. + final DateTime Function() clock; + + String _day; + + ServiceDayRollover({DateTime Function()? clock, String? day}) + : clock = clock ?? DateTime.now, + _day = day ?? ServiceDay.stamp((clock ?? DateTime.now)()); + + /// The service day the screen is currently showing. + String get day => _day; + + /// The service day the wall clock is actually in. + String get currentDay => ServiceDay.stamp(clock()); + + /// Whether the day has moved on since [day] was adopted. + bool get hasRolled => _day != currentDay; + + /// Adopts the current day, reporting whether that was a change. + /// + /// The caller does the clearing; this only answers the question. Idempotent, + /// so the timer and the resume listener can both call it and whichever + /// arrives second finds the day already current. + bool rollIfNeeded() { + final now = currentDay; + if (now == _day) return false; + _day = now; + return true; + } + + /// How long until the next local midnight, plus a second. + /// + /// The second is not cosmetic. A timer woken *on* the boundary can find + /// `DateTime.now()` still reading 23:59:59.999 after its own rounding, adopt + /// the day it already had, and leave the screen on yesterday until something + /// else happens to trigger it. + /// + /// `day + 1` is safe across month and year ends — `DateTime` normalises 32 + /// January into 1 February — and it is the same local-calendar arithmetic + /// [ServiceDay.stamp] does, so the wake lands on exactly the boundary the + /// filter tests. + Duration get untilNextDay { + final now = clock(); + final midnight = DateTime(now.year, now.month, now.day + 1); + return midnight.difference(now) + const Duration(seconds: 1); + } } diff --git a/lib/data/service_profile.dart b/lib/data/service_profile.dart index 82a9c5a..328f3b6 100644 --- a/lib/data/service_profile.dart +++ b/lib/data/service_profile.dart @@ -1,8 +1,11 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:get/get.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/data/api_config.dart'; +import 'package:miler/data/miler_api.dart'; /// ───────────────────────────────────────────────────────────────────────── /// WHICH LINE OF WORK THIS RIDER IS ON @@ -476,27 +479,108 @@ class ServiceProfile { /// /// A free function rather than a controller method so it can be tested without /// a GetX container — see the note on [ServiceProfile.active]. +/// Re-reads the rider's tenant from `GET /miler/profile` and persists it. +/// +/// ── Why a signed-in rider needed this ── +/// +/// `tenantname` is written at verify-pin, so it only ever reached a device by +/// way of a **login**. A rider already signed in when the backend began +/// returning the field would never see it: his prefs carry no name, resolution +/// falls back to the tenant id, and an id this app has not been told about +/// lands him on logistics — no **Start delivery** button, and a parcel that +/// strands at `Collected_By_Miler` the moment collected-state is enabled. +/// +/// The fix is not to make every rider sign out. `GET /miler/profile` returns +/// the same pair, so the tenant can be refreshed in place on launch. +/// +/// ── What it costs, and when ── +/// +/// Nothing, for a rider whose name is already known: he resolves from prefs on +/// this launch and picks up any change on the next one, with no first frame +/// spent waiting on a network call. A rider with **no** stored name is the case +/// this exists for, and that one is worth a bounded wait. +/// +/// Every failure is silent by design. A launch with no signal must land on the +/// same screen it always did rather than on an error, and the stored pair is +/// still there to resolve from. +Future refreshTenantFromProfile() async { + final prefs = await SharedPreferences.getInstance(); + if ((prefs.getString('authtoken') ?? '').trim().isEmpty) return; + + final known = (prefs.getString(TenantController.kTenantName) ?? '').trim(); + final fetch = _fetchTenantIntoPrefs(); + if (known.isNotEmpty) { + unawaited(fetch); + return; + } + await fetch.timeout(const Duration(seconds: 4), onTimeout: () {}); +} + +Future _fetchTenantIntoPrefs() async { + try { + final res = await MilerApi.getProfile(); + if (!res.ok) return; + + final map = res.map; + // The backend returns the pair top-level *and* inside `user`. Read both, so + // a handler that later moves them cannot silently stop resolving riders. + final user = map['user'] is Map ? map['user'] as Map : const {}; + Object? pick(String k) => map[k] ?? user[k]; + + final prefs = await SharedPreferences.getInstance(); + + final name = (pick('tenantname') ?? pick('tenantcode') ?? '') + .toString() + .trim(); + if (name.isNotEmpty) { + await prefs.setString(TenantController.kTenantName, name); + } + + final id = int.tryParse('${pick('tenantid') ?? ''}'.trim()); + if (id != null && id > 0) { + await prefs.setInt(TenantController.kTenantId, id); + } + } catch (_) { + // Offline, timed out, malformed — all the same answer: keep what we have. + } +} + Future 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. + // Both halves of the pair are read before either is acted on, because which + // one answers depends on what the other said. See the note below. final name = (prefs.getString(TenantController.kTenantName) ?? '') .trim() .toLowerCase(); - if (name.isNotEmpty) { - final byName = TenantController.profileForName(name); - if (byName != null) return byName; - } + final byName = name.isEmpty ? null : TenantController.profileForName(name); final id = prefs.getInt(TenantController.kTenantId) ?? 0; - if (id != 0) { - final byId = TenantController.profileForId(id); - if (byId != null) return byId; - } + final byId = id == 0 ? null : TenantController.profileForId(id); + + // ── When the two disagree, the id wins ── + // + // Name-first is right for the case it was written for: an unrecognised name + // falls through to the id, so a tenant this build has never heard of costs no + // release. That property is untouched below — a tenant absent from + // [TenantController.milkManTenantIds] is still decided by its name alone. + // + // What it did not survive is the two lists *contradicting each other*. The id + // list names one specific tenant on purpose; the name list is a pattern, and + // patterns collide. Tenant 13 carrying a display name that normalises to + // `doormile` — which is, after all, the company that owns the tenant record — + // resolved that rider to Logistics and never consulted the id at all. On the + // Logistics line `_unreleased` is empty by design, so the **Start round** bar + // does not render, and once `MILER_COLLECTED_STATE_ENABLED` is on his + // collected parcels strand at `Collected_By_Miler` with no control to release + // them. A global flag gets one safe shot, and this was the loose end in it. + // + // A deliberate statement about a known tenant outranks a match on a word + // nobody on either side of the API controls the spelling of. + if (byId != null) return byId; + if (byName != null) return byName; // ── The tenant the session's own token claims ── // @@ -618,11 +702,34 @@ class TenantController extends GetxController { /// 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; + final key = _nameKey(name); + if (key.isEmpty) return null; + if (milkManTenantNames.any((n) => _nameKey(n) == key)) { + return ServiceProfile.milkMan; + } + if (logisticsTenantNames.any((n) => _nameKey(n) == key)) { + return ServiceProfile.parcel; + } return null; } + /// A tenant name reduced to the letters and digits in it. + /// + /// ── Why separators cannot be allowed to decide a rider's day ── + /// + /// This was an exact-string lookup against a lower-cased name, which meant + /// `DailyGrubs` resolved and `Daily Grubs` did not — and the difference + /// between them is whether the rider gets a **Start delivery** button at all. + /// Nobody on either side of the API controls how a client's display name was + /// typed into the tenant record, so matching on it was a coin flip we had no + /// reason to take: `daily-grubs`, `DAILY_GRUBS` and `Daily Grubs` are the + /// same client by any reading, and all three missed. + /// + /// Applied to both sides of the comparison, so the literals above stay + /// readable as the words they are. + static String _nameKey(String raw) => + raw.toLowerCase().replaceAll(RegExp('[^a-z0-9]'), ''); + /// 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; diff --git a/lib/data/stop_compliance.dart b/lib/data/stop_compliance.dart index 3b304e0..befd627 100644 --- a/lib/data/stop_compliance.dart +++ b/lib/data/stop_compliance.dart @@ -108,9 +108,22 @@ class StopCompliance { return StopCompliance( onTime: onTime, lateBy: lateSeconds == null ? null : Duration(seconds: lateSeconds), - // `riderkms` is the backend's own name for the distance ridden, so a row - // that came straight from the API reads without a stamp. - actualKm: _double(m['actualkm']) ?? _double(stop['riderkms']), + // ── Three spellings, because the app writes two of them itself ── + // + // `compliance.actualkm` is the contract's. `riderkms` is the backend's + // own name on a booking row that came straight from the API. And + // `actualkms` is what **this app** posts on the arrival and pickup writes + // (`PickupsController`), so it is the key on any record that was stamped + // locally before the queue caught up. + // + // Reading only the first two meant a stop the rider had just finished + // reported no distance at all until a poll replaced the row — which is + // most of the rows on Activity for most of a shift, and it is why the + // day's total read zero. + actualKm: + _double(m['actualkm']) ?? + _double(stop['riderkms']) ?? + _double(stop['actualkms']), plannedKm: _double(m['plannedkm']) ?? _double(stop['kms']), ); } @@ -131,7 +144,14 @@ class StopCompliance { static double? _double(dynamic v) { if (v == null) return null; - if (v is num) return v.toDouble(); + // ── Zero is not a measurement, whichever type it arrives as ── + // + // A numeric `0` returned `0.0` here while the string `"0"` returned null, + // so the same absent distance resolved two different ways depending on + // whether the row came off JSON or off a locally stamped map. Callers all + // guard with `> 0`, so nothing was visibly wrong — which is precisely why + // it would have gone on being inconsistent. + if (v is num) return v == 0 ? null : v.toDouble(); final parsed = double.tryParse( v.toString().replaceAll(RegExp(r'[^0-9.\-]'), ''), ); diff --git a/lib/data/work_domain.dart b/lib/data/work_domain.dart index 48b1f78..6f9fdab 100644 --- a/lib/data/work_domain.dart +++ b/lib/data/work_domain.dart @@ -95,9 +95,49 @@ abstract final class WorkBoundary { /// 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 stop) { + /// + /// ── Skipped closes, and why it has to close *here* ── + /// + /// A skip used to leave the order open, and the Deliveries tab kept it under + /// its own heading on the argument that the rider looks for it where he left + /// it. The cost of that was **dual ownership**: the same stop sat in the + /// active queue *and* in Activity, so the two screens disagreed about whether + /// the rider still owed it a visit. One order, one owner — a stop he has + /// written off is a record, and records live on Activity. + /// + /// ── Why the status alone cannot answer it ── + /// + /// On the delivery leg the backend has no failed-attempt outcome. A + /// successful skip leaves the consignment `Out_for_Delivery`, so + /// `/miler/bookings` keeps returning the row as live delivery work for the + /// rest of the day, and [stopStatusOf] reads exactly what the server sent. + /// Asking only the payload therefore re-admits the stop on every poll and on + /// every cold start. + /// + /// [closedIds] is the answer to that, and it is **not** a display flag: it is + /// the persisted record of a mutation that already succeeded — day-stamped, + /// scoped to this rider, tenant and line (see [WorkScope]) — written by the + /// completed and skipped stores at the moment the rider's action came back + /// OK. It is the same shape of evidence [pickupComplete] already takes from + /// `collectedIds`, and it survives the process that wrote it, which is the + /// whole point. + /// + /// Clearing it is what a resume does: [removeSkippedBookings] drops the id + /// and the stop is live work again on the very next read. There is no second + /// place holding the same opinion. + static bool isClosed( + Map stop, { + Set closedIds = const {}, + }) { + if (closedIds.isNotEmpty && closedIds.contains(MilkRun.idOf(stop))) { + return true; + } final status = stopStatusOf(stop); if (status == StopStatus.delivered || status.isCancelled) return true; + // Filed under its own word, never flattened into cancelled: a stop the + // rider walked away from and one the office called off are different + // records, and Activity slices them apart. + if (status.isSkipped) return true; return status.isPicked && ServiceProfile.active.endsAtHub; } @@ -110,8 +150,12 @@ abstract final class WorkBoundary { Map stop, { Set collectedIds = const {}, Set acceptedIds = const {}, + + /// Orders written off today — delivered, cancelled or skipped — read back + /// from the completed and skipped stores. See [isClosed]. + Set closedIds = const {}, }) { - if (isClosed(stop)) return WorkDomain.closed; + if (isClosed(stop, closedIds: closedIds)) return WorkDomain.closed; final handedOver = switch (ServiceProfile.active.handoffAt) { HandoffPoint.collected => pickupComplete( @@ -139,12 +183,14 @@ abstract final class WorkBoundary { Iterable> day, { Set collectedIds = const {}, Set acceptedIds = const {}, + Set closedIds = const {}, }) => [ for (final stop in day) if (domainOf( stop, collectedIds: collectedIds, acceptedIds: acceptedIds, + closedIds: closedIds, ) == WorkDomain.delivery) stop, @@ -155,12 +201,14 @@ abstract final class WorkBoundary { Iterable> day, { Set collectedIds = const {}, Set acceptedIds = const {}, + Set closedIds = const {}, }) => [ for (final stop in day) if (domainOf( stop, collectedIds: collectedIds, acceptedIds: acceptedIds, + closedIds: closedIds, ) == WorkDomain.pickup) stop, diff --git a/lib/data/work_repository.dart b/lib/data/work_repository.dart index 945a482..b28eba6 100644 --- a/lib/data/work_repository.dart +++ b/lib/data/work_repository.dart @@ -201,7 +201,7 @@ class WorkRepository { return _publish( const LoadFailure>>( LoadFailureKind.server, - message: 'The hub sent work this app could not read.', + message: 'Your office sent work this app could not read.', ), seq, ); diff --git a/lib/main.dart b/lib/main.dart index 224ae37..2b595a7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -97,6 +97,12 @@ Future main() async { // Xpress-rider delivery one under `lib/xpress`. // // Moved above the controller registrations below, which it now selects. + // The tenant can change under a rider who never signs out — and until the + // backend began returning `tenantname`, one who was already signed in had no + // way to receive it at all. Refreshed from `GET /miler/profile` first so the + // resolution below reads the current answer. Cheap and non-blocking for a + // rider we already have a name for; see [refreshTenantFromProfile]. + await refreshTenantFromProfile(); final profile = await TenantController.to.load(); // ── One line's controllers, not both ── diff --git a/lib/providers/auth/auth_provider.dart b/lib/providers/auth/auth_provider.dart index dc3579a..f9db37e 100644 --- a/lib/providers/auth/auth_provider.dart +++ b/lib/providers/auth/auth_provider.dart @@ -649,7 +649,7 @@ class AuthProvider { 'status': false, 'code': 403, 'message': - 'Your MPIN is issued by your hub and cannot be changed from the ' + 'Your MPIN is issued by your office and cannot be changed from the ' 'app. Ask your supervisor to reset it, then sign in with the MPIN ' 'they give you.', }), diff --git a/lib/providers/pickuplog/pickuplog_provider.dart b/lib/providers/pickuplog/pickuplog_provider.dart index 56d3c1c..b7ddff0 100644 --- a/lib/providers/pickuplog/pickuplog_provider.dart +++ b/lib/providers/pickuplog/pickuplog_provider.dart @@ -96,11 +96,62 @@ class UpdatePickupProvider { /// a file this one has no business touching. What matters is that every /// branch now ends in a real request or an honest failure — there are no /// invented successes left. + /// The booking this payload is about, or null when it carries none. + /// + /// Treats `0`, `''` and an unparseable value as absent — the three shapes a + /// missing id actually arrives in, none of which is the `null` a `??` chain + /// is looking for. + static Object? _bookingId(Map data) { + for (final key in const ['pickupid', 'orderheaderid']) { + final raw = data[key]; + if (raw == null) continue; + final s = raw.toString().trim(); + if (s.isEmpty) continue; + final n = int.tryParse(s); + if (n != null) { + if (n > 0) return n; + continue; + } + // A non-numeric reference — a tracking number — is a real id. + return s; + } + return null; + } + Future?> updatePickup(Map data) async { final String status = (data['orderstatus'] ?? '').toString(); - final id = data['pickupid'] ?? data['orderheaderid']; final String notes = (data['notes'] ?? '').toString(); + // ── `??` was catching null, and a missing id is never null here ── + // + // This read `data['pickupid'] ?? data['orderheaderid']`. Every caller + // builds its payload with `int.tryParse('${'$'}{stop['pickupid'] ?? 0}') ?? 0`, + // so a row without a booking id arrives as **`0`** — which is not null, so + // the fallback never fired and the app posted to + // `/miler/bookings/0/reached`. + // + // Nothing complained. The rider's rung advanced anyway (arrival is his own + // report and is deliberately not gated on the write landing), so from the + // phone it looked identical to a healthy arrival — while the hub was told + // about booking zero, and the console showed no arrival at all. + // + // Zero and empty are *absent* now, and an absent id refuses the call + // outright rather than aiming it at a booking that does not exist. See + // [_bookingId]. + final id = _bookingId(data); + if (id == null) { + ApiConfig.logGap( + 'update:$status', + 'no usable booking id on the payload — pickupid=' + '${data['pickupid']}, orderheaderid=${data['orderheaderid']}. ' + 'Refusing to post; the hub would have been told about booking 0.', + ); + return { + 'status': false, + 'message': 'This stop is missing its booking reference.', + }; + } + final double? lat = double.tryParse( '${data['riderslat'] ?? data['pickuplat'] ?? ''}', ); @@ -199,6 +250,26 @@ class UpdatePickupProvider { // mirror that flag — it reads which one happened. final pivot = MilerLifecycle.pickupComplete(picked); MilerLifecycle.report('pickup-complete', pivot); + // ── The Picked half of the lifecycle trace ── + // + // Unconditional, and it prints the response body. `_send` logs only + // failures, so a pivot that succeeded and wrote the *wrong* state — + // which is the entire production question — left no record at all, and + // every investigation of it started by adding this line by hand. + // + // Read with the `[TRACE][START-RIDE]` line in `releaseForDelivery`: + // together they answer "did the app release at Picked?" from one log, + // with no console access and no packet capture. See MilerLifecycle. + debugPrint( + '[TRACE][PICKED] booking=$id ' + 'POST /miler/bookings/$id/pickup-complete ' + '-> ${picked.status} booking="${pivot.bookingStatus.name}" ' + 'consignment="${pivot.consignmentState.name}" ' + 'id="${pivot.consignmentId}" next="${pivot.nextAction}" ' + 'compatibilityMode=${pivot.isCompatibilityMode} ' + 'startDeliveryCalledHere=false ' + 'raw=${picked.raw}', + ); if (pivot.isCompatibilityMode) { ApiConfig.logGap( 'pickup-complete', @@ -301,11 +372,23 @@ class UpdatePickupProvider { ); case 'skipped': - // Was a fabricated success — `okEnvelope('skip not supported by - // backend')` — so the rider watched a stop move to "skipped" while - // nothing left the phone and the hub never learned. + // ── The booking skip, not the consignment skip ── + // + // It was a fabricated success once — `okEnvelope('skip not supported + // by backend')` — so the rider watched a stop move to "skipped" while + // nothing left the phone. The fix that replaced it posted to + // `POST /miler/consignments/:id/skip` with a **booking** id, because + // that was the only skip route on the contract. Two different + // sequences: the call either 404'd or, worse, skipped whichever + // consignment happened to carry that number. + // + // Every caller of this branch is pre-collection — the skip sheet opens + // from a pickup card — and the pre-pickup skip now has its own route, + // which keeps the booking assigned and resumable. A skip *after* + // collection never comes through here: it goes through + // `closeDelivery`, which resolves the real consignment id first. return envelope( - await MilerApi.skipConsignment( + await MilerApi.skipBooking( id, reason: notes.isEmpty ? 'Skipped by rider' : notes, lat: lat, diff --git a/lib/providers/summary/summary.dart b/lib/providers/summary/summary.dart index ff92c33..dab5fb8 100644 --- a/lib/providers/summary/summary.dart +++ b/lib/providers/summary/summary.dart @@ -10,11 +10,22 @@ class SummaryProvider { Future fetchSummaryStats(int userId) => _fetchSummaryStatsNew(); - /// NEW API: GET /miler/earnings?period=daily|weekly|monthly - /// -> data:{ completed_stops, total_kms, total_earnings, total_bonus }. - /// PickupStats is stop COUNTS per period; map completed_stops for each. + /// `GET /miler/earnings?period=daily|weekly|monthly` + /// + /// → `data: { completed_stops, cancelled_stops, total_stops, total_kms, + /// total_earnings, total_bonus }`. + /// + /// ── The success rate is read, not derived ── + /// + /// `cancelled_stops` and `total_stops` did not exist, so the page computed a + /// success rate from `completed_stops` alone — which is a rate with no + /// denominator, and it read 100% on a day with three cancellations. Both + /// counts ship now and the figures come off the response. + /// + /// The three period calls stay three calls: the endpoint answers for one + /// period at a time, and the page shows all three. Future _fetchSummaryStatsNew() async { - Future stops(String period) async { + Future<({int done, int cancelled, int total})> stops(String period) async { try { final uri = Uri.parse( ApiConfig.url('/miler/earnings'), @@ -25,25 +36,34 @@ class SummaryProvider { final d = (decoded is Map && decoded['data'] is Map) ? decoded['data'] as Map : {}; - return int.tryParse('${d['completed_stops'] ?? 0}') ?? 0; + int n(String key) => int.tryParse('${d[key] ?? 0}') ?? 0; + final done = n('completed_stops'); + final cancelled = n('cancelled_stops'); + // A server that has not shipped `total_stops` yet answers 0, and a + // total below the completed count is not a total — fall back to what + // is certainly true rather than printing a rate over nothing. + final reported = n('total_stops'); + final total = reported >= done + cancelled + ? reported + : done + cancelled; + return (done: done, cancelled: cancelled, total: total); } } catch (_) {} - return 0; + return (done: 0, cancelled: 0, total: 0); } final today = await stops('daily'); final week = await stops('weekly'); final month = await stops('monthly'); - ApiConfig.logGap( - 'summaryStats', - 'v1 earnings has no cancelled/total count; derived from completed_stops.', - ); return PickupStats( - today: today, - week: week, - month: month, - total: month, - cancelled: 0, + today: today.done, + week: week.done, + month: month.done, + total: month.total, + // Was hardcoded to zero because nothing reported it. It is the month's + // figure, to match `total` — the two are one fraction and must come from + // one period or the rate is nonsense. + cancelled: month.cancelled, ); } } diff --git a/lib/views/Dashboard/activity/activity_format.dart b/lib/views/Dashboard/activity/activity_format.dart index 2082182..9362c8b 100644 --- a/lib/views/Dashboard/activity/activity_format.dart +++ b/lib/views/Dashboard/activity/activity_format.dart @@ -83,6 +83,46 @@ String clockOf(DateTime t) { return '$hour12:$minute ${t.hour < 12 ? 'AM' : 'PM'}'; } +/// `Today`, `Yesterday`, or `17 Aug` — which day a record belongs to. +/// +/// A clock alone is ambiguous the moment a rider opens a record that is not +/// today's, and Activity's own rule bans the all-caps date band that used to +/// answer it. This is the same fact as a caption on the record itself, where it +/// costs one line rather than a header per group. +String dayLabel(DateTime t, {DateTime? now}) { + final today = now ?? DateTime.now(); + final day = DateTime(t.year, t.month, t.day); + final ref = DateTime(today.year, today.month, today.day); + final diff = ref.difference(day).inDays; + if (diff == 0) return 'Today'; + if (diff == 1) return 'Yesterday'; + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + return '${t.day} ${months[t.month - 1]}'; +} + +/// `2:05` — the same clock as [clockOf] with the meridiem dropped. +/// +/// For a strip of four times on one line, where the `AM`/`PM` is both the +/// widest part of the string and the part a rider can infer from the three +/// beside it. +String shortClockOf(DateTime t) { + final hour12 = t.hour % 12 == 0 ? 12 : t.hour % 12; + return '$hour12:${t.minute.toString().padLeft(2, '0')}'; +} + /// A duration at a glance: `6m`, `1h 12m`. /// /// Seconds are never shown — nothing on these screens is decided to the second, diff --git a/lib/views/Dashboard/activity/activity_page.dart b/lib/views/Dashboard/activity/activity_page.dart index 17589cf..d75c528 100644 --- a/lib/views/Dashboard/activity/activity_page.dart +++ b/lib/views/Dashboard/activity/activity_page.dart @@ -6,7 +6,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/Models/stop_status.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; -import 'package:miler/views/helpers/constants/miler_surface.dart'; +import 'package:miler/views/helpers/constants/narrative.dart'; import 'package:miler/data/stop_area.dart'; import 'package:miler/data/accepted_store.dart'; import 'package:miler/data/work_scope.dart'; @@ -125,7 +125,8 @@ class ActivityPage extends StatefulWidget { State createState() => _ActivityPageState(); } -class _ActivityPageState extends State { +class _ActivityPageState extends State + with WidgetsBindingObserver { final PickupProvider _provider = PickupProvider(); List> _done = >[]; @@ -147,6 +148,15 @@ class _ActivityPageState extends State { bool _failed = false; bool _fetching = false; + /// Notices when the day underneath the page has moved on. + /// + /// [_load] filters against `ServiceDay.today` *as read at fetch time*, so the + /// list is only ever as fresh as the last fetch. See [ServiceDayRollover]. + final ServiceDayRollover _day = ServiceDayRollover(); + + /// Fires at the next local midnight. See [_armRollover]. + Timer? _rollover; + /// Which trip tab is open. Held across refreshes; clamped at read time, so a /// trip disappearing between polls cannot leave the page pointing past the /// end of the list. @@ -167,14 +177,71 @@ class _ActivityPageState extends State { // for the rest of the session: he finished a stop, came here, and found // nothing, which is exactly the bug this page was added to prevent. BottomPage.currentTab.addListener(_onTabChanged); + WidgetsBinding.instance.addObserver(this); + _armRollover(); } @override void dispose() { BottomPage.currentTab.removeListener(_onTabChanged); + WidgetsBinding.instance.removeObserver(this); + _rollover?.cancel(); super.dispose(); } + /// ── The day ends on screen, not just in the filter ── + /// + /// [ServiceDay] is deliberately a read-time question rather than a scheduled + /// purge, and that is right for the *data*. It is not sufficient for the + /// *screen*, because this page only re-reads when the tab comes forward: a + /// rider who leaves Activity open through 23:59, or backgrounds the app on it + /// overnight and picks the phone up at six, is looking at a list that was + /// filtered against a day which has since ended. Nothing is wrong in the + /// store; the pixels are just stale. + /// + /// Two triggers, because a phone gives no single reliable one: + /// + /// • **A timer to the next local midnight**, for the app left in the + /// foreground across the boundary. Android and iOS both suspend timers in + /// the background, so this one is the *only* thing it is asked to cover — + /// it is not load-bearing, and nothing breaks when it does not fire. + /// • **Resume**, for every other case — which is most of them. The check is + /// a string compare against [_loadedDay], so a resume inside the same day + /// costs nothing and a resume the next morning turns the page over before + /// the rider has read it. + /// + /// Both land in [_rolloverCheck], and it is idempotent: whichever gets there + /// first does the work and the other finds the day already current. + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) _rolloverCheck(); + } + + void _armRollover() { + _rollover?.cancel(); + _rollover = Timer(_day.untilNextDay, _rolloverCheck); + } + + /// Turns the page over if the service day has moved on since [_load] ran. + /// + /// Yesterday's rows are dropped from state **before** the refetch rather than + /// after it. A new day starts empty even with no signal — the alternative is + /// a rider standing in a dead spot at 6am reading yesterday's completed stops + /// under a heading that says Today, which is the exact failure this is for. + /// Anything that genuinely belongs to the new day (a stop closed at 00:10 on + /// an evening shift) comes straight back from the completed store, which + /// stamped it with the new day when it was written. + void _rolloverCheck() { + if (!mounted) return; + _armRollover(); + if (!_day.rollIfNeeded()) return; + setState(() { + _done = >[]; + _skipped = >[]; + }); + _load(); + } + void _onTabChanged() { if (BottomPage.currentTab.value == _activityTabIndex && mounted) _load(); } @@ -289,14 +356,39 @@ class _ActivityPageState extends State { // every trip summary and the day's cash total are all computed off the // same set the list draws. Nothing is deleted — see [ServiceDay] for why // this is a read-time question and not a scheduled purge. - final serviceDay = ServiceDay.today; + // The day this fetch filters against, adopted so a later rollover check + // compares against what is actually on screen. + _day.rollIfNeeded(); + final serviceDay = _day.day; final before = merged.length; + var undated = 0; merged.removeWhere( - (_, row) => !ServiceDay.belongsToToday(row, now: serviceDay), + (_, row) => !ServiceDay.belongsToToday( + row, + now: serviceDay, + // ── An undated row is excluded, and said out loud ── + // + // Delivered, Skipped and Cancelled all pass through here, and the + // rule is the same for all three: a record that cannot prove it + // belongs to today does not appear on a screen headed Today. It is + // not deleted — both local stores and the backend keep it — it is + // simply not this view's business. + // + // The local stores stamp `completedday` / `skippedday` at the moment + // the rider acts (see `accepted_store.dart`), so a stop finished in + // this session carries its own proof and is unaffected. What this + // catches is a backend row with no timestamp at all, which is a data + // defect worth counting rather than absorbing. + onUndated: (row) { + undated++; + ServiceDay.logUndated(row); + }, + ), ); if (before != merged.length) { debugPrint( - '[ACTIVITY] $serviceDay — kept ${merged.length} of $before rows', + '[ACTIVITY] $serviceDay — kept ${merged.length} of $before rows' + '${undated > 0 ? ' ($undated undated)' : ''}', ); } @@ -370,53 +462,154 @@ class _ActivityPageState extends State { final bool hasWork = _all.isNotEmpty; return Scaffold( - // The brand shows through the sheet's two top corners — see [MilerSheet]. - backgroundColor: ColorConstants.primary, - appBar: const MilerAppBar(title: 'Activity'), - body: MilerSheet( - child: RefreshIndicator( - onRefresh: _load, - color: ColorConstants.primary, - // ── The chrome is pinned; only the answer under it swaps ── + // ── No maroon under the bar ── + // + // This was [ColorConstants.primary], from the old arrangement where the + // sheet clipped its own top corners and the brand showed through them. + // That arrangement is gone — the bar rounds its own bottom and paints the + // page colour into the corners itself (see [milerBrandSurface]) — but the + // red Scaffold stayed, and the sheet was still being pushed down by + // [_heroLift] to make room for the floating figure card. + // + // What that left was a 30pt band of brand red spanning the full width + // directly under the app bar: partly covered by the card in the middle, + // bare down both gutters, and on a day with nothing finished — when the + // card is not built at all — a solid red stripe across the whole screen. + // It read as a second, broken app bar. + // + // The page's ground is white, so the Scaffold is white. Nothing can show + // through anywhere, which is the only way to be sure of it. + backgroundColor: ColorConstants.pureSurface, + // `pageColor` is the surface directly under the bar, and that is what + // makes its bottom curve visible — see [milerBrandSurface]. + appBar: const MilerAppBar( + title: 'Activity', + pageColor: ColorConstants.pureSurface, + ), + body: Stack( + clipBehavior: Clip.none, + children: [ + // ── The ground is the canvas, because the page has objects on it ── // - // The strapline and the filter bar are the page's own furniture: they - // say what this screen is and which slice of it he is looking at, and a - // filter that scrolled away would leave the rider reading a partial - // list with nothing on screen explaining why. They are hidden entirely - // on a day with no work, because there is nothing to filter and an - // empty state should own the screen it is explaining. - // ── Stretch, or the furniture is centred instead of guttered ── - // - // A `Column` cross-aligns `center` by default, so this one handed - // `_chrome` loose width: it shrink-wrapped to its widest child and - // was then centred. The heading and the filter bar therefore sat on - // whatever edge the centring happened to leave — measured at 48pt — - // while the trip rows below stood correctly on the 20pt gutter. - // - // Every `_gutter` inside the chrome was being applied to a box that - // was not the width of the page, which is why the numbers in the - // code and the pixels on the screen disagreed. - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (_firstLoadDone && hasWork) _chrome(), - Expanded( - child: SmoothSwap( - child: !_firstLoadDone - ? _skeleton() - : _failed && _all.isEmpty - ? ErrorRetry( - key: const ValueKey('activity-error'), - onRetry: _load, - ) - : !hasWork - ? _empty() - : _list(), + // This sheet was the working white with everything drawn straight + // onto it. That is right for a page of rows and wrong for a page of + // cards: white stops on white cannot be seen, which is the whole + // reason the surface ladder has a ground at all. + Positioned.fill( + child: MilerSheet( + // ── This page belongs to the Narrative system now ── + // + // A light canvas with bordered, lifted white cards, rather than + // the app's darker ground with borderless ones. Both buy the same + // separation; what does not work is half of each, so the page + // takes this one whole. See [Narrative]. + // ── White, like Home ── + // + // It was [Narrative.canvas], on the reasoning that a page of + // cards needs a ground to separate from. The cards carry a + // hairline and a lift of their own, so the grey was a second + // separator under things that already had one — and the two + // screens a rider moves between all day were a shade apart for + // no reason either of them could state. + color: ColorConstants.pureSurface, + child: RefreshIndicator( + onRefresh: _load, + color: ColorConstants.primary, + // ── The chrome is pinned; only the answer under it swaps ── + // + // The strapline and the filter bar are the page's own furniture: they + // say what this screen is and which slice of it he is looking at, and a + // filter that scrolled away would leave the rider reading a partial + // list with nothing on screen explaining why. They are hidden entirely + // on a day with no work, because there is nothing to filter and an + // empty state should own the screen it is explaining. + // ── Stretch, or the furniture is centred instead of guttered ── + // + // A `Column` cross-aligns `center` by default, so this one handed + // `_chrome` loose width: it shrink-wrapped to its widest child and + // was then centred. The heading and the filter bar therefore sat on + // whatever edge the centring happened to leave — measured at 48pt — + // while the trip rows below stood correctly on the 20pt gutter. + // + // Every `_gutter` inside the chrome was being applied to a box that + // was not the width of the page, which is why the numbers in the + // code and the pixels on the screen disagreed. + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Clears the floating count card. Its height is fixed and + // its type is clamped (see [_heroCard]) precisely so this + // can be arithmetic rather than a guess that goes wrong at + // 2.0x system text. + SizedBox( + height: _firstLoadDone && hasWork + ? _heroTop + _heroHeight + Narrative.gapBlock.h + : 6.h, + ), + if (_firstLoadDone && hasWork) _chrome(), + Expanded( + child: SmoothSwap( + child: !_firstLoadDone + ? _skeleton() + : _failed && _all.isEmpty + ? ErrorRetry( + key: const ValueKey('activity-error'), + onRetry: _load, + ) + : !hasWork + ? _empty() + : _list(), + ), + ), + ], ), ), - ], + ), ), - ), + // ── The day's figure floats over the seam ── + // + // It used to be the first line *inside* the page, which put the one + // fact a rider opens this screen for in the same plane as the filter + // bar under it. Lifted onto its own card straddling the brand and the + // canvas, it reads as the page's answer rather than as its first row + // — and the seam between the app bar and the list, which was a bare + // curve, now has something on it. + if (_firstLoadDone && hasWork) + Positioned( + top: _heroTop, + left: _cardGutter, + right: _cardGutter, + child: _heroCard(), + ), + ], + ), + ); + } + + /// How far below the app bar the floating count card starts. + static double get _heroTop => 4.h; + + /// The card's own height. Fixed, with its type clamped, so the spacer that + /// clears it below cannot drift out of step at a large text scale. + static double get _heroHeight => 74.h; + + /// The floating card: the day's figure, and nothing else. + Widget _heroCard() { + return Container( + height: _heroHeight, + padding: EdgeInsets.symmetric(horizontal: 20.w), + alignment: Alignment.centerLeft, + decoration: BoxDecoration( + color: Narrative.card, + borderRadius: BorderRadius.circular(Narrative.radiusPill), + border: Border.all(color: Narrative.cardBorder, width: 1), + // The one thing on the page that sits *over* another surface rather + // than on it, so it takes the deeper of the two lifts. + boxShadow: Narrative.floatShadow, + ), + child: MediaQuery.withClampedTextScaling( + maxScaleFactor: 1.2, + child: _heroFigure(), ), ); } @@ -443,11 +636,16 @@ class _ActivityPageState extends State { /// The page's single left edge. Every row, every heading and every figure /// column starts here, which is what lets the eye run straight down a day's /// work instead of stepping in and out of a card's padding on every stop. - static double get _gutter => 20.w; + static double get _gutter => (Narrative.gutter + 2).w; - /// The padding a filter chip carries inside itself. Named because the bar - /// has to subtract exactly this much to put its first label on [_gutter]. - static double get _chipInset => 8.w; + /// The margin a stop card leaves either side of itself, so the canvas shows + /// down both edges of the list and the cards read as objects on a ground. + /// + /// Narrower than [_gutter] on purpose: the card carries 12 of padding + /// *inside* it, so its content lands on 24 while the page's own furniture — + /// the heading, the filter bar — stands on 20. The two edges a rider + /// actually sees are the card's, and they are straight. + static double get _cardGutter => Narrative.gutter.w; // ══════════════════════════════════════════════════════════════════════ // CHROME — what this screen is, and which slice of it @@ -465,16 +663,29 @@ class _ActivityPageState extends State { // the overview by plain padding, the bar by subtracting the padding its // chips already have. The chips keep their full tap width. return Padding( - padding: EdgeInsets.symmetric(vertical: 2.h), + padding: EdgeInsets.only(bottom: 4.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: EdgeInsets.symmetric(horizontal: _gutter), - child: _shiftOverview(), + child: _shiftNotes(), ), - SizedBox(height: 16.h), + SizedBox(height: 10.h), _filterBar(), + // ── The hairline under the bar is gone ── + // + // It was there because the tabs were an *underlined* segmented + // control: the selected tab's marker was a rule, and a rule needs a + // rail under it or it reads as a stray mark floating between the card + // above and the list below. + // + // The tabs are filled pills now. A pill carries its own boundary, so + // the rail has nothing left to support — and a full-width line across + // a white page is the loudest thing on it, drawn to separate two + // groups that whitespace already separates. Air does it, and air is + // what the rest of this screen uses. + SizedBox(height: 14.h), ], ), ); @@ -498,7 +709,148 @@ class _ActivityPageState extends State { /// footer already computed. Nothing is derived that the record cannot prove, /// and a metric with nothing behind it is not printed at all — a shift with /// no measured stops has no on-time line rather than a confident `0%`. - Widget _shiftOverview() { + /// The numeral and its caption — the whole content of the floating card. + /// + /// Split out of [_shiftNotes] when the figure moved onto its own surface: the + /// card holds the answer, and the qualifiers that used to sit under it stay + /// on the page, above the filter bar, where they can wrap freely. + /// ── The card is two halves, and each answers one question ── + /// + /// It was one left-aligned row — a big count, its caption, then whatever was + /// left of the width — with the kilometres bolted onto the end. That reads as + /// a headline with something appended, and on a narrow phone the appended + /// thing is the part that gets squeezed. + /// + /// Two equal halves with a hairline between them. `34 delivered` and `26.9 km + /// ridden` are the same *kind* of fact at the same rank — what the shift + /// produced, and what it cost — so they are set the same way and given the + /// same room, and neither can crowd the other as the numbers grow. + Widget _heroFigure() { + final done = _all.where((s) => stopStatusOf(s).isFinishedPickup).toList(); + if (done.isEmpty) { + return Align( + alignment: Alignment.centerLeft, + child: Text( + 'Today', + style: MilerType.body.copyWith( + fontSize: 19.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.4, + ), + ), + ); + } + + return Row( + children: [ + Expanded( + child: _heroHalf( + figure: '${done.length}', + // ── The count wears the brand ── + // + // It is the one figure the page is *answering with*. The distance + // beside it is context for it, so that one stays slate — two brand + // figures side by side would be two answers. + figureColour: Narrative.burgundy, + caption: ServiceProfile.active.completionVerb.toLowerCase(), + ), + ), + // ── Both halves, always ── + // + // The distance half used to be hidden when the figure was zero, which + // meant a rider whose rows carried no measurement never saw the slot at + // all — and could not tell the difference between "the app does not + // report this" and "I have not ridden anywhere". A card that is a pair + // has to stay a pair. + // + // An absent figure prints the em dash, which is what every other + // unknown in this app prints. + Container(width: 1, height: 34.h, color: Narrative.cardBorder), + Expanded( + child: _heroHalf( + figure: _kmToday > 0 ? kmText(_kmToday) : '\u2014', + figureColour: _kmToday > 0 ? Narrative.ink : Narrative.inkMuted, + caption: 'km ridden', + ), + ), + ], + ); + } + + /// One half of the hero: a figure over what it counts. + Widget _heroHalf({ + required String figure, + required Color figureColour, + required String caption, + }) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 12.w), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + figure, + maxLines: 1, + style: TextStyle( + // The brief's `display-lg`, which is what a figure the page is + // answering with is meant to be set at. + fontSize: 30.sp, + fontWeight: FontWeight.w800, + letterSpacing: -1, + height: 1.0, + fontFeatures: const [FontFeature.tabularFigures()], + color: figureColour, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + SizedBox(height: 4.h), + Text( + caption, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.sp, + height: 1.0, + fontWeight: FontWeight.w500, + color: Narrative.inkSoft, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ); + } + + /// ── How far he rode today ── + /// + /// The day's other headline figure, and the one the app had nowhere to put: + /// distance was on every individual row and nowhere in aggregate, so a rider + /// who wanted to know what he had covered had to add up forty of them. + /// + /// It belongs on this card because this card is the day: the count of what he + /// finished, and the distance he covered finishing it, are the two facts a + /// shift is described by. Set quieter than the count — slate, not brand — + /// because the count is what the page is *answering with* and two brand + /// figures side by side is two answers. + /// + /// Summed from the same per-stop measurement each row prints, so the card and + /// the rows can never disagree. + double get _kmToday { + var total = 0.0; + for (final stop in _all) { + if (!stopStatusOf(stop).isFinishedPickup) continue; + final km = StopCompliance.of(stop).actualKm; + if (km != null && km > 0) total += km; + } + return total; + } + + Widget _shiftNotes() { final rows = _all; final done = rows.where((s) => stopStatusOf(s).isFinishedPickup).toList(); final attention = rows.where((s) => stopStatusOf(s).isSkipped).length; @@ -533,60 +885,11 @@ class _ActivityPageState extends State { // the all-caps date-band vocabulary never returns; the word rides in the // numeral's caption instead, lowercase, where it is a fact and not a // band. + if (second.isEmpty && third.isEmpty) return const SizedBox.shrink(); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (done.isNotEmpty) ...[ - SizedBox(height: 2.h), - MediaQuery.withClampedTextScaling( - maxScaleFactor: 1.3, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - '${done.length}', - style: TextStyle( - fontSize: 28.sp, - fontWeight: FontWeight.w800, - letterSpacing: -1, - height: 1.0, - fontFeatures: const [FontFeature.tabularFigures()], - color: ColorConstants.slateText, - fontFamily: FontConstants.fontFamily, - ), - ), - SizedBox(width: 7.w), - Flexible( - child: Padding( - padding: EdgeInsets.only(top: 6.h), - child: Text( - '${ServiceProfile.active.completionVerb.toLowerCase()}' - ' today', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w600, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - ), - ], - ), - ), - ] else ...[ - SizedBox(height: 4.h), - Text( - 'Today', - style: MilerType.body.copyWith( - fontSize: 19.sp, - fontWeight: FontWeight.w700, - letterSpacing: -0.4, - ), - ), - ], if (second.isNotEmpty) ...[ SizedBox(height: 4.h), // ── Amber is a word, not a line ── @@ -613,24 +916,27 @@ class _ActivityPageState extends State { text: second[i].$1, style: TextStyle( color: second[i].$2 - ? ColorConstants.warning - : ColorConstants.secondaryText, + ? Narrative.amber + : Narrative.inkMuted, ), ), ], ], ), style: MilerType.body.copyWith( - fontSize: 14.sp, + fontSize: 13.5.sp, fontWeight: FontWeight.w500, ), ), ], if (third.isNotEmpty) ...[ - SizedBox(height: 2.h), + SizedBox(height: 3.h), Text( third.join(' · '), - style: MilerType.micro.copyWith(fontSize: 13.sp), + style: MilerType.micro.copyWith( + fontSize: 13.sp, + color: Narrative.inkMuted, + ), ), ], ], @@ -656,118 +962,112 @@ class _ActivityPageState extends State { int get _attentionCount => _all.where((s) => stopStatusOf(s).isSkipped).length; + /// ── A segmented control with a physical indicator ── + /// + /// It was four words on a rail with a 2pt rule sliding under the selected + /// one. That reads as a *tab bar*, and a tab bar says "these are four pages"; + /// this is one page with a filter on it. The brief asks for the other idiom — + /// a track holding a pill that moves — and it is the right one: the pill is a + /// physical object the eye can follow, the track says the four are exclusive, + /// and nothing has to be underlined to be legible in sun. + /// + /// The pill travels rather than fading in and out. Same reason as the trip + /// tabs and the nav bar: two pills cross-fading have nothing occupying the + /// space between them, so the selection appears to teleport. + /// + /// Scrollable, because at 2.0x system text four labels do not fit a phone and + /// a bar that wraps to two lines moves the list under the rider's thumb. + /// ── Four pills, not a segmented track ── + /// + /// It was a tonal track with one white pill sliding along it, four equal + /// slots wide. Two things were wrong with that on a white page. The track is + /// a container drawn around a control that already marks its own selection, + /// which is the nesting this app keeps pulling out of everything else; and + /// equal slots force `Cancelled` and `All` to the same width, so the type had + /// to come down to 12.5 to stop the long one touching its own edges. + /// + /// Separate pills, each as wide as its label. The selected one is a **filled + /// brand pill with white type** — the inverse of the trip tabs on Home, and + /// deliberately so: this is the only control on the page, where those sit + /// above a card with an action of its own and must not outrank it. + /// + /// Scrollable, because four labels plus a count will not fit a 320pt phone at + /// a large system font, and a filter that clips its last option is a filter + /// the rider cannot reach. Widget _filterBar() { - // ── Sized by its own type, never by a fixed box ── - // - // A `SizedBox(height:)` around a row of words clips them the moment the - // rider turns his text size up, and this app is read at 2.0x on a bike. The - // bar takes its height from the label plus symmetric padding, and the - // scroll view is told to shrink-wrap that rather than to fill a number. - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - // `_chipInset` back off the leading edge so the first label — not the - // chip box around it — sits on the same line as the heading above. - padding: EdgeInsets.only( - left: _gutter - _chipInset, - right: _gutter - _chipInset, + final filters = _ActivityFilter.values; + + return SizedBox( + height: 40.h, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: EdgeInsets.symmetric(horizontal: _gutter), + physics: const ClampingScrollPhysics(), + itemCount: filters.length, + separatorBuilder: (_, _) => SizedBox(width: 8.w), + itemBuilder: (context, i) => + _filterChip(filters[i], filters[i] == _filter), ), - physics: const ClampingScrollPhysics(), - child: Row( - children: [ - for (final (i, f) in _ActivityFilter.values.indexed) ...[ - if (i > 0) SizedBox(width: _chipInset), - Builder( - builder: (context) { - final selected = f == _filter; - return Semantics( - button: true, - selected: selected, - label: f.label, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: selected ? null : () => setState(() => _filter = f), - child: Padding( - // Horizontal padding only from the gap between words; - // the vertical keeps a 48dp target under a 13.5sp label. - padding: EdgeInsets.symmetric( - horizontal: _chipInset, - vertical: 12.h, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - f.label, - maxLines: 1, - style: MilerType.body.copyWith( - fontSize: 14.sp, - fontWeight: selected - ? FontWeight.w700 - : FontWeight.w500, - letterSpacing: -0.1, - color: selected - ? ColorConstants.primary - : ColorConstants.secondaryText, - ), - ), - // ── A count only where it changes a decision ── - // - // On *Active* it does: it is the number of stops - // the rider still owes somebody, and seeing it - // without opening the slice is the reason to - // open the slice. Everywhere else the list's own - // length answers better, and a number beside - // every word doubles the text in the bar. - if (f == _ActivityFilter.active && - _attentionCount > 0) ...[ - SizedBox(width: 5.w), - Text( - '$_attentionCount', - style: MilerType.body.copyWith( - fontSize: 13.sp, - fontWeight: FontWeight.w700, - color: ColorConstants.warning, - ), - ), - ], - ], - ), - SizedBox(height: 6.h), - // The whole selected state: a 2pt brand rule. Four - // pills were four containers arguing about which one - // was on, on a page whose brief is that containers - // recede. - AnimatedContainer( - duration: DesignConstants.motionState, - curve: Curves.easeOutCubic, - height: 2.h, - width: selected ? 18.w : 0, - decoration: BoxDecoration( - // Transparent when it is not the selected one: - // a zero-width rule still *has* a colour, and a - // brand-coloured box nobody can see is the kind - // of thing that reads as four selected filters - // to anything inspecting the tree. - color: selected - ? ColorConstants.primary - : Colors.transparent, - borderRadius: BorderRadius.circular( - DesignConstants.radiusFull, - ), - ), - ), - ], - ), - ), + ); + } + + Widget _filterChip(_ActivityFilter f, bool selected) { + return Semantics( + button: true, + selected: selected, + label: f.label, + excludeSemantics: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: selected ? null : () => setState(() => _filter = f), + child: AnimatedContainer( + duration: DesignConstants.motionState, + curve: Curves.easeOut, + alignment: Alignment.center, + padding: EdgeInsets.symmetric(horizontal: 18.w), + decoration: BoxDecoration( + color: selected + ? Narrative.burgundy + : ColorConstants.tint(ColorConstants.slateText, 0.055), + borderRadius: BorderRadius.circular(Narrative.radiusPill), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + f.label, + maxLines: 1, + style: MilerType.body.copyWith( + // 13.5, back up from the 12.5 the equal-slot track forced: + // a pill is as wide as its own label, so `Cancelled` no + // longer has to fit the width `All` was given. + fontSize: 13.5.sp, + fontWeight: selected ? FontWeight.w700 : FontWeight.w600, + letterSpacing: -0.1, + color: selected ? Colors.white : Narrative.inkMuted, + ), + ), + // ── A count only where it changes a decision ── + // + // On *Active* it does: it is the number of stops the rider still + // owes somebody, and seeing it without opening the slice is the + // reason to open the slice. Everywhere else the list's own length + // answers better, and a number beside every word doubles the text + // in the bar. + if (f == _ActivityFilter.active && _attentionCount > 0) ...[ + SizedBox(width: 6.w), + Text( + '$_attentionCount', + style: MilerType.body.copyWith( + fontSize: 13.5.sp, + fontWeight: FontWeight.w800, + color: selected ? Colors.white : Narrative.amber, ), - ); - }, - ), - ], - ], + ), + ], + ], + ), + ), ), ); } @@ -859,19 +1159,12 @@ class _ActivityPageState extends State { // The same well Home's kitchens open into: a canvas-toned // inset that makes the open trip read as *contents*, not as // more page. One system, both accordions. - ? Container( - margin: EdgeInsets.only(bottom: 6.h), - padding: EdgeInsets.fromLTRB(10.w, 6.h, 10.w, 0), - decoration: BoxDecoration( - color: MilerSurface.canvas, - borderRadius: BorderRadius.circular( - DesignConstants.radiusLg, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: _tripBody(trip), - ), + // The canvas-toned well that used to hold an open trip is + // gone: the page's own ground is that tone now, so the inset + // was a grey box drawn on grey. The cards are the contents. + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: _tripBody(trip), ) : SizedBox(width: double.infinity, height: 0), ), @@ -885,7 +1178,7 @@ class _ActivityPageState extends State { /// The stops of one trip, in the order the hub assigned them. List _tripBody(Trip trip) => [ for (final (i, stop) in trip.stops.indexed) - _row(stop, first: i == 0, last: i == trip.stops.length - 1), + _row(stop, last: i == trip.stops.length - 1), SizedBox(height: 10.h), ]; @@ -917,20 +1210,33 @@ class _ActivityPageState extends State { final skipped = trip.stops.where((s) => stopStatusOf(s).isSkipped).length; final clean = skipped == 0; - return Padding( - padding: EdgeInsets.only(top: 14.h, bottom: 4.h), + // ── A run that opens is an object; a run that is just a label is not ── + // + // With several trips in a day the header is a control — press it and a + // drawer opens — so it takes a card, like everything else on this page you + // can press. With one trip there is nothing to open (see [_list]), the + // header is a heading, and a card drawn round a heading is a box around + // nothing. + return Container( + margin: EdgeInsets.fromLTRB( + _cardGutter, + Narrative.gapBlock.h, + _cardGutter, + Narrative.gapLine.h + 2.h, + ), + decoration: tappable ? Narrative.cardBox() : null, child: Material( color: Colors.transparent, child: InkWell( onTap: tappable ? () => setState(() => _openTrip = expanded ? null : slot) : null, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + borderRadius: BorderRadius.circular(Narrative.radiusCard), child: Padding( - // Vertical only. A horizontal inset here bought nothing — the row - // is already full-width, so it added no tap area — and cost the - // disc its place on the gutter the headings stand on. - padding: EdgeInsets.symmetric(vertical: 10.h), + padding: EdgeInsets.symmetric( + vertical: tappable ? Narrative.cardPad.h - 4.h : 6.h, + horizontal: tappable ? Narrative.cardPad.w : 0, + ), child: Row( children: [ // A settled run reads green at a glance; one with a stop still @@ -945,7 +1251,14 @@ class _ActivityPageState extends State { (clean ? ColorConstants.acceptGreen : ColorConstants.warning) - .withValues(alpha: 0.12), + // ── 12% → 18% ── + // + // A dark accent at 12% is a grey with an opinion: + // `acceptGreen` is #047354, and a twelfth of it + // over white is a tone a rider reads as "off" + // rather than as "done". Six points more is what + // makes the disc read as the colour it is naming. + .withValues(alpha: 0.18), shape: BoxShape.circle, ), child: Icon( @@ -1030,10 +1343,14 @@ class _ActivityPageState extends State { physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), ), + // No horizontal padding: the cards carry their own [_cardGutter] and the + // headers carry theirs, so nothing is inset twice. A list-level gutter on + // top of a card margin is what put the cards 32 off the edge and made the + // page read as a column of narrow slips. padding: EdgeInsets.fromLTRB( - _gutter, - 6.h, - _gutter, + 0, + 2.h, + 0, BottomPage.bottomInset(context) + 16.h, ), children: children, @@ -1344,11 +1661,24 @@ class _ActivityPageState extends State { /// booking reference is **not** here — it is a lookup key for an office, it /// lives on the record page, and a rider should not be scanning past /// database identifiers to find a name. - Widget _row( - Map stop, { - bool first = false, - bool last = false, - }) { + /// ── A stop is a card now, not a rung ── + /// + /// This page ran as rows on a spine: a timeline rail down the left, every + /// stop drawn straight onto the paper, and the argument for it was that a + /// list of the same kind of thing is separated by rhythm rather than by + /// containers. + /// + /// It reads better as cards, for a reason the rail could not answer. Each + /// row here is a *finished piece of work you can open* — it has a name, a + /// clock, an outcome and a page behind it — and the rail said the opposite: + /// that these were beads on one thread, to be read down rather than picked + /// out. A rider looking for the stop somebody is asking him about scans for + /// an object, and a card is an object. + /// + /// The spine has not gone; it moved up a level. The trip header still gathers + /// its stops, so the day still reads as runs, and the cards are what is + /// inside one. + Widget _row(Map stop, {bool last = false}) { final id = (stop['orderid'] ?? '').toString(); final status = stopStatusOf(stop); final at = _happenedAt(stop); @@ -1371,7 +1701,7 @@ class _ActivityPageState extends State { final reason = (parked || attempted) ? (stop['skipreason'] ?? stop['notes'] ?? '').toString().trim() : ''; - final meta = _meta(stop, late: late, c: c); + final meta = _meta(stop, late: late, c: c, status: status); final proof = stop['proof']; final double rowCash = (proof is Map && proof['amountcollected'] is num) @@ -1384,40 +1714,56 @@ class _ActivityPageState extends State { button: id.isNotEmpty, label: '${status.label}, ${routeLineOf(stop)}', hint: id.isEmpty ? null : 'Opens the delivery record', - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: id.isEmpty ? null : () => _openDetails(context, stop), - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _railColumn( - mark: mark, - icon: status.icon, - first: first, - last: last, - ), - SizedBox(width: 12.w), - Expanded( - child: Padding( - // ── The gap answers to what is actually in the row ── - // - // Seen on a device: a logistics record carries no - // completion clock, no route line and no measurements — - // `/miler/bookings` returns none of them — so most rows - // are a name and a word. At a fixed 18 of separation that - // is a row which is mostly padding, and seven of them fill - // a phone that should hold twelve. - // - // The wide gap is there to separate *two-and-three-line* - // rows on a meal run. A short row does not need it. - padding: EdgeInsets.only( - top: 8.h, - bottom: last - ? 8.h - : (meta.isEmpty && ends.to == null ? 10.h : 18.h), + child: Container( + margin: EdgeInsets.fromLTRB( + _cardGutter, + 0, + _cardGutter, + last ? 4.h : Narrative.gapLine.h + 4.h, + ), + // Level 1: white, a hairline, and a lift so soft it is felt rather + // than seen. The canvas under it is light enough that the card needs + // both — see [Narrative], and the note there about not mixing the two + // separation recipes. + decoration: Narrative.cardBox(), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: id.isEmpty ? null : () => _openDetails(context, stop), + borderRadius: BorderRadius.circular(Narrative.radiusCard), + child: Padding( + padding: EdgeInsets.fromLTRB( + Narrative.cardPad.w, + Narrative.cardPad.h - 4.h, + (Narrative.cardPad - 6).w, + Narrative.cardPad.h - 4.h, + ), + // Centred, not top-aligned. The card's three storeys are one + // block; a mark and a disclosure pinned to the first line of it + // sit high and read as belonging to the name rather than to the + // card. The text column keeps its own start alignment inside. + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // The mark, as a tile rather than a bead on a rail. Tonal, + // like every disc in the app — the outcome is carried by the + // glyph and the tint, and the word repeats neither because it + // is on the line below saying something the tile cannot. + Container( + width: 40.w, + height: 40.w, + alignment: Alignment.center, + decoration: BoxDecoration( + // 18%, not 12: a dark accent at a twelfth is a grey with + // an opinion. These marks and the trip header's disc are + // the same object at two sizes and must not drift apart. + color: mark.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(Narrative.radiusChip), ), + child: Icon(status.icon, size: 19.sp, color: mark), + ), + SizedBox(width: Narrative.gapBlock.w - 4.w), + Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -1430,9 +1776,11 @@ class _ActivityPageState extends State { maxLines: 1, overflow: TextOverflow.ellipsis, style: MilerType.body.copyWith( + // `title-lg`: the card's own heading. fontSize: 16.sp, fontWeight: FontWeight.w600, - letterSpacing: -0.3, + letterSpacing: -0.2, + color: Narrative.ink, ), ), ), @@ -1442,82 +1790,56 @@ class _ActivityPageState extends State { // flexible name overflows the row from about 1.6x // system text — 122pt off a 320pt phone at 2.0x, // which is a black-and-yellow band across the one - // line that says who the stop was. - // - // Clamped rather than shrunk: `₹2,450` and `5:08 - // PM` are read at a glance, not studied, so they - // stop scaling at 1.3 while the name — the thing - // the rider is actually looking for — keeps - // scaling all the way up. + // line that says who the stop was. Clamped and + // allowed to give: `₹2,450` and `5:08 PM` are read + // at a glance, not studied, so they stop scaling + // while the name keeps going. if (rowCash > 0 || hasTime) - MediaQuery.withClampedTextScaling( - maxScaleFactor: 1.3, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (rowCash > 0) ...[ - SizedBox(width: 8.w), - Text( - rupees(rowCash), - maxLines: 1, - style: MilerType.figure( - 13.5, - color: ColorConstants.moneyGreen, - ).copyWith(fontWeight: FontWeight.w700), - ), - ], - if (hasTime) ...[ - SizedBox(width: 10.w), - Text( - RouteMetricsHelper.formatClock(at), - maxLines: 1, - style: MilerType.figure( - 13, - color: ColorConstants.secondaryText, - ).copyWith(fontWeight: FontWeight.w500), - ), - ], - ], + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: MediaQuery.withClampedTextScaling( + maxScaleFactor: 1.3, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (rowCash > 0) ...[ + SizedBox(width: 8.w), + Text( + rupees(rowCash), + maxLines: 1, + style: + MilerType.figure( + 13.5, + color: + ColorConstants.moneyGreen, + ).copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + if (hasTime) ...[ + SizedBox(width: 10.w), + Text( + RouteMetricsHelper.formatClock(at), + maxLines: 1, + style: + MilerType.figure( + 12.5, + color: Narrative.inkMuted, + ).copyWith( + fontWeight: FontWeight.w500, + ), + ), + ], + ], + ), + ), ), ), ], ), - SizedBox(height: 2.h), - // ── The word does not repeat the rail ── - // - // The rail disc beside this row already carries the - // outcome in shape and colour, and the card's own rule - // is that a tile and a pill saying the same thing is - // one too many. Painted green, a day of 25 delivered - // stops was a column of 25 green words — and a page - // where everything is coloured has no colour left for - // the row that went wrong. - // - // So the norm is quiet ink and the exception keeps the - // status colour. And on a logistics row — which the - // payload leaves as a bare name and a word — the line - // earns its keep by saying *where*: `Delivered · - // Peelamedu` when there is no route line to say it. - Text( - [ - status.label, - if (ends.to == null) - if (areaOf(stop) case final a when a.isNotEmpty) - a, - ].join(' · '), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: MilerType.body.copyWith( - fontSize: 13.5.sp, - fontWeight: FontWeight.w600, - color: - (status.isSkipped || - status.isCancelled || - status.isRejected) - ? mark - : ColorConstants.secondaryText, - ), - ), // The route, and only when there are two ends to it — // a collection has one place and printing it twice // reads as a journey that went nowhere. @@ -1527,25 +1849,27 @@ class _ActivityPageState extends State { routeLineOf(stop), maxLines: 1, overflow: TextOverflow.ellipsis, - style: MilerType.micro.copyWith(fontSize: 12.5.sp), - ), - ], - if (meta.isNotEmpty) ...[ - SizedBox(height: 4.h), - Row( - children: [ - Flexible(child: _dotted(meta, size: 12.5)), - if (id.isNotEmpty) ...[ - SizedBox(width: 8.w), - Icon( - LucideIcons.chevronRight, - size: 14.sp, - color: ColorConstants.borderStrong, - ), - ], - ], + style: MilerType.micro.copyWith( + fontSize: 13.sp, + color: Narrative.inkSoft, + ), ), ], + // ── One storey fewer ── + // + // The outcome word had a line of its own, directly + // under the name, and the measurements had another + // under that. Three storeys per row, one of them + // carrying a single word — which is why twelve stops + // filled a screen and a day did not. + // + // The word is the first item on the measurements line + // now. Same rule as before it moved: the norm is quiet + // ink, the exception keeps the status colour, and the + // tile beside the row is what carries the outcome in + // shape. See [_meta]. + SizedBox(height: 3.h), + _dotted(meta, size: 12.5), // ── The one row that still owes something ── // // A finished stop has nothing left to do and stays @@ -1568,14 +1892,33 @@ class _ActivityPageState extends State { ), ], if (parked) ...[ - SizedBox(height: 4.h), + SizedBox(height: 6.h), _resumeAction(stop, id), ], ], ), ), - ), - ], + // ── The disclosure is the card's ── + // + // It used to sit inline after the last figure — `14m ›` — so + // it read as a control belonging to that line, and a stop + // with no measurements lost its affordance entirely while + // still being tappable. Centred against the whole card, it is + // the passive "this goes somewhere" mark every Account row + // wears. + if (id.isNotEmpty) + Padding( + padding: EdgeInsets.only(left: 6.w), + child: Center( + child: Icon( + LucideIcons.chevronRight, + size: 17.sp, + color: Narrative.inkMuted.withValues(alpha: 0.6), + ), + ), + ), + ], + ), ), ), ), @@ -1583,62 +1926,6 @@ class _ActivityPageState extends State { ); } - /// The rail and this stop's marker. - /// - /// 22dp, tinted, with the status glyph — a quarter of the area the old tile - /// took. It is an anchor for the eye and a colour cue, not a badge. - Widget _railColumn({ - required Color mark, - required IconData icon, - required bool first, - required bool last, - }) { - return SizedBox( - width: 24.w, - child: Column( - children: [ - SizedBox( - height: 8.h, - child: first - ? null - : Center( - child: Container( - width: 1.5, - color: ColorConstants.borderSubtle, - ), - ), - ), - Container( - width: 22.w, - height: 22.w, - alignment: Alignment.center, - decoration: BoxDecoration( - color: mark.withValues(alpha: 0.13), - shape: BoxShape.circle, - ), - child: Icon(icon, size: 12.sp, color: mark), - ), - if (!last) - Expanded( - child: Center( - child: Container( - width: 1.5, - color: ColorConstants.borderSubtle, - ), - ), - ), - ], - ), - ); - } - - /// `Resume stop →`, at a real tap target and with the guards intact. - /// - /// Compact, but the row it sits in is 48dp tall including its padding, and - /// the loading/disabled protection is the same the full-width button had: - /// an id already in flight cannot be pressed twice. The resume rules - /// themselves are untouched — only a stop the data layer marks resumable - /// reaches this at all. See `_load`'s merge and `MyPickups.resumeSkippedStop`. Widget _resumeAction(Map stop, String id) { final busy = _busyIds.contains(id); return Align( @@ -1706,8 +1993,9 @@ class _ActivityPageState extends State { Map stop, { required bool late, required StopCompliance c, + required StopStatus status, }) { - final grey = ColorConstants.secondaryText; + final grey = Narrative.inkMuted; final started = stampOf(stop, const ['startedat']); final done = stampOf(stop, const [ 'completedat', @@ -1747,7 +2035,35 @@ class _ActivityPageState extends State { weight.isNotEmpty && weight != '0'; + final bool exception = + status.isSkipped || status.isCancelled || status.isRejected; + return <(String, Color)>[ + // ── The outcome is coloured, and it leads the line ── + // + // It was quiet ink on the norm, on the rule that a day of 25 delivered + // stops must not be a column of 25 green words. That rule was written + // for a page where every row was drawn on the same white paper and + // colour was the only thing that could separate them. + // + // The rows are cards now, each on its own surface, so the separation is + // structural and the word is free to say what it means. The brief asks + // for it directly: emerald *is* the delivered state in this system, and + // a status the eye can pick out without reading is the difference + // between scanning a day and reading one. + ( + status.label, + exception + ? Narrative.amber + : status.isFinishedPickup + ? Narrative.emerald + : grey, + ), + // On a logistics row the payload leaves nothing but a name and a word, + // so the line earns its keep by saying *where* — `Delivered · + // Peelamedu` — when there is no route line above it to say it. + if (routeEndsOf(stop).to == null) + if (areaOf(stop) case final a when a.isNotEmpty) (a, grey), if (parcels > 0) ( // A collection *raised* the orders; a delivery *handed them over*. diff --git a/lib/views/Dashboard/activity/delivery_details_page.dart b/lib/views/Dashboard/activity/delivery_details_page.dart index 106932e..c257920 100644 --- a/lib/views/Dashboard/activity/delivery_details_page.dart +++ b/lib/views/Dashboard/activity/delivery_details_page.dart @@ -17,6 +17,9 @@ 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_app_bar.dart'; +import 'package:miler/views/helpers/constants/miler_surface.dart'; +import 'package:miler/views/helpers/constants/narrative.dart'; +import 'package:miler/views/helpers/constants/Font_constant.dart'; /// ───────────────────────────────────────────────────────────────────────── /// DELIVERY DETAILS — one stop, the whole story @@ -77,38 +80,117 @@ class DeliveryDetailsPage extends StatelessWidget { // the story (the journey), the evidence (the photograph), and then three // folded rows for the facts somebody in an office might ask about weeks // later. Those facts have not been removed; they have been ranked. - backgroundColor: ColorConstants.daylightSurface, - appBar: const MilerAppBar(title: 'Delivery details', showBack: true), - body: ListView( - physics: const AlwaysScrollableScrollPhysics( - parent: BouncingScrollPhysics(), - ), - padding: EdgeInsets.fromLTRB(0, 0, 0, 40.h), - children: [ - Container( - padding: EdgeInsets.fromLTRB(20.w, 26.h, 20.w, 8.h), - decoration: BoxDecoration( - color: ColorConstants.pureSurface, - borderRadius: BorderRadius.vertical( - bottom: Radius.circular(DesignConstants.radiusXl), + // ── One white slab was the problem, not the cure ── + // + // The page was a single white sheet holding all four sections, closed + // with a rounded bottom edge. On the common record — a delivery with no + // photograph and two timeline rungs — the sheet ran out two-fifths down + // the screen and the rest was bare grey, so the page read as *unfinished* + // rather than as short. And inside the slab nothing was separated from + // anything: four subjects, one surface, all of it centred. + // + // It stands on the app's own ladder now, the same one Account and the + // Deliveries queue use: canvas ground, one white panel per subject, the + // canvas showing between them. A short record is then a page with two + // panels on it — which looks deliberate, because it is. + backgroundColor: ColorConstants.primary, + appBar: MilerAppBar( + title: 'Delivery details', + showBack: true, + // This Scaffold is brand-red, so the bar's curve would open onto more + // maroon. It names the sheet under it — see [milerBrandSurface]. + pageColor: ColorConstants.pureSurface, + // ── The reference rides in the bar ── + // + // It was a folded row three taps down, which is the right rank for a + // rider and the wrong one for the conversation this page exists to + // settle: the office opens with the booking number, and he should be + // able to read it back without hunting. It stays quiet — small, on the + // brand, in the corner — because it is an *identifier*, not a heading. + trailing: _reference().isEmpty + ? null + : Padding( + padding: EdgeInsets.only(right: 4.w), + child: Text( + _reference(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + color: ColorConstants.onAccent.withValues(alpha: 0.72), + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + body: MilerSheet( + // White, like Activity and Home. It was [Narrative.canvas] — a grey + // ground for panels that carry a hairline and a lift of their own, so + // the grey was a second separator under things that already had one. + // The three screens a rider moves between are one colour now. + color: ColorConstants.pureSurface, + child: ListView( + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + padding: EdgeInsets.fromLTRB(0, 14.h, 0, 40.h), + children: [ + MilerPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _hero(status, ends, at, late: late, c: c), + _factStrip(c), + ], ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _hero(status, ends, at, late: late, c: c), - _metricLine(c), - _journey(status, ends), - _proofPanel(), - _folded(c), - ], - ), - ), - ], + _journey(status, ends), + _proofPanel(context), + _folded(c), + ], + ), ), ); } + /// The booking reference, trimmed of the routing prefix nobody reads aloud. + /// + /// `DM-BK-BDD9EF53-35734` is what the payload carries; `#BDD9EF53-35734` is + /// what a rider and the office actually say to each other. The full string + /// stays on the folded Order row, with its copy control. + String _reference() { + final id = (stop['orderid'] ?? stop['OrderId'] ?? '').toString().trim(); + if (id.isEmpty) return ''; + final trimmed = id.replaceFirst( + RegExp(r'^DM-BK-', caseSensitive: false), + '', + ); + return '#$trimmed'; + } + + /// When the stop was promised for, when the booking carried a promise. + /// + /// Null is a real answer and the common one: nothing on a milk-run drop is + /// timed to the minute, and a record that invents a deadline is a record that + /// can call an on-time stop late. See [StopCompliance], which follows the + /// same rule with the flag it derives from this. + DateTime? _promisedAt() { + for (final k in const [ + 'expected_pickup_time', + 'expecteddeliverytime', + 'promisedat', + 'eta_time', + ]) { + final raw = (stop[k] ?? '').toString().trim(); + if (raw.isEmpty) continue; + final t = parseStamp(raw); + if (t != null) return t; + } + return null; + } + /// ── Who, what and when, in one second ── /// /// The verdict used to be a 24sp status beside a disc, with the route as an @@ -132,52 +214,131 @@ class DeliveryDetailsPage extends StatelessWidget { final note = _outcomeNote(status); final hasTime = at.millisecondsSinceEpoch > 0; + // ── Ranked left, not stacked down the middle ── + // + // Every line was centred: mark, verdict, name, clock, route, note. Centred + // text has no common left edge, so the eye restarts on each line and the + // block reads as a receipt printed by a machine rather than as a record + // with a subject. It is also the reason the page felt empty — six centred + // lines in a full-width panel leave two big triangles of nothing either + // side of them. + // + // The mark leads on the left, the verdict and the person sit against it, + // and the clock takes the corner it was queueing for. One left edge, one + // right, and the same shape as every other identity block in the app. return Column( - crossAxisAlignment: CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - DecoratedBox( - decoration: BoxDecoration( - color: mark.withValues(alpha: 0.12), - shape: BoxShape.circle, - ), - child: SizedBox( - width: 52.w, - height: 52.w, - child: Icon(status.icon, size: 24.sp, color: mark), - ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: mark.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: SizedBox( + width: 46.w, + height: 46.w, + child: Icon(status.icon, size: 22.sp, color: mark), + ), + ), + SizedBox(width: 13.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + status.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.body.copyWith( + fontSize: 23.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.6, + height: 1.15, + color: mark, + ), + ), + SizedBox(height: 1.h), + // ── The person, as a caption to the verdict ── + // + // Both lines were headline weight, so the panel had two + // titles arguing about which one the page was about. The + // verdict is what the page answers; the person is who it + // happened to, and the preposition is what turns a second + // heading into a caption. + Text( + who.isEmpty ? 'Customer' : 'To $who', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: MilerType.micro.copyWith( + fontSize: 13.5.sp, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + if (hasTime) ...[ + SizedBox(width: 10.w), + // ── When it was due, when it happened, and which day ── + // + // The record carried one clock and left the rider to remember + // whether that was good. The promised time sits above the real + // one when the booking carried a promise, so the two can be read + // against each other without arithmetic; the day is under them + // because a record opened a week later has no other way of + // saying it is not today's. + // + // Clamped: a clock is glanced at, and at 2.0x an unclamped one + // takes the width the name needs. + // Flexible and scaled down rather than rigid: three clocks in a + // fixed column beside a flexible name is a right-edge overflow + // waiting for a large text scale, and the sweep caught exactly + // that at 1.3x on a 390. + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: MediaQuery.withClampedTextScaling( + maxScaleFactor: 1.2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + if (_promisedAt() case final due?) ...[ + Text( + 'ETA ${RouteMetricsHelper.formatClock(due)}', + maxLines: 1, + style: MilerType.micro.copyWith(fontSize: 11.sp), + ), + SizedBox(height: 2.h), + ], + Text( + '${status.label} ${RouteMetricsHelper.formatClock(at)}', + maxLines: 1, + style: MilerType.figure( + 13.sp, + color: ColorConstants.slateText, + ).copyWith(fontWeight: FontWeight.w700), + ), + SizedBox(height: 2.h), + Text( + dayLabel(at), + maxLines: 1, + style: MilerType.micro.copyWith(fontSize: 11.sp), + ), + ], + ), + ), + ), + ), + ], + ], ), - SizedBox(height: 12.h), - Text( - status.label, - textAlign: TextAlign.center, - style: MilerType.body.copyWith( - fontSize: 23.sp, - fontWeight: FontWeight.w700, - letterSpacing: -0.6, - color: mark, - ), - ), - SizedBox(height: 3.h), - Text( - who.isEmpty ? 'Customer' : who, - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: MilerType.body.copyWith( - fontSize: 18.sp, - fontWeight: FontWeight.w600, - letterSpacing: -0.3, - ), - ), - if (hasTime) ...[ - SizedBox(height: 2.h), - Text( - RouteMetricsHelper.formatClock(at), - style: MilerType.caption.copyWith(fontSize: 14.sp), - ), - ], if (ends.to != null && ends.from.trim().isNotEmpty) ...[ - SizedBox(height: 10.h), + SizedBox(height: 12.h), // ── The arrow is a glyph, not a character ── // // `→` (U+2192) is not in Poppins. Text set in the app's own family @@ -186,7 +347,6 @@ class DeliveryDetailsPage extends StatelessWidget { // from and where it went. Drawn as an icon it cannot go missing, and // it takes the same grey as the names either side of it. Row( - mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ Flexible( @@ -194,7 +354,6 @@ class DeliveryDetailsPage extends StatelessWidget { ends.from, maxLines: 1, overflow: TextOverflow.ellipsis, - textAlign: TextAlign.right, style: MilerType.micro.copyWith(fontSize: 13.sp), ), ), @@ -221,7 +380,6 @@ class DeliveryDetailsPage extends StatelessWidget { SizedBox(height: 10.h), Text( note ?? 'Arrived later than planned', - textAlign: TextAlign.center, style: MilerType.micro.copyWith( fontSize: 13.sp, fontWeight: FontWeight.w600, @@ -255,15 +413,21 @@ class DeliveryDetailsPage extends StatelessWidget { return to.isEmpty ? null : 'Handed to $to'; } - /// `4.6 km · 27 min · 1 order` — the whole stats strip, as one line. + /// ── The numbers, as tiles a rider can read at a glance ── /// - /// It was three labelled cells in a tinted box: DISTANCE / TOTAL TIME / - /// ORDERS, each with a glyph and an all-caps caption. That is a dashboard - /// widget, and a dashboard is for comparing many things — there is one stop - /// on this page and it is already finished. The numbers still earn a place - /// (a rider does check what a run cost him) but they earn a line, not a - /// panel. - Widget _metricLine(StopCompliance c) { + /// This was `4.6 km · 27 min · 1 order` — one grey centred sentence — and + /// before that it was three labelled cells, removed on the argument that a + /// dashboard is for comparing many things and there is one stop on this page. + /// + /// The argument was about the *panel*, not about the figures, and the + /// sentence overcorrected: the three things a rider checks after a run are + /// values, and values set as prose have to be read rather than seen. They are + /// tiles again — tonal, inside the panel that owns them, no borders — which + /// is the same idiom Account's stat strip and the stop sheet's fact grid + /// already use. + /// + /// Two to a row, because two fit at any text scale and three do not. + Widget _factStrip(StopCompliance c) { final from = stampOf(stop, const [ OrderEvent.accepted, OrderEvent.arrivedAtPickup, @@ -277,21 +441,146 @@ class DeliveryDetailsPage extends StatelessWidget { ]); final took = (from != null && done != null) ? done.difference(from) : null; final parcels = _parcelCount(); + final cash = stopCollectionAmount(stop); + final delivery = stopKindOf(stop).isDelivery; - final parts = [ - if (c.actualKm != null && c.actualKm! > 0) '${kmText(c.actualKm!)} km', - if (took != null && took.inSeconds > 0) shortDuration(took), + final cells = <(String, String, Color?)>[ + if (took != null && took.inSeconds > 0) + ( + shortDuration(took), + delivery ? 'TOTAL DELIVERY TIME' : 'TOTAL TIME', + null, + ), if (parcels != null && parcels > 0) - '$parcels ${parcels == 1 ? 'order' : 'orders'}', + ('$parcels', parcels == 1 ? 'PARCEL' : 'PARCELS', null), + if (c.actualKm != null && c.actualKm! > 0) + ('${kmText(c.actualKm!)} km', 'DISTANCE', null), + if (cash > 0) (rupees(cash), 'COLLECTED', ColorConstants.moneyGreen), ]; - if (parts.isEmpty) return SizedBox(height: 18.h); + + final hub = _hubRow(); + if (cells.isEmpty && hub == null) return SizedBox(height: 2.h); return Padding( - padding: EdgeInsets.only(top: 14.h, bottom: 22.h), - child: Text( - parts.join(' · '), - textAlign: TextAlign.center, - style: MilerType.micro.copyWith(fontSize: 13.sp), + padding: EdgeInsets.only(top: 16.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < cells.length; i += 2) ...[ + if (i > 0) SizedBox(height: 8.h), + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: _fact(cells[i])), + if (i + 1 < cells.length) ...[ + SizedBox(width: 8.w), + Expanded(child: _fact(cells[i + 1])), + ], + ], + ), + ), + ], + if (hub != null) ...[ + if (cells.isNotEmpty) SizedBox(height: 8.h), + hub, + ], + ], + ), + ); + } + + /// ── The hub is a logistics word ── + /// + /// A parcel rider's load goes to a depot, and the moment he reached it is a + /// fact his day is measured on — so the record says it, on its own full-width + /// tile, with the clock the ledger actually stamped. + /// + /// **Not drawn on a milk run.** That line loads at a kitchen and ends at a + /// customer's door: there is no hub in it, and captioning the kitchen arrival + /// "arrived at hub" would be naming a building the rider never went to. The + /// same moment is still on his record — the Journey's own Arrived rung, which + /// names the place it happened at instead of assuming one. + Widget? _hubRow() { + if (!ServiceProfile.active.isLogistics) return null; + final at = stampOf(stop, const [OrderEvent.arrivedAtPickup]); + if (at == null) return null; + + return Container( + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 13.h), + decoration: BoxDecoration( + color: Narrative.inset, + borderRadius: BorderRadius.circular(Narrative.radiusInner), + ), + child: Row( + children: [ + Expanded( + child: Text( + 'ARRIVED AT BASE', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.eyebrow.copyWith( + fontSize: 9.5.sp, + letterSpacing: 0.8, + ), + ), + ), + SizedBox(width: 10.w), + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Text( + clockOf(at), + maxLines: 1, + style: MilerType.figure( + 13.5, + color: ColorConstants.slateText, + ).copyWith(fontWeight: FontWeight.w700), + ), + ), + ), + ], + ), + ); + } + + /// One tile: its name in the quiet line, the value in the loud one. + Widget _fact((String, String, Color?) cell) { + final (value, label, tint) = cell; + return Container( + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h), + decoration: BoxDecoration( + color: Narrative.inset, + borderRadius: BorderRadius.circular(Narrative.radiusInner), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.eyebrow.copyWith( + fontSize: 9.5.sp, + letterSpacing: 0.8, + ), + ), + SizedBox(height: 5.h), + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + value, + maxLines: 1, + style: MilerType.figure( + 18, + color: tint ?? ColorConstants.slateText, + ).copyWith(fontWeight: FontWeight.w700, letterSpacing: -0.4), + ), + ), + ], ), ); } @@ -323,18 +612,25 @@ class DeliveryDetailsPage extends StatelessWidget { ]; if (live.isEmpty) return SizedBox(height: 8.h); - return Padding( - padding: EdgeInsets.only(top: 8.h), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (final (i, (title, rows)) in live.indexed) - _DisclosureRow( - title: title, - first: i == 0, - children: rows.whereType().toList(), - ), - ], + // `padded: false`: the disclosure rows draw their own rules, and a rule + // that stops short of the panel's edge reads as a mistake rather than as a + // divider. They take the horizontal inset themselves instead. + return MilerPanel( + padded: false, + gap: 24, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: MilerSurface.panelPad), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final (i, (title, rows)) in live.indexed) + _DisclosureRow( + title: title, + last: i == live.length - 1, + children: rows.whereType().toList(), + ), + ], + ), ), ); } @@ -350,28 +646,89 @@ class DeliveryDetailsPage extends StatelessWidget { /// Absent when the delivery carries no photo — an empty frame saying "no /// proof" on every older record would be furniture, and the delivery info /// panel already states what happened. - Widget _proofPanel() { + Widget _proofPanel(BuildContext context) { final path = (stop['proofphotopath'] ?? '').toString().trim(); if (path.isEmpty) return const SizedBox.shrink(); final file = File(path); if (!file.existsSync()) return const SizedBox.shrink(); - return Padding( - padding: EdgeInsets.only(top: 8.h), + return MilerPanel( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _sectionLabel('Proof of delivery'), + Padding( + padding: EdgeInsets.only(bottom: 12.h), + child: Text( + 'Proof of delivery', + style: MilerType.body.copyWith( + fontSize: 16.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + ), + ), ClipRRect( - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + borderRadius: BorderRadius.circular(Narrative.radiusInner), child: AspectRatio( aspectRatio: 4 / 3, - child: Image.file( - file, - fit: BoxFit.cover, - // A file that has gone is not proof; the panel disappears - // rather than drawing a broken frame in its place. - errorBuilder: (_, _, _) => const SizedBox.shrink(), + child: Stack( + fit: StackFit.expand, + children: [ + Image.file( + file, + fit: BoxFit.cover, + // A file that has gone is not proof; the panel disappears + // rather than drawing a broken frame in its place. + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), + // ── The photograph opens ── + // + // A 4:3 crop inside a panel is enough to see *that* a photo + // was taken and not enough to settle an argument about what + // is in it. The control sits on the image, bottom-right, + // over a scrim so it reads on a dark hallway and on a white + // doorstep alike. + Positioned( + right: 8.w, + bottom: 8.h, + child: Material( + color: Colors.black.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular( + DesignConstants.radiusFull, + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => _openProof(context, file), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 11.w, + vertical: 7.h, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.maximize2, + size: 12.sp, + color: ColorConstants.onAccent, + ), + SizedBox(width: 6.w), + Text( + 'View full', + style: TextStyle( + fontSize: 11.5.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.onAccent, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ), + ), + ), + ], ), ), ), @@ -397,12 +754,48 @@ class DeliveryDetailsPage extends StatelessWidget { ), ], ), - SizedBox(height: 14.h), ], ), ); } + /// The photograph, full-bleed on a dark ground, pinchable. + /// + /// A dialog rather than a page: it is a look, not a place — dismissed by a + /// tap anywhere, with nothing to navigate back from. + void _openProof(BuildContext context, File file) { + showDialog( + context: context, + barrierColor: Colors.black.withValues(alpha: 0.92), + builder: (ctx) => GestureDetector( + onTap: () => Navigator.pop(ctx), + child: Stack( + children: [ + Center( + child: InteractiveViewer( + maxScale: 5, + child: Image.file(file, fit: BoxFit.contain), + ), + ), + Positioned( + top: MediaQuery.of(ctx).padding.top + 8, + right: 8, + child: IconButton( + tooltip: 'Close', + onPressed: () => Navigator.pop(ctx), + icon: Icon( + LucideIcons.x, + color: ColorConstants.onAccent, + size: 22.sp, + ), + ), + ), + ], + ), + ), + ); + } + // ══════════════════════════════════════════════════════════════════════ // 2. THE TIMELINE — the journey, newest first // ══════════════════════════════════════════════════════════════════════ @@ -630,249 +1023,9 @@ class DeliveryDetailsPage extends StatelessWidget { if (points.isEmpty) return const SizedBox.shrink(); - // ── The outcome is last, and it is the loud one ── - // - // Reading downwards, the eye finishes on the bottom rung, which is where - // the outcome now is. That is the one the record is *about*, so it takes - // the verdict's colour and a wider disc; everything above it is a step that - // got him there and is drawn as done rather than as important. - final int leadIndex = points.length - 1; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _sectionLabel('Journey'), - for (var i = 0; i < points.length; i++) - _rung( - points[i], - colour: i == leadIndex ? status.color : ColorConstants.acceptGreen, - lead: i == leadIndex, - first: i == 0, - last: i == points.length - 1, - ), - SizedBox(height: 26.h), - ], - ); + return _JourneyPanel(points: points, status: status); } - Widget _rung( - _TrackPoint point, { - required Color colour, - required bool lead, - required bool first, - required bool last, - }) { - final bool reached = point.at != null; - - return IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // ── The clock column ── - SizedBox( - // Wide enough for `11:42 AM` at the largest text scale the app - // supports. It was 62 and `maxLines: 1` with no overflow, so the - // two-digit-hour afternoon times silently lost their `AM`/`PM` — - // a clipped clock on a record whose whole point is when things - // happened. - width: 76.w, - child: Padding( - padding: EdgeInsets.only(top: 3.h, right: 10.w), - child: Text( - point.at == null ? '' : clockOf(point.at!), - maxLines: 1, - overflow: TextOverflow.visible, - softWrap: false, - textAlign: TextAlign.right, - style: - MilerType.figure( - 13, - color: lead - ? ColorConstants.slateText - : ColorConstants.secondaryText, - ).copyWith( - fontWeight: lead ? FontWeight.w700 : FontWeight.w600, - letterSpacing: 0, - ), - ), - ), - ), - // ── The rail ── - SizedBox( - width: 30.w, - child: Column( - children: [ - SizedBox( - height: _nodeBox, - child: Stack( - alignment: Alignment.center, - children: [ - // Drawn behind the node in two halves, so the line passes - // through rather than stopping either side of every disc. - Positioned.fill( - child: Column( - children: [ - Expanded( - child: Center( - child: first - ? const SizedBox.shrink() - : _rail(), - ), - ), - Expanded( - child: Center( - child: last ? const SizedBox.shrink() : _rail(), - ), - ), - ], - ), - ), - if (reached) - Container( - width: lead ? 28.w : 24.w, - height: lead ? 28.w : 24.w, - decoration: BoxDecoration( - color: colour, - shape: BoxShape.circle, - border: Border.all( - color: ColorConstants.pureSurface, - width: 2, - ), - boxShadow: lead - ? [ - BoxShadow( - color: colour.withValues(alpha: 0.28), - blurRadius: 10, - spreadRadius: 1, - ), - ] - : null, - ), - child: Icon( - point.icon, - size: lead ? 16.sp : 13.sp, - color: Colors.white, - ), - ) - else - // Never reached. An open ring rather than a filled disc: - // it must not read as a step that is merely pending on a - // record that is already closed. - Container( - width: 22.w, - height: 22.w, - decoration: BoxDecoration( - color: ColorConstants.pureSurface, - shape: BoxShape.circle, - border: Border.all( - color: ColorConstants.borderStrong, - width: 2, - ), - ), - ), - ], - ), - ), - if (!last) Expanded(child: Center(child: _rail())), - ], - ), - ), - SizedBox(width: 12.w), - Expanded( - child: Padding( - // Bottom padding rather than a gap between rungs: the connector - // runs *through* the space, so it belongs to the rung. - padding: EdgeInsets.only(bottom: last ? 0 : 22.h), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ── The place leads its own rung ── - // - // This is what the separate ROUTE section used to carry. On - // the two rungs that happened somewhere, the somewhere is - // the heading and the act is the line under it — which is - // how the rider thinks it: *at Vidhya Kitchen, I picked up*. - if (point.place != null && - point.place!.trim().isNotEmpty) ...[ - Text( - point.place!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: MilerType.body.copyWith( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - letterSpacing: -0.3, - ), - ), - if (point.address != null && - point.address!.trim().isNotEmpty) ...[ - SizedBox(height: 2.h), - Text( - point.address!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: MilerType.micro.copyWith( - fontSize: 12.5.sp, - height: 1.35, - ), - ), - ], - SizedBox(height: 5.h), - ], - Text( - point.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: MilerType.body.copyWith( - // A rung that names a place has already had its heading; - // the act under it is a normal line. Bold everywhere is - // why this page read heavy. - fontSize: point.place != null - ? 14.sp - : (lead ? 16.sp : 15.sp), - fontWeight: point.place != null - ? FontWeight.w500 - : FontWeight.w600, - letterSpacing: -0.2, - color: reached - ? (point.place != null - ? ColorConstants.secondaryText - : ColorConstants.slateText) - : ColorConstants.secondaryText, - ), - ), - if (point.note != null) ...[ - SizedBox(height: 4.h), - Text( - point.note!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: MilerType.micro.copyWith( - fontSize: 13.sp, - height: 1.4, - ), - ), - ], - ], - ), - ), - ), - ], - ), - ); - } - - /// The height a node sits in, at the top of a rung. - /// - /// Tall enough to centre on the first line of a title at every text scale the - /// app supports, and the same on the timeline and the route so the two rails - /// read as one idiom rather than two that nearly line up. - static double get _nodeBox => 34.h; - - /// One length of the rail. Furniture: it says "these are one journey, in - /// order", and must not compete with the marks strung on it. - Widget _rail() => Container(width: 2, color: ColorConstants.borderSubtle); - // ══════════════════════════════════════════════════════════════════════ // 3. ROUTE — the two ends, with their addresses // ══════════════════════════════════════════════════════════════════════ @@ -1226,48 +1379,10 @@ class DeliveryDetailsPage extends StatelessWidget { // SHARED FURNITURE // ══════════════════════════════════════════════════════════════════════ - /// ── A section head, with a mark ── - /// - /// Five grey eyebrows down a page all look like the same word, and the eye - /// stops using them to navigate. A 3pt brand-coloured rule in front of each - /// one gives the run of sections a repeating left mark to jump between — the - /// cheapest possible signpost, and the only place on this page the brand - /// colour appears outside a control. - Widget _sectionLabel(String title) => Padding( - padding: EdgeInsets.only(bottom: 14.h), - child: Row( - children: [ - Container( - width: 3.w, - height: 13.h, - decoration: BoxDecoration( - color: ColorConstants.primary, - borderRadius: BorderRadius.circular(2), - ), - ), - SizedBox(width: 8.w), - // Flexible: a letterspaced eyebrow at 2.0x system text is wider than - // a 320pt panel minus its rim, and a rigid Row pays that in an - // overflow stripe rather than an ellipsis. - Flexible( - child: Text( - title.toUpperCase(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: MilerType.eyebrow.copyWith( - fontSize: 11.5.sp, - letterSpacing: 1.0, - ), - ), - ), - ], - ), - ); - /// `Distance 4.6 km` /// - /// Returns null for an unknown value so [_section] can drop a whole group - /// that turned out to have nothing in it. + /// Returns null for an unknown value so the group can be dropped whole when + /// it turns out to have nothing in it. Widget? _infoRow(String label, String? value, {bool? good}) { if (value == null || value.trim().isEmpty) return null; return _InfoRow(label: label, value: value, good: good); @@ -1301,7 +1416,7 @@ class _InfoRow extends StatelessWidget { final Color valueColor = good == null ? ColorConstants.slateText : good! - ? ColorConstants.acceptGreen + ? Narrative.emerald : ColorConstants.warning; return Padding( @@ -1406,6 +1521,13 @@ class _TrackPoint { this.place, this.address, }); + + /// The strip's word for this rung: the first word of the title, capitalised. + /// + /// `Out for delivery` → **OUT**, `Picked up` → **PICKED**. One word is all a + /// mark on a shared line can carry, and the first is the one that identifies + /// the act — the full sentence is a tap away in the log. + String get shortTitle => title.split(' ').first.toUpperCase(); } /// One folded group of facts — `Payment ›`, opening to its rows. @@ -1424,14 +1546,15 @@ class _DisclosureRow extends StatefulWidget { final String title; final List children; - /// The first row draws its own top rule, so the group reads as a block - /// rather than as three things that happen to be adjacent. - final bool first; + /// The last row draws no rule under it: the panel's own edge closes the + /// group, and a hairline sitting a pixel above it is the doubled-up border + /// this app keeps pulling out of its surfaces. + final bool last; const _DisclosureRow({ required this.title, required this.children, - required this.first, + required this.last, }); @override @@ -1446,8 +1569,6 @@ class _DisclosureRowState extends State<_DisclosureRow> { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (widget.first) - Divider(height: 1, thickness: 1, color: ColorConstants.borderSubtle), Material( color: Colors.transparent, child: InkWell( @@ -1497,7 +1618,612 @@ class _DisclosureRowState extends State<_DisclosureRow> { : const SizedBox(width: double.infinity, height: 0), ), ), - Divider(height: 1, thickness: 1, color: ColorConstants.borderSubtle), + if (!widget.last) + Divider(height: 1, thickness: 1, color: ColorConstants.borderSubtle), + ], + ); + } +} + +/// ───────────────────────────────────────────────────────────────────────── +/// THE JOURNEY — a strip you read in one look, and a log you open +/// +/// The whole ladder used to be drawn out vertically, always: six rungs, each +/// with a clock column, a rail, a title and a note. It is the honest picture of +/// a stop and it is also two-thirds of the page, on a screen a rider opens to +/// check one thing. +/// +/// So the ladder is stated twice, at two ranks. The **strip** is the shape of +/// the journey — four or five marks on one line, each with its word and its +/// clock — which answers "did that run cleanly" without scrolling. **View full +/// log** opens the same rungs underneath, with the places, the addresses and +/// the gaps between them, for the day somebody is asking about one. +/// +/// Nothing is dropped between the two: the strip draws exactly the rungs the +/// log does, in the same order, from the same stamps. +/// ───────────────────────────────────────────────────────────────────────── +class _JourneyPanel extends StatefulWidget { + final List<_TrackPoint> points; + final StopStatus status; + + const _JourneyPanel({required this.points, required this.status}); + + @override + State<_JourneyPanel> createState() => _JourneyPanelState(); +} + +class _JourneyPanelState extends State<_JourneyPanel> { + bool _open = false; + + @override + Widget build(BuildContext context) { + final points = widget.points; + final status = widget.status; + // ── The outcome is last, and it is the loud one ── + // + // Reading downwards, the eye finishes on the bottom rung, which is where + // the outcome now is. That is the one the record is *about*, so it takes + // the verdict's colour and a wider disc; everything above it is a step that + // got him there and is drawn as done rather than as important. + final int leadIndex = points.length - 1; + + return MilerPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Journey', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.body.copyWith( + fontSize: 16.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + ), + ), + if (points.length > 1) + // Scaled down rather than rigid: a link beside a heading is + // the classic pair that fits on a 390 and overflows a 320 at + // 2.0x, and this one is on the panel a rider opens *because* + // he has his font turned up. + // ── It sits ON the corner, not near it ── + // + // Measured twice. First the link's right edge and the title's + // left edge were both 29 in from the page — symmetric, which + // is what made it read as *another line of text* that happens + // to be right-aligned rather than as a control. Pulling it to + // 19 broke the text column but left it floating six points + // inside the panel's own edge, which is the worst of both: too + // far in to be a corner, too far out to be the column. + // + // It takes the whole of the panel's padding back now, so its + // right edge lands on the panel's edge and the gap is gone. + // The glyphs keep a point or two of their own side bearing, + // which is the only breathing room a corner control needs. + // + // Translated rather than given a negative margin, which does + // not exist: hit-testing moves with it, so the tap target goes + // where the glyphs do. + // ── Not `Flexible`, and that was the gap ── + // + // The title beside it is `Expanded`, and two flex children + // split the row's free width between them: the link was sized + // to *half the row*, right-aligned inside that half, and what + // looked like a margin was the other half sitting empty. + // + // Inflexible, `RenderFlex` gives it its own width first and + // hands the remainder to the title — so it ends where its + // glyphs end. Bounded, so a 2.0x text scale scales it down + // instead of overflowing the panel. + ConstrainedBox( + constraints: BoxConstraints(maxWidth: 150.w), + child: Transform.translate( + offset: const Offset(2, 0), + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Semantics( + button: true, + child: InkWell( + onTap: () => setState(() => _open = !_open), + borderRadius: BorderRadius.circular( + DesignConstants.radiusLg, + ), + child: Padding( + // No right padding: the link closes the panel's own + // right edge, the way the title opens its left. Six + // points of inset on a control meant to sit on a + // corner is what makes it read as floating near one. + padding: EdgeInsets.fromLTRB(10.w, 4.h, 0, 4.h), + child: Text( + _open ? 'Hide full log' : 'View full log', + maxLines: 1, + style: MilerType.body.copyWith( + fontSize: 12.5.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.1, + color: ColorConstants.primary, + ), + ), + ), + ), + ), + ), + ), + ), + ], + ), + SizedBox(height: 16.h), + _Stepper(points: points, status: status), + ClipRect( + child: AnimatedSize( + duration: DesignConstants.motionState, + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: !_open + ? const SizedBox(width: double.infinity, height: 0) + : Padding( + padding: EdgeInsets.only(top: 20.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < points.length; i++) + _rung( + points[i], + colour: i == leadIndex + ? status.color + : Narrative.emerald, + lead: i == leadIndex, + first: i == 0, + last: i == points.length - 1, + ), + ], + ), + ), + ), + ), + SizedBox(height: 16.h), + _outcomeCard(points[leadIndex], status), + ], + ), + ); + } + + /// ── Where it ended, said in full ── + /// + /// The strip above can only afford one word per mark, and the word the rider + /// came for — *where* the parcel actually went — is the one that will not fit + /// in it. So the last rung is restated underneath at full width: the outcome, + /// the door it happened at, and the one duration that says how the last leg + /// went. + Widget _outcomeCard(_TrackPoint last, StopStatus status) { + final place = (last.place ?? '').trim(); + final address = (last.address ?? '').trim(); + final mark = last.at == null ? ColorConstants.secondaryText : status.color; + + return Container( + padding: EdgeInsets.symmetric(horizontal: 13.w, vertical: 13.h), + decoration: BoxDecoration( + color: Narrative.inset, + borderRadius: BorderRadius.circular(Narrative.radiusInner), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 30.w, + height: 30.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: mark.withValues(alpha: 0.14), + shape: BoxShape.circle, + ), + child: Icon(last.icon, size: 15.sp, color: mark), + ), + SizedBox(width: 11.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + place.isEmpty ? last.title : '${last.title} to $place', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: MilerType.body.copyWith( + fontSize: 14.5.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + ), + ), + if (address.isNotEmpty) ...[ + SizedBox(height: 3.h), + Text( + address, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: MilerType.micro.copyWith( + fontSize: 12.5.sp, + height: 1.35, + ), + ), + ], + if (last.note != null) ...[ + SizedBox(height: 5.h), + Row( + children: [ + Icon( + LucideIcons.clock, + size: 12.sp, + color: Narrative.emerald, + ), + SizedBox(width: 5.w), + Flexible( + child: Text( + last.note!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.micro.copyWith( + fontSize: 12.sp, + fontWeight: FontWeight.w600, + color: Narrative.emerald, + ), + ), + ), + ], + ), + ], + ], + ), + ), + ], + ), + ); + } + + Widget _rung( + _TrackPoint point, { + required Color colour, + required bool lead, + required bool first, + required bool last, + }) { + final bool reached = point.at != null; + + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ── The clock column ── + SizedBox( + // Wide enough for `11:42 AM` at the largest text scale the app + // supports. It was 62 and `maxLines: 1` with no overflow, so the + // two-digit-hour afternoon times silently lost their `AM`/`PM` — + // a clipped clock on a record whose whole point is when things + // happened. + width: 76.w, + child: Padding( + padding: EdgeInsets.only(top: 3.h, right: 10.w), + child: Text( + point.at == null ? '' : clockOf(point.at!), + maxLines: 1, + overflow: TextOverflow.visible, + softWrap: false, + textAlign: TextAlign.right, + style: + MilerType.figure( + 13, + color: lead + ? ColorConstants.slateText + : ColorConstants.secondaryText, + ).copyWith( + fontWeight: lead ? FontWeight.w700 : FontWeight.w600, + letterSpacing: 0, + ), + ), + ), + ), + // ── The rail ── + SizedBox( + width: 30.w, + child: Column( + children: [ + SizedBox( + height: _nodeBox, + child: Stack( + alignment: Alignment.center, + children: [ + // Drawn behind the node in two halves, so the line passes + // through rather than stopping either side of every disc. + Positioned.fill( + child: Column( + children: [ + Expanded( + child: Center( + child: first + ? const SizedBox.shrink() + : _rail(), + ), + ), + Expanded( + child: Center( + child: last ? const SizedBox.shrink() : _rail(), + ), + ), + ], + ), + ), + if (reached) + Container( + width: lead ? 28.w : 24.w, + height: lead ? 28.w : 24.w, + decoration: BoxDecoration( + color: colour, + shape: BoxShape.circle, + border: Border.all( + color: ColorConstants.pureSurface, + width: 2, + ), + boxShadow: lead + ? [ + BoxShadow( + color: colour.withValues(alpha: 0.28), + blurRadius: 10, + spreadRadius: 1, + ), + ] + : null, + ), + child: Icon( + point.icon, + size: lead ? 16.sp : 13.sp, + color: Colors.white, + ), + ) + else + // Never reached. An open ring rather than a filled disc: + // it must not read as a step that is merely pending on a + // record that is already closed. + Container( + width: 22.w, + height: 22.w, + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + shape: BoxShape.circle, + border: Border.all( + color: ColorConstants.borderStrong, + width: 2, + ), + ), + ), + ], + ), + ), + if (!last) Expanded(child: Center(child: _rail())), + ], + ), + ), + SizedBox(width: 12.w), + Expanded( + child: Padding( + // Bottom padding rather than a gap between rungs: the connector + // runs *through* the space, so it belongs to the rung. + padding: EdgeInsets.only(bottom: last ? 0 : 22.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── The place leads its own rung ── + // + // This is what the separate ROUTE section used to carry. On + // the two rungs that happened somewhere, the somewhere is + // the heading and the act is the line under it — which is + // how the rider thinks it: *at Vidhya Kitchen, I picked up*. + if (point.place != null && + point.place!.trim().isNotEmpty) ...[ + Text( + point.place!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.body.copyWith( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + letterSpacing: -0.3, + ), + ), + if (point.address != null && + point.address!.trim().isNotEmpty) ...[ + SizedBox(height: 2.h), + Text( + point.address!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: MilerType.micro.copyWith( + fontSize: 12.5.sp, + height: 1.35, + ), + ), + ], + SizedBox(height: 5.h), + ], + Text( + point.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: MilerType.body.copyWith( + // A rung that names a place has already had its heading; + // the act under it is a normal line. Bold everywhere is + // why this page read heavy. + fontSize: point.place != null + ? 14.sp + : (lead ? 16.sp : 15.sp), + fontWeight: point.place != null + ? FontWeight.w500 + : FontWeight.w600, + letterSpacing: -0.2, + color: reached + ? (point.place != null + ? ColorConstants.secondaryText + : ColorConstants.slateText) + : ColorConstants.secondaryText, + ), + ), + if (point.note != null) ...[ + SizedBox(height: 4.h), + Text( + point.note!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: MilerType.micro.copyWith( + fontSize: 13.sp, + height: 1.4, + ), + ), + ], + ], + ), + ), + ), + ], + ), + ); + } + + /// The height a node sits in, at the top of a rung. + /// + /// Tall enough to centre on the first line of a title at every text scale the + /// app supports, and the same on the timeline and the route so the two rails + /// read as one idiom rather than two that nearly line up. + static double get _nodeBox => 34.h; + + /// One length of the rail. Furniture: it says "these are one journey, in + /// order", and must not compete with the marks strung on it. + Widget _rail() => Container(width: 2, color: ColorConstants.borderSubtle); +} + +/// The journey on one line: a mark per rung, its word under it, its clock under +/// that, and the thread they are strung on. +class _Stepper extends StatelessWidget { + final List<_TrackPoint> points; + final StopStatus status; + + const _Stepper({required this.points, required this.status}); + + @override + Widget build(BuildContext context) { + final lastReached = points.lastIndexWhere((p) => p.at != null); + final lead = points.length - 1; + + return LayoutBuilder( + builder: (context, box) { + final cell = box.maxWidth / points.length; + final half = cell / 2; + // The thread runs between the first and last marks, never out past + // them: a line that starts at the panel's edge reads as a journey that + // began somewhere off the page. + final travelled = lastReached <= 0 ? 0.0 : lastReached * cell; + + return Stack( + children: [ + Positioned( + left: half, + top: 12.h, + width: (box.maxWidth - cell).clamp(0.0, double.infinity), + height: 2, + child: DecoratedBox( + decoration: BoxDecoration( + color: ColorConstants.borderStrong, + borderRadius: BorderRadius.circular( + DesignConstants.radiusFull, + ), + ), + ), + ), + if (travelled > 0) + Positioned( + left: half, + top: 12.h, + width: travelled, + height: 2, + child: DecoratedBox( + decoration: BoxDecoration( + color: Narrative.emerald, + borderRadius: BorderRadius.circular( + DesignConstants.radiusFull, + ), + ), + ), + ), + Row( + children: [ + for (var i = 0; i < points.length; i++) + Expanded(child: _mark(points[i], i == lead)), + ], + ), + ], + ); + }, + ); + } + + Widget _mark(_TrackPoint p, bool lead) { + final reached = p.at != null; + final colour = !reached + ? ColorConstants.borderStrong + : lead + ? status.color + : Narrative.emerald; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 26.w, + height: 26.w, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: reached ? colour : ColorConstants.pureSurface, + border: reached + ? Border.all(color: ColorConstants.pureSurface, width: 2) + : Border.all(color: colour, width: 2), + ), + child: Icon( + p.icon, + size: 13.sp, + color: reached ? ColorConstants.onAccent : colour, + ), + ), + SizedBox(height: 7.h), + Text( + p.shortTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: MilerType.eyebrow.copyWith( + fontSize: 8.5.sp, + letterSpacing: 0.4, + color: lead + ? ColorConstants.slateText + : ColorConstants.secondaryText, + ), + ), + SizedBox(height: 2.h), + Text( + p.at == null ? '—' : shortClockOf(p.at!), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: MilerType.figure( + 11, + color: lead + ? ColorConstants.slateText + : ColorConstants.secondaryText, + ).copyWith(fontWeight: lead ? FontWeight.w700 : FontWeight.w600), + ), ], ); } diff --git a/lib/views/Dashboard/home/duty_sheet.dart b/lib/views/Dashboard/home/duty_sheet.dart index fce209b..b81b956 100644 --- a/lib/views/Dashboard/home/duty_sheet.dart +++ b/lib/views/Dashboard/home/duty_sheet.dart @@ -107,13 +107,19 @@ class DutySheet extends StatelessWidget { // One sentence. The off-duty version carries the fact that used // to be discovered only after a failed slide. + // + // Both strings were blanked at some point and the sheet shipped as a + // question with two buttons and no consequence — which is the one + // thing this rewrite existed to keep. `duty_sheet_test` names all + // three facts it must contain, so they are restored to what that + // test has always asked for. Text( goingOnline - ? 'Your hub can add you to the next slot, and live ' - '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.', + ? 'Your office can start assigning you trips, and live tracking ' + 'runs while you are on duty.' + : 'Your office stops assigning you trips and live tracking pauses. ' + 'A stop you have already started has to be finished ' + 'first.', style: TextStyle( fontSize: 14.5.sp, height: 1.45, diff --git a/lib/views/Dashboard/home/home_header.dart b/lib/views/Dashboard/home/home_header.dart new file mode 100644 index 0000000..ecc7842 --- /dev/null +++ b/lib/views/Dashboard/home/home_header.dart @@ -0,0 +1,616 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:lucide_icons_flutter/lucide_icons.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'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE HOME HEADER — identity, duty, and the trip being worked +/// +/// ── What was wrong with the last one ── +/// +/// It was a fixed block above the scroll view — the Column's first child — and +/// it never went away. Everything it carried was therefore charged at every +/// scroll position, and it carried a lot: a 44pt avatar, an eyebrow reading +/// DOORMILE DELIVERY, "Hi, Rajan", a 34pt two-word duty switch, and the trip +/// selector inside a translucent rounded track of its own. Five objects +/// competing, none of them the work. +/// +/// Two of them were also answering questions the app answers elsewhere. The +/// avatar duplicated the Account tab in the bottom bar. The trip's `33 of 50 +/// done` was printed again, larger, in the run card immediately below it. +/// +/// The cost was the only thing that mattered: on a 390×844 phone the rider's +/// first actual stop began below the fold. +/// +/// ── What this is ── +/// +/// One pinned sliver with two states and a short cross-fade between them. +/// +/// ``` +/// EXPANDED (~166dp of content, plus the status bar) +/// ┌──────────────────────────────────────────────────┐ +/// │ DOORMILE ● ON │ +/// │ │ +/// │ Good afternoon, Rajan │ +/// │ 17 stops remaining │ +/// │ │ +/// │ TRIP 1 TRIP 2 TRIP 3 │ +/// │ 33 / 50 Not set Not set │ +/// │ ━━━━━━━ │ +/// └──────────────────────────────────────────────────┘ +/// +/// COLLAPSED (52dp of content, plus the status bar) +/// ┌──────────────────────────────────────────────────┐ +/// │ Trip 1 · 17 left · 33/50 ● ON │ +/// └──────────────────────────────────────────────────┘ +/// ``` +/// +/// The greeting and the wordmark are the decoration, so they are what goes. +/// What survives the collapse is what a rider glancing at a mounted phone +/// needs: which trip, how much is left, and whether he is on duty. +/// +/// ── Why a `SliverPersistentHeader` and not a `SliverAppBar` ── +/// +/// `SliverAppBar` gives a `FlexibleSpaceBar`, which interpolates *one* piece of +/// content between two positions and scales it. This header does not scale +/// anything: it has two different layouts and swaps one for the other. A +/// persistent-header delegate hands over `shrinkOffset` directly, which is +/// exactly the input a cross-fade needs, and it is native — no package was +/// added for any of this. +/// ───────────────────────────────────────────────────────────────────────── +class HomeHeaderDelegate extends SliverPersistentHeaderDelegate { + /// The status-bar inset. Passed in rather than read from a `MediaQuery`, + /// because [minExtent] and [maxExtent] are called without a context. + final double topInset; + + /// `Good afternoon, Rajan` — composed by the page, which owns the clock. + final String greeting; + + /// `17 stops remaining`, or empty when there is no run to count. + /// + /// **Not drawn in the expanded state** — see [_ExpandedBody]. It survives + /// because the page folds it into [collapsedSummary] for the collapsed line, + /// where the brief card is no longer on screen to carry the figure. + final String subtitle; + + final bool onDuty; + + /// Duty cannot be changed mid-pickup. The control dims and locks rather than + /// disappearing, so the state stays readable. + final bool dutyLocked; + + /// A duty write is in flight. The control shows it and refuses a second + /// press; the page's own confirmation flow is untouched. + final bool dutyBusy; + + final VoidCallback onToggleDuty; + + /// The collapsed line's operational summary — `Trip 1 · 17 left · 33/50`. + /// Composed by the page from the same figures the selector draws, so the two + /// states cannot disagree. + final String collapsedSummary; + + /// The expanded content's height for this context's text scale — see + /// [expandedContentFor]. Passed in for the same reason as [topInset]: the + /// extent getters have no context to measure with. + final double expandedContent; + + const HomeHeaderDelegate({ + required this.topInset, + required this.expandedContent, + required this.greeting, + required this.subtitle, + required this.onDuty, + required this.dutyLocked, + required this.dutyBusy, + required this.onToggleDuty, + required this.collapsedSummary, + }); + + /// The collapsed row's own height, without the status bar. + static double get collapsedContent => 52.h; + + /// The header's own surface. Keyed so a test can measure *this* rather than + /// the `Scaffold`'s Material, which is the whole screen. + static const Key surfaceKey = ValueKey('home_header_surface'); + + /// The expanded content's height, without the status bar. + /// + /// ── Why this is measured rather than hardcoded ── + /// + /// A sliver header must declare a fixed extent, so anything short of the + /// content is a silent clip. The greeting is type that grows with the system + /// font, so the extent has to grow with it or a rider at 1.5× loses the + /// bottom of the header. + /// + /// The `N stops remaining` line was charged here too until it was removed as + /// a duplicate of the brief card's own headline — which is why this is 20-odd + /// points cheaper than it was, and why the budget test moved with it. + /// + /// Scaling is clamped at 1.3× inside the header (see [build]) for the same + /// reason the run card clamps it: these are figures and labels read at a + /// glance, not prose, and an unclamped 2.0× turns a 166pt header into a + /// 240pt one that pushes the work off the screen entirely — which is the + /// problem this redesign exists to fix. + static double expandedContentFor(BuildContext context) { + final scale = MediaQuery.textScalerOf(context).scale(1).clamp(1.0, 1.3); + // ── Ceiled, because text does not lay out in fractions of a pixel ── + // + // `fontSize * height` is the line box in theory; the engine rounds font + // metrics, so the greeting measures up to a pixel taller than the + // arithmetic says. That slack used to be absorbed by the 4pt gap above the + // `stops remaining` line, and removing that line exposed it — a header + // short by 0.12 to 0.4 of a pixel, which is a `RenderFlex overflowed` + // stripe across Home for a rounding error. + // + // Ceiling the term is the fix rather than adding a fudge constant: it is + // exactly the quantity being rounded, and it cannot drift the way a magic + // `+2` bolted on the end would. + return topPad + + wordmarkRow + + 2.h + + (24.sp * 1.15 * scale).ceilToDouble() + // greeting + bottomPad; + } + + /// The three metrics [_ExpandedBody] lays out against, named here so the + /// measurement above and the layout below cannot drift apart — a sliver + /// header whose extent disagrees with its content is a silent clip. + static double get topPad => 6.h; + + /// The wordmark and duty row. + /// + /// [DutyPill.tapTarget], not the pill's own ~30pt: the row holds the one + /// control up here, and a 48pt target inside a 28pt row is a 28pt target. + /// The eight points this costs came off the paddings either side of it. + static double get wordmarkRow => DutyPill.tapTarget; + static double get bottomPad => 20.h; + + /// Everything the header draws, so the page can measure the fold. + static double heightFor(BuildContext context) => + MediaQuery.paddingOf(context).top + expandedContentFor(context); + + @override + double get minExtent => topInset + collapsedContent; + + @override + double get maxExtent => topInset + expandedContent; + + @override + Widget build(BuildContext context, double shrinkOffset, bool overlaps) { + final range = (maxExtent - minExtent).clamp(1.0, double.infinity); + // 0 fully expanded → 1 fully collapsed. + final t = (shrinkOffset / range).clamp(0.0, 1.0); + + // ── Two fades that do not overlap in the middle ── + // + // Crossing them linearly leaves both layouts half-visible for most of the + // travel, which reads as the header being broken rather than as it + // changing. The expanded content is gone by 60% of the journey and the + // collapsed line does not start until 55%, so there is a beat where the bar + // is just brand — and that beat is what makes the swap read as deliberate. + final expandedOpacity = (1 - t / 0.6).clamp(0.0, 1.0); + final collapsedOpacity = ((t - 0.55) / 0.45).clamp(0.0, 1.0); + + return Material( + key: surfaceKey, + // ── A gradient, and it is nearly not one ── + // + // Two stops about eight per cent apart, top-left to bottom-right. Enough + // that the header reads as a surface with light falling across it rather + // than as a rectangle filled with one hex value; not enough to be seen as + // a gradient, which on a brand ground is the difference between premium + // and 2014. The brand red stays the darker stop, so nothing about the + // colour the app is known by changes. + type: MaterialType.transparency, + // ── The corner, and the case for evaluating it ── + // + // A 20pt bottom radius reads well at rest: the header is an object + // sitting on the page rather than a band painted across the top of it. + // Pinned over scrolling content it reads as a mistake — a rounded lid + // with rows sliding under its corners. + // + // So it is a rounded card when it is at rest and a straight bar when it + // is holding the top of the screen, interpolated on the same `t` as + // everything else. Straight was the alternative considered and it is + // cleaner while scrolling and flatter at rest; this keeps both. + borderRadius: BorderRadius.vertical( + bottom: Radius.circular(20.r * (1 - t)), + ), + clipBehavior: Clip.antiAlias, + child: DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFA51C1C), Color(0xFF8B1A1A)], + ), + ), + child: MediaQuery.withClampedTextScaling( + maxScaleFactor: 1.3, + child: Padding( + padding: EdgeInsets.only(top: topInset), + child: Stack( + fit: StackFit.expand, + children: [ + if (collapsedOpacity > 0) + Align( + alignment: Alignment.topCenter, + child: Opacity( + opacity: collapsedOpacity, + child: SizedBox( + height: collapsedContent, + child: _CollapsedRow( + summary: collapsedSummary, + onDuty: onDuty, + locked: dutyLocked, + busy: dutyBusy, + onTap: onToggleDuty, + ), + ), + ), + ), + if (expandedOpacity > 0) + // ── Laid out at its full height, then clipped ── + // + // This was a plain child of a `StackFit.expand` stack, so it + // was given the header's *current* height — and the header's + // current height is shrinking. The greeting and its subtitle + // are a Column of fixed rows that cannot compress, so from + // the first pixel of scroll the Column was handed less than + // it needs and Flutter drew the yellow-and-black + // `BOTTOM OVERFLOWED BY 28 PIXELS` banner across the header. + // + // The fade already handles the disappearing. What this + // content must do while it fades is *hold its shape and slide + // out from under the top of the screen*, which is what a + // collapsing header looks like — not reflow itself into a + // smaller and smaller box. + // + // `Positioned` with an explicit height opts out of + // `StackFit.expand`: the body is measured at + // [expandedContent] whatever the header currently is, pinned + // to the top, and the overflow is clipped by the Stack (and + // rounded off by the Material above it) instead of being + // reported as a layout error. + Positioned( + top: 0, + left: 0, + right: 0, + height: expandedContent, + // Ignored once it is mostly faded, so a half-visible + // greeting cannot swallow a tap meant for the collapsed + // control under it. + child: IgnorePointer( + ignoring: expandedOpacity < 0.5, + child: Opacity( + opacity: expandedOpacity, + child: _ExpandedBody( + greeting: greeting, + onDuty: onDuty, + dutyLocked: dutyLocked, + dutyBusy: dutyBusy, + onToggleDuty: onToggleDuty, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + /// ── The rebuild gate ── + /// + /// The whole point of listing every field: a row further down the trip + /// changing does not touch any of these, so the header does not rebuild for + /// it. The header carries no collections at all now that the trip selector + /// has moved out from under it, so every comparison here is a scalar. + @override + bool shouldRebuild(HomeHeaderDelegate old) => + old.topInset != topInset || + old.expandedContent != expandedContent || + old.greeting != greeting || + old.subtitle != subtitle || + old.onDuty != onDuty || + old.dutyLocked != dutyLocked || + old.dutyBusy != dutyBusy || + old.collapsedSummary != collapsedSummary; +} + +/// The expanded layout: who, whether he is working, and which trip. +class _ExpandedBody extends StatelessWidget { + final String greeting; + final bool onDuty; + final bool dutyLocked; + final bool dutyBusy; + final VoidCallback onToggleDuty; + const _ExpandedBody({ + required this.greeting, + required this.onDuty, + required this.dutyLocked, + required this.dutyBusy, + required this.onToggleDuty, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.fromLTRB( + 20.w, + HomeHeaderDelegate.topPad, + 16.w, + HomeHeaderDelegate.bottomPad, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: HomeHeaderDelegate.wordmarkRow, + child: Row( + children: [ + // ── The mark, at label size ── + // + // It was `DOORMILE DELIVERY` under a 44pt avatar. The rider + // knows which app he opened; what the wordmark is for is + // telling him whose *shift* this is on a phone that may carry + // two of these. One word, set as a label, is enough for that. + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'DOORMILE', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w800, + letterSpacing: 1.6, + color: Colors.white.withValues(alpha: 0.72), + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + SizedBox(width: 12.w), + DutyPill( + onDuty: onDuty, + locked: dutyLocked, + busy: dutyBusy, + onTap: onToggleDuty, + ), + ], + ), + ), + // Two, not ten. The row above is 48 tall to hold a 48pt tap target + // around a 30pt pill, so it already carries ~17pt of air under the + // wordmark — and a gap stacked on air is ten points of the rider's + // first stop. + SizedBox(height: 2.h), + Text( + greeting, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 24.sp, + height: 1.15, + fontWeight: FontWeight.w700, + letterSpacing: -0.6, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + // ── The `N stops remaining` line used to sit here ── + // + // It was justified as "the one operational line above the fold". It + // stopped being the only one: the brief card immediately below leads + // with `20 stops left` as its headline, at nearly twice the size and + // against a progress bar. So the figure was on screen twice, forty + // points apart, the smaller and greyer of the two first. + // + // The header's job is identity and duty. The count belongs to the + // card that also shows what it is made of — see [TripBriefStrip]. + // + // `subtitle` is kept on the delegate because the page still folds it + // into [collapsedSummary], which is the *only* place that figure + // appears once the header shrinks and the brief has scrolled away. + ], + ), + ); + } +} + +/// The collapsed layout: the operational context, and nothing else. +class _CollapsedRow extends StatelessWidget { + final String summary; + final bool onDuty; + final bool locked; + final bool busy; + final VoidCallback onTap; + + const _CollapsedRow({ + required this.summary, + required this.onDuty, + required this.locked, + required this.busy, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 20.w), + child: Row( + children: [ + Expanded( + // Scale-down rather than ellipsis. This line is `Trip 1 · 17 left · + // 33/50` — three figures — and an ellipsis takes the last one off, + // which is the one a rider is scrolling past the header to check. + // On a 320pt phone it shrinks instead. + child: Align( + alignment: Alignment.centerLeft, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + summary, + maxLines: 1, + style: TextStyle( + fontSize: 14.5.sp, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ), + SizedBox(width: 12.w), + DutyPill(onDuty: onDuty, locked: locked, busy: busy, onTap: onTap), + ], + ), + ); + } +} + +/// ── Duty, as a status rather than as a setting ── +/// +/// It was `AnimatedToggleSwitch.dual`: a 34pt two-position track carrying the +/// words *On duty* and *Off duty* and a sliding knob. That is a settings +/// control — the shape a preferences screen uses — and it was the second +/// loudest object in a header whose subject is the work. +/// +/// What a rider needs from it is an answer, not an affordance: **am I on +/// duty?** So it states the answer, in the smallest form that can carry it, and +/// the tap is how you change it. `● ON` reads at arm's length; the dot is +/// green, which is this palette's only positive-state colour and is spent +/// nowhere else in the header. +/// +/// **Off is not red.** Off duty is not an error, and red on maroon is +/// invisible anyway — it is the same white pill with the dot unlit. +/// +/// The control is visibly ~30pt tall and its target is 48. Nothing about the +/// duty *logic* changed: this calls the page's existing confirm-then-write +/// flow, and refuses a press while one is in flight or while a pickup is live. +class DutyPill extends StatelessWidget { + final bool onDuty; + final bool locked; + final bool busy; + final VoidCallback onTap; + + const DutyPill({ + super.key, + required this.onDuty, + required this.locked, + required this.busy, + required this.onTap, + }); + + /// The accessibility floor, which the visible pill is deliberately under. + static const double tapTarget = 48.0; + + @override + Widget build(BuildContext context) { + final enabled = !locked && !busy; + + return Semantics( + button: true, + enabled: enabled, + toggled: onDuty, + label: locked + ? 'On duty, locked while a pickup is running' + : busy + ? 'Changing duty status' + : onDuty + ? 'On duty. Tap to go off duty.' + : 'Off duty. Tap to go on duty.', + excludeSemantics: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: enabled ? onTap : null, + // The target is the box; the pill inside it is what is drawn. A 30pt + // control with a 30pt target is a control a moving thumb misses. + child: SizedBox( + height: tapTarget, + child: Center( + child: AnimatedOpacity( + duration: DesignConstants.motionState, + opacity: enabled ? 1 : 0.55, + child: Container( + height: 30.h, + padding: EdgeInsets.symmetric(horizontal: 12.w), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: onDuty ? 0.18 : 0.11), + borderRadius: BorderRadius.circular( + DesignConstants.radiusFull, + ), + border: Border.all( + color: Colors.white.withValues(alpha: onDuty ? 0.5 : 0.28), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (busy) + SizedBox( + width: 10.w, + height: 10.w, + child: const CircularProgressIndicator( + strokeWidth: 1.8, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + else if (locked) + Icon(LucideIcons.lock, size: 11.sp, color: Colors.white) + else + AnimatedContainer( + duration: DesignConstants.motionState, + width: 8.w, + height: 8.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: onDuty + ? ColorConstants.acceptGreen + : Colors.white.withValues(alpha: 0.45), + boxShadow: onDuty + ? [ + BoxShadow( + color: ColorConstants.acceptGreen + .withValues(alpha: 0.55), + blurRadius: 6, + ), + ] + : null, + ), + ), + SizedBox(width: 7.w), + Text( + onDuty ? 'ON' : 'OFF', + maxLines: 1, + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w800, + letterSpacing: 0.8, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/views/Dashboard/home/homepage.dart b/lib/views/Dashboard/home/homepage.dart index 0e9751e..2c4ea00 100644 --- a/lib/views/Dashboard/home/homepage.dart +++ b/lib/views/Dashboard/home/homepage.dart @@ -19,6 +19,7 @@ import 'package:miler/providers/pickuplog/pickuplog_provider.dart'; import 'package:miler/views/Dashboard/orders/orderstaus_button.dart'; import 'package:miler/views/helpers/constants/apiconstants.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/miler_surface.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; import 'package:miler/helpers/poll_policy.dart'; import 'package:miler/views/helpers/widgets/page_transitions.dart'; @@ -49,6 +50,7 @@ import 'package:miler/views/Dashboard/home/route_brief.dart'; import 'package:miler/views/Dashboard/home/trip.dart'; import 'package:miler/views/Dashboard/home/trip_card.dart'; import 'package:miler/views/Dashboard/home/stop_detail_sheet.dart'; +import 'package:miler/views/Dashboard/home/home_header.dart'; import 'package:miler/views/Dashboard/home/trip_tabs.dart'; import 'package:miler/views/Dashboard/home/trip_empty_state.dart'; import 'package:miler/views/Dashboard/home/duty_sheet.dart'; @@ -67,6 +69,7 @@ import 'package:miler/data/milk_run.dart'; import 'package:miler/data/order_events.dart'; import 'package:miler/data/work_domain.dart'; import 'package:miler/data/work_repository.dart'; +import 'package:miler/data/pickup_locations.dart'; import 'package:miler/data/service_profile.dart'; import 'package:miler/views/helpers/constants/miler_type.dart'; import 'package:miler/views/Dashboard/home/pickup_preview_sheet.dart'; @@ -118,7 +121,24 @@ class _HomepageState extends State // Slate-tinted daylight grey (#F8FAFC), not the old warm #FCF9F8. The warm // surface sat ~3% from white, so white cards on it had no visible edge in // sunlight — riders reported not being able to tell cards apart. - static Color get _surfaceColor => ColorConstants.daylightSurface; + /// ── Home's ground, on the Narrative ladder ── + /// + /// It read `daylightSurface` — the heavy slate that was right when a card was + /// a bare white fill and the tonal step was the only thing separating the + /// two. Cards carry a hairline and a lift now, so the ground is the brief's + /// light canvas and the two halves of the trade are in the same place. One + /// alias, so Home cannot drift from the other three tabs again. + /// ── The page is white ── + /// + /// It was [MilerSurface.canvas] — #DEE3EA — chosen when Home's rows sat + /// directly on the page and needed a ground to separate from. They do not any + /// more: the run brief and every stop are cards with their own edge and their + /// own lift, so the grey was a second separator under things that already had + /// one, and it made a screen of white cards read as heavy. + /// + /// White, and the cards do the separating. It is also what the brand red + /// needs to look like an accent rather than a fourth surface tone. + static Color get _surfaceColor => ColorConstants.pureSurface; static Color get _cardColor => ColorConstants.pureSurface; static Color _textPrimary = ColorConstants.onSurface; // app-standard near-black @@ -211,6 +231,14 @@ class _HomepageState extends State /// Local only — the parcel backend has no state between "accepted" and /// "picked", and `picked` is the delivery. See the collected store. Set _collectedIds = {}; + + /// Orders the rider has written off today — delivered, cancelled or skipped. + /// + /// The union of the completed and skipped stores, both of which record a + /// mutation only after it came back OK. Held here so Home's counters can ask + /// [WorkBoundary] the same question the Deliveries tab asks, with the same + /// arguments. See [_acceptedWaitingCount]. + Set _closedIds = {}; Set _rejectedIds = {}; /// Which trip tab is open. @@ -223,6 +251,7 @@ class _HomepageState extends State @override void dispose() { WidgetsBinding.instance.removeObserver(this); + BottomPage.currentTab.removeListener(_onTabChanged); final keys = List.from(_pickupTimers.keys); for (final k in keys) { _stopPickupPosting(k); @@ -242,6 +271,11 @@ class _HomepageState extends State _ensureLocationPermission(); _ensureCameraPermission(); _startPolling(); + // What the counters are actually called, joined on the location id the + // booking already carries. One request, cached, and every failure resolves + // to "use the row's own name" — see [PickupLocations]. + unawaited(PickupLocations.ensureLoaded()); + BottomPage.currentTab.addListener(_onTabChanged); WidgetsBinding.instance.addPostFrameCallback((_) { _fetchQueues(); }); @@ -291,18 +325,45 @@ class _HomepageState extends State } // ==================== LOCATION METHODS ==================== + /// The error radius, in metres, of the fix [_getValidCoordinates] last took. + /// Credited to the rider by [_isNearPickupLocation], the same way + /// `PickupsController._checkGeofence` credits its own. + double _lastFixAccuracy = 0; + + /// ── `low` was fine for telemetry and is not fine for a 10 m fence ── + /// + /// This asked for [LocationAccuracy.low] — a ~1 km hint on Android — and + /// fell back to a last-known position of any age. The pair it returns is + /// then used for two different jobs: it rides along on the status payload, + /// where a rough fix costs nothing, and it is measured against the geofence, + /// where a rough fix is not a measurement at all. + /// + /// So it takes a real one while the fence is enforced, and keeps the cheap + /// path when it is not. A cached fix is accepted only if it is fresher than + /// [kGeofenceFixMaxAge] — on a round, a stale one is reliably the previous + /// stop. Future<(String lat, String lng)?> _getValidCoordinates() async { try { await _ensureLocationPermission(); Position? pos; try { pos = await Geolocator.getCurrentPosition( - desiredAccuracy: LocationAccuracy.low, - timeLimit: const Duration(seconds: 3), - ).timeout(const Duration(seconds: 3)); + locationSettings: LocationSettings( + accuracy: kGeofenceEnforced + ? LocationAccuracy.best + : LocationAccuracy.low, + timeLimit: Duration(seconds: kGeofenceEnforced ? 8 : 3), + ), + ); } catch (_) { - pos = await Geolocator.getLastKnownPosition(); + final cached = await Geolocator.getLastKnownPosition(); + final stale = + cached != null && + kGeofenceEnforced && + DateTime.now().difference(cached.timestamp) > kGeofenceFixMaxAge; + pos = stale ? null : cached; } + _lastFixAccuracy = pos?.accuracy ?? 0; if (pos != null) { final lat = pos.latitude.toStringAsFixed(6); final lng = pos.longitude.toStringAsFixed(6); @@ -498,7 +559,7 @@ class _HomepageState extends State title: 'Turn on location', subtitle: 'Bookings and live tracking need your position. Nothing is ' - 'assigned to a rider the hub cannot see.', + 'assigned to a rider your office cannot see.', icon: LucideIcons.mapPinOff, ), SizedBox(height: 20.h), @@ -519,15 +580,15 @@ class _HomepageState extends State Future _isNearPickupLocation(Map Booking) async { // ── The second proximity gate ── // - // `PickupsController._checkGeofence` guards a single stop against the - // rider's configured `pickupradius`. This one is separate: it guards the - // *bulk* "mark selected as arrived" action on Home against a hardcoded - // 500 m, and it runs before the controller is ever called. + // `PickupsController._checkGeofence` guards a single stop. This one guards + // the *bulk* "mark selected as arrived" action on Home, and it runs before + // the controller is ever called. // - // So it has to honour the same switch, or turning the fence off still - // leaves a rider blocked here with a different message and no obvious - // reason why one path let him through and the other did not. That switch is - // currently OFF — see [kGeofenceEnforced] for why and how to restore it. + // It honours the same switch and, since this change, the same **radius**. + // It was hardcoded to 500 m while the controller read a configured 100 — + // so the app had two fences of different sizes on two routes into the same + // rung, and which one a rider met depended on whether he had ticked boxes + // or slid a sheet. One number now: [kGeofenceRadiusMeters]. if (kBypassGeofenceForTesting) { debugPrint( '[GEOFENCE] OFF for bulk arrival — enforcement is disabled in this ' @@ -556,7 +617,11 @@ class _HomepageState extends State pickupLat, pickupLng, ); - return distanceInMeters <= 500; + // The phone's own error is credited to the rider here too — see + // [kGeofenceRadiusMeters] for why a 10 m fence measured with a ±20 m fix + // has to, and `_getValidCoordinates` for the fix it is measured with. + final double slack = _lastFixAccuracy; + return (distanceInMeters - slack) <= kGeofenceRadiusMeters; } catch (e) { debugPrint('[PROXIMITY] Error checking proximity: $e'); return true; @@ -606,7 +671,9 @@ class _HomepageState extends State ), content: Text( specificMessage ?? - 'You must be within 500 meters of the pickup location to mark this Booking as arrived.', + 'You must be within ' + '${kGeofenceRadiusMeters.toStringAsFixed(0)} metres of the ' + 'pickup location to mark this booking as arrived.', style: TextStyle( fontSize: FontConstants.regular(context), fontFamily: FontConstants.fontFamily, @@ -916,6 +983,30 @@ class _HomepageState extends State } } + /// Home's index in the bottom bar. + static const int _homeTabIndex = 0; + + /// Re-reads the day the moment Home becomes the visible tab. + /// + /// ── Why a 30-second poll was not good enough ── + /// + /// Home polls on the shared interval — 30s waiting for the hub, 60s off duty + /// — which is right for work arriving *from* the hub and wrong for work the + /// rider just finished with his own hands on the next tab along. He + /// completed a delivery, watched Deliveries go 22 → 21, tapped Home, and the + /// pill said 22 for up to another half-minute. + /// + /// Deliveries and Activity have both listened for this for a while + /// (`MyPickups._onTabChanged`, `ActivityPage`); Home was the one screen that + /// did not, which is why it was also the one screen showing a stale figure. + void _onTabChanged() { + if (BottomPage.currentTab.value == _homeTabIndex && + mounted && + !_isFetchingQueues) { + _fetchQueues(); + } + } + void _startPolling() { if (!_pollForeground) { _pollerSubscription?.cancel(); @@ -1074,6 +1165,11 @@ class _HomepageState extends State // Stops he has already closed today. Same argument as the skips: the // rider's own record of what he did beats a queue that has not caught up. final completedIds = await getCompletedOrderIds(); + // Kept, because the pill's count needs the same answer the Deliveries + // tab builds its list from — see [_acceptedWaitingCount]. Assigned + // directly rather than through `setState`: this runs inside a fetch that + // already rebuilds, and the field is only ever read during that build. + _closedIds = {...completedIds, ...skippedIds}; // Accepted stops have left the Home list for the Bookings tab, but they // are still on the rider's route — their parcels are still to be @@ -1110,6 +1206,14 @@ class _HomepageState extends State // Stamped both ways here, from the same store Activity reads, so the // progress rail and Activity can never disagree about which stops are // parked. + // ── The rider's own arrivals ── + // + // Read alongside the other local sets and applied with them, because + // `reached` does not persist the rung: without this the queue reports + // `Miler_Assigned` for a stop the rider is standing at and the row drops + // back to ACCEPTED. See [getArrivedOrderIds] for the whole of it. + final arrivedIds = await getArrivedOrderIds(); + for (final s in allStops) { final id = (s['orderid'] ?? '').toString(); if (id.isEmpty) continue; @@ -1124,6 +1228,11 @@ class _HomepageState extends State s['orderstatus'] = 'skipped'; } else if (raw == StopStatus.skipped && acceptedIds.contains(id)) { s['orderstatus'] = 'accepted'; + } else if (arrivedIds.contains(id) && _localArrivalMayShow(raw)) { + // Below the three above on purpose. Arrival is the *weakest* of the + // local records — it is the one the server could not confirm — so + // anything the server does know about this stop outranks it. + s['orderstatus'] = 'arrived'; } } @@ -1213,6 +1322,13 @@ class _HomepageState extends State } } + /// See [HomepageArrivalPrecedence]. Kept as a one-line forwarder so the call + /// site reads the same as it did while the rule itself is reachable from a + /// test — a precedence contract that can only be exercised by building the + /// whole page is a contract nobody checks. + static bool _localArrivalMayShow(StopStatus serverSaid) => + HomepageArrivalPrecedence.localArrivalMayShow(serverSaid); + /// Applies a set of stops to the trip state. Shared by the normal fetch and /// the signed-out demo path. void _applyStops( @@ -1534,6 +1650,21 @@ class _HomepageState extends State _tripStops, collectedIds: _collectedIds, acceptedIds: _acceptedIds, + // ── The set the other screen was passing and this one was not ── + // + // Both screens call [WorkBoundary.deliveryQueue], which was supposed to be + // what stopped them disagreeing. It cannot do that when they hand it + // different arguments: Deliveries passed `closedIds`, Home did not, and a + // delivered stop therefore left one list and stayed in the other's count. + // The rider finished a drop, watched the tab go 22 → 21, and came back to + // Home still being told he had 22 in hand. + // + // It is worse than a stale figure here, because Home *stamps* a closed + // stop `picked` (see the local-records loop in `_fetchBookings`) so its + // row cannot flicker back to LIVE while the queue catches up. `picked` is + // squarely inside the delivery domain, so the pill was counting the stamp + // that was put there to hide it. + closedIds: _closedIds, ).length; /// ── Today's work, as one run ── @@ -1545,7 +1676,7 @@ class _HomepageState extends State /// /// The screen itself is per-trip: the hub assigns work in slots, dispatch /// rings the rider about "the second trip", and [_trips] is that grouping. See - /// [_selectedTrip] and the pinned [TripTabsHeader] in `build`. + /// [_selectedTrip] and the [TripSelector] in the header. Trip get _todayTrip => Trip.fromStops( id: 'today', stops: _tripStops, @@ -1693,12 +1824,18 @@ class _HomepageState extends State /// `PICKED` is the hand-off. It is written to the server — the parcel really /// has been collected, which is exactly what the status means — and recorded /// locally as collected, which is what moves the card to Bookings. - Future _advanceStop( + /// Advances one stop a rung. + /// + /// Returns null when the rung was taken, and **the reason** when the server + /// refused it — see the PICKED branch below. It used to return `void`, so a + /// refused pivot left the stop on Home with nothing on screen to say why and + /// the confirmation sheet reporting success over the top of it. + Future _advanceStop( Map stop, String nextStatus, ) async { final orderId = (stop['orderid'] ?? '').toString(); - if (orderId.isEmpty) return; + if (orderId.isEmpty) return null; try { final dc = Get.find(); @@ -1754,7 +1891,11 @@ class _HomepageState extends State // where it is, on Home, still to be picked up. if (!ok) { debugPrint('[ADVANCE] $orderId -> $nextStatus reported failure'); - if (nextStatus == 'PICKED') return; + // Handing the reason back rather than dropping it: a rider whose + // pickup the hub refused has to be told, or the only thing he can do + // is slide again at a stop that will keep refusing. See + // [PickupsController.lastPickupRefusal]. + if (nextStatus == 'PICKED') return dc.lastPickupRefusal ?? 'refused'; } // ── The rung the SERVER produced, not the one the button is named after ── @@ -1772,15 +1913,38 @@ class _HomepageState extends State // about release". if (nextStatus == 'ARRIVED') { stop['orderstatus'] = 'arrived'; + // ── And it has to survive the refresh this sheet is about to run ── + // + // The line above is a field on an in-memory row. `reached` does not + // persist the rung (request 15), so the `_fetchQueues()` at the foot of + // `_advanceStopWithSheet` rebuilt this row from a server that still + // says `Miler_Assigned` and put it straight back on ACCEPTED — about a + // second after the rider slid the sheet. See [getArrivedOrderIds]. + await addArrivedOrderIds([orderId]); } else { + // ── The rung is PICKED, whatever the pivot released ── + // + // This used to take the rung from `lastPivotConsignmentStatus`, so in + // compatibility mode — `MILER_COLLECTED_STATE_ENABLED` off, where + // `pickup-complete` releases the consignment itself — a collection + // landed on `Out_for_Delivery` and the rider's card jumped from + // ACCEPTED straight to ACTIVE. He never saw PICKED, and the round he + // had not started yet was already on the road. + // + // Which state the *server* is in is still recorded on the row, because + // `deliver` is gated on it and lying about it is how a hand-over gets + // refused at a door. What the rider is shown is his own ladder: + // collected here, released when he presses **Start delivery**. The two + // are allowed to differ — see `MyPickups._needsRelease`, which is the + // other half of this and the reason the difference is safe. final produced = ConsignmentState.values .where((s) => s.name == dc.lastPivotConsignmentStatus) .firstOrNull; - final legacy = produced == null - ? '' - : ApiConfig.legacyStatusFromConsignment(produced.name); - stop['orderstatus'] = legacy.isEmpty ? 'picked' : legacy; + stop['orderstatus'] = 'picked'; if (produced != null) stop['consignmentstatus'] = produced.name; + // Off the arrived rung the moment it is collected, so the local + // record can never outlive the stop it describes. + await removeArrivedOrderIds([orderId]); } // Written at the one moment it is true. Nothing else records these two: @@ -1803,7 +1967,7 @@ class _HomepageState extends State // be able to render this order from a cached response that predates the // hand-over. See [WorkRepository]. unawaited(WorkRepository.instance.invalidate()); - if (!mounted) return; + if (!mounted) return null; setState(() => _collectedIds = {..._collectedIds, orderId}); } @@ -1819,8 +1983,13 @@ class _HomepageState extends State if (nextStatus == 'ARRIVED' && notice != null && mounted) { AppFeedback.infoGlobal(notice); } + return null; } catch (e) { debugPrint('[ADVANCE] $orderId -> $nextStatus failed: $e'); + return nextStatus == 'PICKED' + ? 'Could not record this pickup — check your connection and try ' + 'again.' + : null; } } @@ -1846,7 +2015,11 @@ class _HomepageState extends State ); final rung = switch (state) { StopState.accepted => StopStatus.accepted, - StopState.active => StopStatus.arrived, + // Both, because this answers *which rung is the stop on* and the answer + // is the same for either: he is at the counter and the next thing he does + // is collect. Splitting [StopState.arrived] out of `active` was about the + // word the row prints, not about what the sheet offers next. + StopState.arrived || StopState.active => StopStatus.arrived, _ => null, }; if (rung == null) return; @@ -1884,12 +2057,17 @@ class _HomepageState extends State for (final s in batch) if (!missing.contains(MilkRun.idOf(s))) s, ]; + // The first refusal is the answer for the whole batch: they are one + // counter, one slide and one server call each, so a hub that + // refused the first has refused the event. + String? refusal; for (final s in loaded) { - await _advanceStop( + refusal ??= await _advanceStop( s, rung == StopStatus.accepted ? 'ARRIVED' : 'PICKED', ); } + if (refusal != null) return refusal; if (rung == StopStatus.arrived) { await saveBagLabels({ for (final line in BagManifest.forGroup(loaded)) @@ -1994,7 +2172,10 @@ class _HomepageState extends State if (states.length != 1) return SelectionAction.accept; return switch (states.first) { StopState.accepted => SelectionAction.arrive, - StopState.active => SelectionAction.pickUp, + // He is at the counter — the next thing he does is collect. Offering + // *Accept* here, which is what the missing arrived case produced, sends + // him back two rungs on a stop he is standing at. + StopState.arrived || StopState.active => SelectionAction.pickUp, _ => SelectionAction.accept, }; } @@ -2068,9 +2249,11 @@ class _HomepageState extends State if (!missing.contains(MilkRun.idOf(stop))) stop, ]; + String? refusal; for (final stop in loaded) { - await _advanceStop(stop, action.status); + refusal ??= await _advanceStop(stop, action.status); } + if (refusal != null) return refusal; // ── The pairing is fixed here, and never recomputed ── // // Joe is Bag 1 of the five that left this counter. Deriving that @@ -2189,7 +2372,7 @@ class _HomepageState extends State rejectedIds: _rejectedIds, collectedIds: _collectedIds, ); - if (state == StopState.accepted || state == StopState.active) return true; + if (state == StopState.accepted || state.isAtSource) return true; } return false; } @@ -2227,8 +2410,8 @@ class _HomepageState extends State }); _showStopSnack( stops.length == 1 - ? 'Stop rejected — the hub has been told' - : '${stops.length} stops rejected — the hub has been told', + ? 'Stop rejected — your office has been told' + : '${stops.length} stops rejected — your office has been told', ColorConstants.secondaryText, ); await _fetchQueues(); @@ -2266,8 +2449,8 @@ class _HomepageState extends State ? 'Why are you rejecting this stop?' : 'Why are you rejecting these $count stops?', subtitle: count == 1 - ? 'The hub needs a reason to re-route it to someone else.' - : 'The hub needs a reason to re-route them to someone else.', + ? 'Your office needs a reason to re-route it to someone else.' + : 'Your office needs a reason to re-route them to someone else.', ), SizedBox(height: 14.h), for (final r in reasons) ...[ @@ -2356,8 +2539,8 @@ class _HomepageState extends State BottomPage.goToTab(0); _showStopSnack( taken.length == 1 - ? 'Acceptance undone — the stop is back with the hub' - : '${taken.length} acceptances undone — back with the hub', + ? 'Acceptance undone — the stop is back with your office' + : '${taken.length} acceptances undone — back with your office', ColorConstants.secondaryText, ); await _fetchQueues(); @@ -2717,6 +2900,17 @@ class _HomepageState extends State pickupLat: pickupLat, pickupLng: pickupLng, ); + // ── The bulk path recorded nothing at all ── + // + // Accept hides the row, PICKED writes two stores; ARRIVED wrote + // neither, and this method finishes with `_fetchQueues()`. So a + // successful bulk arrival was a network call, a success count and + // a row that came straight back on ACCEPTED — which is what "mark + // as arrived does nothing" looked like from the outside. + if (success) { + Booking['orderstatus'] = 'arrived'; + await addArrivedOrderIds([orderId]); + } } else if (targetStatus == 'PICKED') { debugPrint('[BULK] Picking Booking $orderId'); success = await dc.updatePickedStatus( @@ -2739,6 +2933,8 @@ class _HomepageState extends State // the app entirely. await removeAcceptedBookings([orderId]); await addCompletedBookings([Booking]); + // Off the arrived rung — it has been collected. + await removeArrivedOrderIds([orderId]); } } } catch (e) { @@ -2790,7 +2986,7 @@ class _HomepageState extends State AppFeedback.error( context, acceptedBookings.isEmpty - ? 'Could not accept — the hub did not confirm it. Try again.' + ? 'Could not accept — your office did not confirm it. Try again.' : '$missed of ${selectedOrderIds.length} could not be accepted — ' 'they are still on Home.', ); @@ -2991,9 +3187,28 @@ class _HomepageState extends State context, stop: stop, stopNumber: trip.stops.indexOf(stop) + 1, + // Home is the one caller that holds the whole route, so it is the one + // that can say `Stop 3 of 8`. + stopCount: trip.stops.length, riderLat: _lastGeocodedPosition?.latitude ?? _riderLat, riderLng: _lastGeocodedPosition?.longitude ?? _riderLng, ), + // ── An order tapped inside a kitchen is about its own door ── + // + // Same sheet, different subject. The counter is shared by every order in + // the group and already has a surface of its own — the preview behind the + // card's third utility, which draws the whole leg to it with the manifest + // waiting there. So the order's sheet points at the customer instead of + // repeating the kitchen five times. See [StopDetailSheet.ofOrder]. + onOrderTap: (stop) => StopDetailSheet.show( + context, + stop: stop, + stopNumber: trip.stops.indexOf(stop) + 1, + stopCount: trip.stops.length, + riderLat: _lastGeocodedPosition?.latitude ?? _riderLat, + riderLng: _lastGeocodedPosition?.longitude ?? _riderLng, + ofOrder: true, + ), onCallStop: (stop) => _callStop(stop), // Setting off, straight from the row. Where it points follows the stop's // stage: the kitchen while it is still to be collected, the customer once @@ -3038,245 +3253,91 @@ class _HomepageState extends State /// read-once reference information, and holding it up here would spend ~44 /// permanent pixels \u2014 on top of the status bar, this bar, the trip tabs and /// the bottom nav \u2014 to keep a static time range on screen. - Widget _buildAppBar() { + /// ── The header's data, assembled ── + /// + /// The header itself is [HomeHeaderDelegate] — a pinned sliver inside the + /// page's own `CustomScrollView`, not a fixed block above it. This composes + /// the strings it draws, from the state the page already holds. No new + /// source, no extra call: `_trips` is the same getter `build` reads and the + /// duty flags are the same fields the old switch read. + SliverPersistentHeader _buildHeaderSliver(BuildContext context) { final String firstName = _userName.isNotEmpty ? _userName.split(' ').first - : "Rider"; + : 'Rider'; - return AnnotatedRegion( - // ── The status bar belongs to the header ── - // - // On a brand ground the clock and the battery have to invert with it, or - // the top 44pt of the phone is dark glyphs on maroon — which is the one - // contrast failure a rider cannot work around by tilting the screen. - value: const SystemUiOverlayStyle( - statusBarColor: Colors.transparent, - statusBarIconBrightness: Brightness.light, - statusBarBrightness: Brightness.dark, - ), - child: Container( - decoration: const BoxDecoration( - // ── A branded header, not a coloured rectangle ── - // - // It wore the same 7% frosted wash as the other three tabs, flattened - // against white. That is right for a tab you pass through; Home is - // the screen the rider *lives* on, and it is the one place the app - // gets to say whose app it is without spending a logo on it. - // - // Solid brand, and everything on it inverts: the greeting, the duty - // word, the switch, the status-bar glyphs. Nothing is tinted — white - // on `#960019` clears AA at every size used here, and a "mostly - // white" header text would be the one thing on the bar that looks - // like a mistake. - color: ColorConstants.primary, - ), - child: SafeArea( - // Inset handled explicitly below via MediaQuery so the status bar - // never overlaps the header, regardless of nested-Scaffold quirks. - top: false, - bottom: false, - child: Padding( - // Top gap went 8 → 18 → 28. At 8 the greeting sat almost against - // the status bar, which was tolerable while the header scrolled - // away but not now that it is pinned there all day — a fixed bar - // with no air above it reads as clipped rather than as a bar. 18 - // was still tight against a taller left-hand avatar, and it has to - // match the inset [MilerAppBar] uses on the other three tabs or the - // headings jump vertically as the rider switches tab. - // - // Bottom inset is the bar's own breathing room rather than a gap - // before the shift row, so it stays tight at 12. - // 28 → 20 above, 12 → 16 below. On white the extra top inset was - // stopping the greeting reading as clipped against the status bar; - // on a solid ground the bar *is* the status bar's surface, so the - // gap above can close and the type sits in a balanced band instead - // of being pushed to its floor. - padding: EdgeInsets.fromLTRB( - 20.w, - MediaQuery.of(context).padding.top + 20.h, - 16.w, - 16.h, - ), - child: Column( - children: [ - // ── Identity row ── - // - // The slot at the left used to hold the Doormile mark, on every - // tab. The logo is gone from all of them now — the rider knows - // which app he opened — and Home is the one screen that has - // something better to put there: him. [MilerAvatar] draws his - // profile photo, or the first letter of his name when he has - // not set one, at exactly the size the mark occupied so the row - // keeps its proportions. - // - // It also gives the greeting a subject: face, then "Hi, Suriya", - // then the duty toggle that governs his shift — one row that is - // entirely about the person using the app. - Row( - children: [ - MilerAvatar(name: _userName, onBrand: true), - SizedBox(width: 12.w), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // ── An eyebrow the greeting can lead ── - // - // "Hi, Suriya" alone was one line floating in a band - // with a switch. Naming the line of work above it - // gives the header a subject and a predicate — this - // is a *shift*, and here is whose — and it is the one - // place the tenant's operation is stated at all. - Text( - ServiceProfile.active.label.toUpperCase(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: MilerType.eyebrow.copyWith( - color: Colors.white.withValues(alpha: 0.72), - ), - ), - SizedBox(height: 3.h), - // Greeting + name is the anchor of the screen, so it - // gets the heaviest type in the header. - Text( - "Hi, $firstName", - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 22.sp, - fontWeight: FontWeight.w700, - height: 1.1, - letterSpacing: -0.5, - color: ColorConstants.onAccent, - fontFamily: FontConstants.fontFamily, - ), - ), - ], - ), - ), - SizedBox(width: 12.w), - _buildStatusToggle(), - ], - ), - ], - ), - ), - ), + final List headerTrips = _trips; + final Trip? current = headerTrips.tripAt(_selectedTripIndex); + + // The one figure the whole shift is steered by, computed once and shown + // once. The run card below used to print the same arithmetic again. + final int total = current?.stopCount ?? 0; + final int done = + current + ?.states(acceptedIds: _acceptedIds, rejectedIds: _rejectedIds) + .where((st) => st.isResolved) + .length ?? + 0; + final int left = (total - done).clamp(0, total); + + final String subtitle = total == 0 + ? (isOnline ? 'Waiting for work' : 'You are off duty') + : left == 0 + ? 'Run complete' + : left == 1 + ? '1 stop remaining' + : '$left stops remaining'; + + // `Trip 1 · 17 left · 33/50` — the collapsed line, from the same three + // figures the expanded tabs draw, so the two states cannot drift. + final String collapsed = total == 0 + ? '${Trip.tabLabel(_selectedTripIndex)} · $subtitle' + : '${Trip.tabLabel(_selectedTripIndex)} · $left left · $done/$total'; + + return SliverPersistentHeader( + pinned: true, + delegate: HomeHeaderDelegate( + topInset: MediaQuery.paddingOf(context).top, + expandedContent: HomeHeaderDelegate.expandedContentFor(context), + greeting: '${_partOfDay()}, $firstName', + subtitle: subtitle, + onDuty: isOnline, + // Unchanged rules, new presentation: duty is locked mid-pickup because + // going off would strand a customer, and refused while a write is in + // flight. + dutyLocked: _hasActivePickup(), + dutyBusy: _isToggling, + // The confirmation sheet still owns the decision — see + // [_confirmOnlineOffline]. Nothing here changes duty by itself. + onToggleDuty: _confirmOnlineOffline, + collapsedSummary: collapsed, ), ); } + /// `Good morning` / `Good afternoon` / `Good evening`. + /// + /// The greeting was `Hi, Rajan` — a word with no information in it. The time + /// of day costs the same line and tells a rider on a split shift which half + /// of it he is in. + String _partOfDay() { + final hour = DateTime.now().hour; + if (hour < 12) return 'Good morning'; + if (hour < 17) return 'Good afternoon'; + return 'Good evening'; + } + /// ── Duty control ── /// - /// A switch, because that is what a boolean the rider flips twice a day is. + /// The presentation lives in [DutyPill] now, in the header file. What was + /// here was `AnimatedToggleSwitch.dual` — a 34pt two-position track carrying + /// the words *On duty* and *Off duty* — which is a settings control, and was + /// the second loudest object in a header whose subject is the work. See the + /// pill for the reasoning; the package it used is no longer imported by this + /// screen. /// - /// It has been three things. First a skeuomorphic switch — gradient thumb, - /// two drop shadows, a glowing pulse dot — whose thumb slid LEFT for on and - /// RIGHT for off, backwards from every switch ever made. Then a single - /// button, which showed one word and left the rider guessing whether it was - /// his state or the thing tapping would do. Then a two-segment ON | OFF - /// control, which answered that but reads as a form input: two boxes of equal - /// weight, neither of which looks like the live thing it controls. - /// - /// This is the Material 3 switch — a 52×32 track whose fill IS the state, a - /// handle that grows and carries a tick when it lands on, and a word beside - /// it so nothing depends on colour alone. It travels left→right into "on" the - /// way every switch the rider owns does, and the whole row is the target, not - /// just the 52pt track. - /// - /// **Locked** while a pickup is live: going off duty mid-stop would strand a - /// customer. The handle takes a padlock and the row dims rather than the - /// control vanishing, so the state stays readable. - Widget _buildStatusToggle() { - final bool locked = _hasActivePickup(); - final bool on = isOnline; - - // ── Both states on screen, all the time ── - // - // This was a hand-rolled M3 switch: a track, a travelling handle, and the - // word for the CURRENT state beside it. Two problems a rider has on a bike. - // A bare switch is a *guess* — "is this the on position or the off one?" — - // answered only by remembering which side means what; and the word changed - // as he pressed it, so the label he was reading and the control he was - // aiming at both moved. - // - // `AnimatedToggleSwitch.dual` (pub.dev, ~2k likes, actively maintained) is - // the shape this control always wanted: a pill holding the state as a word - // AND a knob whose position says the same thing a second time, sliding - // between them. Nothing is inferred from a position alone, and the package - // brings the rolling indicator, the touch-target floor and the disabled - // state with it rather than this file growing its own. - // - // On the brand ground it stays monochrome. Green-on-maroon is the one - // pairing this palette cannot make: the two are close in luminance, so a - // green "on" read as a smudge. **On** is a solid white knob carrying a - // brand tick — the strongest mark available here, and the live state is - // correctly the filled one. **Off** is a hollow white knob on a translucent - // track: unlit, not absent. Neither depends on hue. - return Semantics( - toggled: on, - enabled: !locked, - label: locked - ? 'On duty, locked while a pickup is running' - : on - ? 'On duty. Switch off to stop receiving trips.' - : 'Off duty. Switch on to receive trips.', - excludeSemantics: true, - child: AnimatedToggleSwitch.dual( - current: on, - first: false, - second: true, - // Locked mid-pickup: going off duty would strand a customer. The - // package dims rather than removing it, so the state stays readable. - active: !locked, - inactiveOpacity: 0.55, - height: 34.h, - spacing: 6.w, - borderWidth: 1.5, - indicatorSize: Size.fromWidth(30.w), - animationDuration: const Duration(milliseconds: 260), - animationCurve: Curves.easeOutCubic, - style: ToggleStyle( - backgroundColor: on - ? Colors.white.withValues(alpha: 0.18) - : Colors.white.withValues(alpha: 0.12), - borderColor: Colors.white.withValues(alpha: on ? 0.85 : 0.40), - indicatorColor: Colors.white, - borderRadius: BorderRadius.circular(DesignConstants.radiusFull), - indicatorBorderRadius: BorderRadius.circular( - DesignConstants.radiusFull, - ), - ), - iconBuilder: (value) => Icon( - locked - ? LucideIcons.lock - : value - ? LucideIcons.check - : LucideIcons.pause, - size: 15.sp, - color: ColorConstants.primary, - ), - textBuilder: (value) => Padding( - padding: EdgeInsets.symmetric(horizontal: 2.w), - child: Text( - value ? 'On duty' : 'Off duty', - maxLines: 1, - style: TextStyle( - fontSize: 12.sp, - fontWeight: FontWeight.w700, - letterSpacing: -0.2, - color: Colors.white, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - // The confirmation sheet owns the decision — see - // [_confirmOnlineOffline]. The switch never changes duty by itself. - onChanged: locked ? null : (_) => _confirmOnlineOffline(), - ), - ); - } + /// **None of the duty logic moved.** [_confirmOnlineOffline] still owns the + /// decision, the confirmation sheet still runs, `_isToggling` still gates a + /// second press and `_hasActivePickup()` still locks it mid-stop. /// Shown on a trip tab the hub has not filled yet. /// @@ -3326,7 +3387,7 @@ class _HomepageState extends State // layer-1 surface. Nothing is masked or recoloured; the picture // simply stops having square corners on a page that has none. SizedBox( - width: 280.w, + width: 450.w, child: AspectRatio( // The illustration occupies the top ~75% of the 3:2 asset; // everything below that line is the baked copy being @@ -3360,7 +3421,7 @@ class _HomepageState extends State SizedBox(height: 6.h), Text( 'Stay on duty — new orders appear here the moment ' - 'the hub assigns them.', + 'your office assigns them.', textAlign: TextAlign.center, style: TextStyle( fontSize: 13.5.sp, @@ -3604,277 +3665,273 @@ class _HomepageState extends State backgroundColor: _surfaceColor, body: Stack( children: [ - // ── Pinned bar, scrolling route ── + // ── One scroll view, and the header is a sliver in it ── // - // A Column, not a single scroll view: the app bar is the Column's - // first child and the scrollable takes the rest, which is what fixes - // the bar in place. It was previously the first sliver *inside* the - // scroll view, so it scrolled away with everything else — taking the - // duty toggle with it. - Column( - children: [ - _buildAppBar(), - - // ── Pull to refresh ── - // - // Home was the only tab without this. Bookings, Earnings, Cart and - // Notifications all had a RefreshIndicator; here a pull just - // dragged the whole page — header included — down off the top of - // the screen, held it against a blank surface-coloured gap, and - // sprang back having done nothing. That is not a missing feature - // so much as a broken-looking one: the gesture responded, so it - // read as a refresh that had failed. - // - // Physics is half the fix. `BouncingScrollPhysics` forced the iOS - // rubber-band on every platform, and rubber-band overscroll is - // unbounded — hence the gap in the screenshot. Passing plain - // `AlwaysScrollableScrollPhysics` lets `applyTo` compose it with - // the platform default instead (clamping on Android, bouncing on - // iOS), so the overscroll is bounded where it should be, while - // "always scrollable" keeps the pull working on a short list. - // - // `edgeOffset` is gone with the bar: the scroll view now starts - // below it, so the spinner already comes down into clear space - // instead of out from behind the status bar. - Expanded( - // ── The corner has to have something behind it ── + // It was a `Column`: the header as a fixed first child, the scroll + // view as the second. That pinned the header by construction and + // charged its full height — 214pt with the status bar — at every + // scroll position, which is why the rider's first stop started + // below the fold. + // + // As a pinned sliver it holds the top of the screen the same way, + // and collapses to 52pt of operational context on the way down. + // Native `SliverPersistentHeader`; no package was added for it. + // See [HomeHeaderDelegate]. + AnnotatedRegion( + // ── The status bar belongs to the header ── + // + // On a brand ground the clock and the battery have to invert with + // it, or the top 44pt of the phone is dark glyphs on maroon — + // which is the one contrast failure a rider cannot work around by + // tilting the screen. + value: const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, + ), + child: RefreshIndicator( + onRefresh: _handleRefresh, + color: ColorConstants.primary, + // The spinner comes down below the pinned header rather than + // out from behind it. + edgeOffset: + MediaQuery.paddingOf(context).top + + HomeHeaderDelegate.collapsedContent, + child: CustomScrollView( + // ── Physics, and why it is not `Bouncing` ── // - // The sheet was clipping straight onto the Scaffold, whose - // background is this same `_surfaceColor` — so the two - // rounded corners revealed the page's own colour and the - // curve was invisible. Home read as a hard maroon line above - // a form while the other three tabs had the shape. - // - // The other three get theirs from `backgroundColor: - // primary` on the Scaffold; Home cannot, because its Stack - // paints other things on that ground. So the brand is laid - // directly behind the sheet instead — the same result, scoped - // to the strip that needs it. - child: ColoredBox( - color: ColorConstants.primary, - child: MilerSheet( - color: _surfaceColor, - child: RefreshIndicator( - onRefresh: _handleRefresh, - color: ColorConstants.primary, - child: CustomScrollView( - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - // ══════════════════════════════════════════════════════════ - // READING ORDER - // - // [app bar] fixed - // shift window scrolls away - // trip summary scrolls away (ring, slot, load, metrics) - // ─ TRIP 1 · 2 · 3 ─ PINNED - // hint line scrolls - // HUB → stops → HUB scrolls - // accept bar scrolls - // - // The tab bar is the only thing between the summary and the stops, - // and it is the only pinned sliver. Everything above it — the - // shift window, the completion ring, the duration and distance — - // is reference material the rider reads once and then works from - // memory, so it is allowed to leave. The tabs are a control that - // governs the whole screen, so they stay. See [TripTabsHeader]. - // ══════════════════════════════════════════════════════════ + // `BouncingScrollPhysics` forced the iOS rubber-band on every + // platform, and rubber-band overscroll is unbounded — so a + // pull dragged the whole page off the top of the screen and + // held it against a blank gap. Plain + // `AlwaysScrollableScrollPhysics` lets `applyTo` compose with + // the platform default (clamping on Android, bouncing on + // iOS), while "always scrollable" keeps the pull working on a + // short list. + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + _buildHeaderSliver(context), - // The trip brief used to sit here, above the tabs. It now sits - // below them, inside the per-trip block — see the note at its new - // call site. - // - // The standalone shift-window strip that used to sit above it is - // gone too: it showed the rider's shift while the card below it - // showed the *trip's* slot under the caption "CURRENT SHIFT", so - // two different time ranges sat stacked with one wearing the - // other's name. Both now live inside [TripBriefStrip] as adjacent - // labelled rows. See that class for the rest of the reasoning. - - // ── Trip 1 · 2 · 3, pinned ── - // - // Only shown once the day has at least one trip in it: with - // nothing assigned the bar would be three empty controls above a - // "you have no bookings" panel, which reads as a broken screen - // rather than an idle one. - if (hasWork) - SliverPersistentHeader( - pinned: true, - delegate: TripTabsHeader( - trips: trips, - selectedIndex: _selectedTripIndex, - acceptedIds: _acceptedIds, - rejectedIds: _rejectedIds, - extent: TripTabsHeader.extentFor(context), - // Selection is per-trip, so the ticks do not travel - // with the rider between tabs — a tick made on Trip 1 - // must not be counted by the bar while Trip 2 is on - // screen. See [_selectedPendingIds]. - onSelect: (index) { - if (index == _selectedTripIndex) return; - setState(() { - _selectedTripIndex = index; - _selectedStopIds.clear(); - }); - }, - ), - ), - - // The stops, an empty state for an unassigned slot, or — until - // the first fetch lands — the shape of a route being loaded. - // - // One swap holding all of them, so the hand-off between them is - // a cross-fade instead of one sliver being swapped for another - // and the screen going briefly blank in between. - // - // ── Why the sliver type changes with the state ── - // - // With nothing to list, the message *is* the page, and it - // belongs in the middle of it. A box adapter shrink-wraps its - // child, so a Center inside one only centres within the - // artwork's own height — which parked it under the app bar at - // the top of an otherwise blank screen. [SliverFillRemaining] - // hands it the rest of the viewport to be centred in, and - // `hasScrollBody: false` still lets it grow past that on a - // short phone rather than clipping. - // - // A real route keeps the adapter: filling the remainder there - // would pad every short trip with a screen of dead space above - // the floating-pill floor. - if (!hasWork) - SliverFillRemaining( - hasScrollBody: false, - child: SmoothSwap( - child: !_queuesLoaded - // The real timeline in grey, not three - // generic cards — see [HomeRouteSkeleton]. - ? const HomeRouteSkeleton( - key: ValueKey('home-loading'), - ) - : _fetchFailed - ? Padding( - key: const ValueKey('home-error'), - padding: EdgeInsets.symmetric( - vertical: 32.h, - ), - child: ErrorRetry( - onRetry: _handleRefresh, - ), - ) - // ── A day with no work says so ── - // - // This rendered `SizedBox.shrink()` — - // literally nothing — so a rider whose hub - // had not assigned him anything opened Home - // to a blank screen and could not tell it - // apart from a screen that had failed to - // load. The empty state already existed and - // had simply been orphaned when the trip tabs - // came out; it states which of the two this - // is, and offers the one action that changes - // it when he is off duty. - : KeyedSubtree( - key: const ValueKey('home-empty'), - child: _buildEmptyState(), - ), - ), - ) - else - SliverToBoxAdapter( - child: SmoothSwap( - // ── A slot the hub has not filled ── - // - // The day has work, just not on this tab. Says so - // by name rather than reusing the whole-day empty - // state, which would claim he has nothing to do - // while another trip is running. - child: trip == null - ? KeyedSubtree( - key: ValueKey( - 'trip-empty-$_selectedTripIndex', - ), - child: _buildTripEmptyState( - _selectedTripIndex, - ), - ) - : Reveal( - // Keyed on the trip, so switching tabs plays - // the entrance once and the 5s poll never - // replays it. - key: ValueKey('reveal_${trip.id}'), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - // The brief sits UNDER the tabs, not above. - // - // It describes one trip, and the tabs are what - // choose that trip — so above them it was - // answering a question the rider had not been - // asked yet, and switching tabs changed a - // panel he had already scrolled past. Below - // them it reads as the selected tab's own - // content, which is what it is, and it lands - // inside the per-trip [Reveal] so it swaps - // with the route it belongs to. - _tripCardFor(trip).buildSummary(), - // The "one thing to do now" head that - // used to sit here is gone. It drew the - // current place, its load and a - // full-width **Navigate to pickup** — - // all of which the timeline's own group - // node below already carries, button - // included. Two identical red buttons - // for one destination was the single - // largest thing on this screen; the - // node that owns the journey owns the - // action. - _tripCardFor(trip).buildStops(), - _tripCardFor( - trip, - ).buildActionBar(), - ], - ), - ), - ), - ), - - // ── Room for everything that floats over this list ── - // - // 80 was measured against the accepted pill alone. Two - // more things pin to the same corner — the selection - // bar and the live-pickup banner — and with a banner up - // the last stop card was covered by it with no way to - // scroll clear, which is what a rider reported as "the - // card is cut off". - // - // The floor now clears the nav bar plus the tallest - // stack that can sit on it, so the end of the route is - // always reachable. - // ── Measured, not assumed ── - // - // This was a flat `80.h`. The two surfaces that can - // occupy the bottom slot now declare their own - // heights, and the floor reserves the taller of the - // two plus the device inset — so the last route row - // clears whichever one is showing, at any text - // scale, without anybody re-measuring by eye. - SliverToBoxAdapter( - child: SizedBox( - height: - (SelectionBar.maxHeight > - AcceptedPill.maxHeight - ? SelectionBar.maxHeight - : AcceptedPill.maxHeight) + - BottomPage.bottomInset(context), - ), - ), - ], + // ── The trip tabs, under the header rather than in it ── + // + // They govern what the page below shows, so they belong to + // the page. Inside the header they were the one control on + // the screen that changes the content, living in the one + // surface that never does — and the red underline could + // only be white, because it was already on red. + // + // Not pinned. The header keeps `Trip 1 · 17 left · 33/50` + // when it collapses, which is what a rider scrolling needs; + // three tabs held permanently would be the same information + // twice, in the space the work is supposed to be in. + if (trips.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.fromLTRB(20.w, 14.h, 20.w, 2.h), + child: TripSelector( + trips: trips, + selectedIndex: _selectedTripIndex, + acceptedIds: _acceptedIds, + rejectedIds: _rejectedIds, + // Selection is per-trip, so the ticks do not travel + // with the rider between trips — a tick made on + // Trip 1 must not be counted by the bar while Trip 2 + // is on screen. See [_selectedPendingIds]. + onSelect: (index) { + if (index == _selectedTripIndex) return; + setState(() { + _selectedTripIndex = index; + _selectedStopIds.clear(); + }); + }, + ), ), ), + + // ══════════════════════════════════════════════════════════ + // READING ORDER + // + // [app bar] fixed ← greeting, duty, 1·2·3 + // trip summary scrolls away (ring, slot, load, metrics) + // hint line scrolls + // HUB → stops → HUB scrolls + // accept bar scrolls + // + // Nothing in this scroll view is pinned any more. Everything here + // is reference material the rider reads once and then works from + // memory, so all of it is allowed to leave. The one control that + // governs the whole screen — which trip he is looking at — moved + // into the header, which is fixed by being the Column's first + // child rather than by being a sliver. See [TripSelector]. + // ══════════════════════════════════════════════════════════ + + // The trip brief used to sit here, above the tabs. It now sits + // below them, inside the per-trip block — see the note at its new + // call site. + // + // The standalone shift-window strip that used to sit above it is + // gone too: it showed the rider's shift while the card below it + // showed the *trip's* slot under the caption "CURRENT SHIFT", so + // two different time ranges sat stacked with one wearing the + // other's name. Both now live inside [TripBriefStrip] as adjacent + // labelled rows. See that class for the rest of the reasoning. + + // ── Trip 1 · 2 · 3 ── + // + // Gone from here. It was a pinned + // `SliverPersistentHeader` on the canvas, in a + // strip of its own between the brand bar and the + // page — a third surface down the top of the + // screen whose only job was to hold a control. It + // is inside the header now, which was already + // pinned, so the strip, its hairline and its height + // arithmetic all went with it. See [TripSelector]. + + // The stops, an empty state for an unassigned slot, or — until + // the first fetch lands — the shape of a route being loaded. + // + // One swap holding all of them, so the hand-off between them is + // a cross-fade instead of one sliver being swapped for another + // and the screen going briefly blank in between. + // + // ── Why the sliver type changes with the state ── + // + // With nothing to list, the message *is* the page, and it + // belongs in the middle of it. A box adapter shrink-wraps its + // child, so a Center inside one only centres within the + // artwork's own height — which parked it under the app bar at + // the top of an otherwise blank screen. [SliverFillRemaining] + // hands it the rest of the viewport to be centred in, and + // `hasScrollBody: false` still lets it grow past that on a + // short phone rather than clipping. + // + // A real route keeps the adapter: filling the remainder there + // would pad every short trip with a screen of dead space above + // the floating-pill floor. + if (!hasWork) + SliverFillRemaining( + hasScrollBody: false, + child: SmoothSwap( + child: !_queuesLoaded + // The real timeline in grey, not three + // generic cards — see [HomeRouteSkeleton]. + ? const HomeRouteSkeleton( + key: ValueKey('home-loading'), + ) + : _fetchFailed + ? Padding( + key: const ValueKey('home-error'), + padding: EdgeInsets.symmetric(vertical: 32.h), + child: ErrorRetry(onRetry: _handleRefresh), + ) + // ── A day with no work says so ── + // + // This rendered `SizedBox.shrink()` — + // literally nothing — so a rider whose hub + // had not assigned him anything opened Home + // to a blank screen and could not tell it + // apart from a screen that had failed to + // load. The empty state already existed and + // had simply been orphaned when the trip tabs + // came out; it states which of the two this + // is, and offers the one action that changes + // it when he is off duty. + : KeyedSubtree( + key: const ValueKey('home-empty'), + child: _buildEmptyState(), + ), + ), + ) + else + SliverToBoxAdapter( + child: SmoothSwap( + // ── A slot the hub has not filled ── + // + // The day has work, just not on this tab. Says so + // by name rather than reusing the whole-day empty + // state, which would claim he has nothing to do + // while another trip is running. + child: trip == null + ? KeyedSubtree( + key: ValueKey( + 'trip-empty-$_selectedTripIndex', + ), + child: _buildTripEmptyState( + _selectedTripIndex, + ), + ) + : Reveal( + // Keyed on the trip, so switching tabs plays + // the entrance once and the 5s poll never + // replays it. + key: ValueKey('reveal_${trip.id}'), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + // The brief sits UNDER the tabs, not above. + // + // It describes one trip, and the tabs are what + // choose that trip — so above them it was + // answering a question the rider had not been + // asked yet, and switching tabs changed a + // panel he had already scrolled past. Below + // them it reads as the selected tab's own + // content, which is what it is, and it lands + // inside the per-trip [Reveal] so it swaps + // with the route it belongs to. + _tripCardFor(trip).buildSummary(), + // The "one thing to do now" head that + // used to sit here is gone. It drew the + // current place, its load and a + // full-width **Navigate to pickup** — + // all of which the timeline's own group + // node below already carries, button + // included. Two identical red buttons + // for one destination was the single + // largest thing on this screen; the + // node that owns the journey owns the + // action. + _tripCardFor(trip).buildStops(), + _tripCardFor(trip).buildActionBar(), + ], + ), + ), + ), + ), + + // ── Room for everything that floats over this list ── + // + // 80 was measured against the accepted pill alone. Two + // more things pin to the same corner — the selection + // bar and the live-pickup banner — and with a banner up + // the last stop card was covered by it with no way to + // scroll clear, which is what a rider reported as "the + // card is cut off". + // + // The floor now clears the nav bar plus the tallest + // stack that can sit on it, so the end of the route is + // always reachable. + // ── Measured, not assumed ── + // + // This was a flat `80.h`. The two surfaces that can + // occupy the bottom slot now declare their own + // heights, and the floor reserves the taller of the + // two plus the device inset — so the last route row + // clears whichever one is showing, at any text + // scale, without anybody re-measuring by eye. + SliverToBoxAdapter( + child: SizedBox( + height: + (SelectionBar.maxHeight > AcceptedPill.maxHeight + ? SelectionBar.maxHeight + : AcceptedPill.maxHeight) + + BottomPage.bottomInset(context), + ), ), - ), + ], ), - ], + ), ), // ── The bottom overlay ── @@ -4126,7 +4183,7 @@ void _showProductDetailsSheet( SizedBox(height: 14.h), if (rows.isEmpty) Padding( - padding: EdgeInsets.symmetric(vertical: 16.h), + padding: EdgeInsets.symmetric(vertical: 1.h), child: Text( 'No product details on this booking.', textAlign: TextAlign.center, @@ -4150,3 +4207,36 @@ void _showProductDetailsSheet( ), ); } + +/// ───────────────────────────────────────────────────────────────────────── +/// THE ARRIVED PRECEDENCE RULE +/// +/// Whether the local ARRIVED record is allowed to speak for a row the server +/// has already described. +/// +/// ── A whitelist, because the blacklist leaked ── +/// +/// This was three exclusions — not complete, not cancelled, not rejected — +/// and `isWorkComplete` is line-dependent: on a milk run it means *delivered +/// or cancelled* only. So a server row reporting `picked`, `outForDelivery` +/// or `deliveryArrived` passed all three tests and the local record wrote +/// `arrived` straight over it. That is the one thing this store must never +/// do — it would walk a stop the hub knows is on the road back to a rung the +/// rider left an hour ago. +/// +/// The rule inverted: the local record may speak **only** where the server +/// has nothing further along to say. Everything the server does say wins, +/// including `arrived` itself, where writing it again is a no-op. +/// +/// This is the whole of the precedence contract, in one place, so it can be +/// asserted directly rather than inferred from three negations. **Delete it +/// with the store** — see [getArrivedOrderIds] — once `reached` persists. +abstract final class HomepageArrivalPrecedence { + static bool localArrivalMayShow(StopStatus serverSaid) => + serverSaid.isPending || + serverSaid == StopStatus.accepted || + serverSaid == StopStatus.arrived || + // A row whose status the app could not parse is a row the server has + // said nothing usable about, which is exactly the gap this store fills. + serverSaid == StopStatus.unknown; +} diff --git a/lib/views/Dashboard/home/pickup_preview_sheet.dart b/lib/views/Dashboard/home/pickup_preview_sheet.dart index 97fc8c0..de591d3 100644 --- a/lib/views/Dashboard/home/pickup_preview_sheet.dart +++ b/lib/views/Dashboard/home/pickup_preview_sheet.dart @@ -12,6 +12,7 @@ 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/constants/narrative.dart'; import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart'; /// ───────────────────────────────────────────────────────────────────────── @@ -133,6 +134,34 @@ class PickupPreviewSheet extends StatelessWidget { return (lat == 0 || lng == 0) ? null : (lat: lat, lng: lng); } + /// The street address of the place, from the same order the pin comes from. + /// + /// ── Why this is derived and not a constructor argument ── + /// + /// The sheet drew the place's *name* and the word `Pickup location` under + /// it, and that was all: a rider who did not recognise the name had a map he + /// could not read a street off and a caption telling him what he already + /// knew from the icon. The address was in the payload the whole time. + /// + /// Read off `lines.first.stop`, exactly as [_place] reads the coordinates — + /// every order here shares one counter, so they share one address, and + /// taking both from the same row means the pin and the words can never + /// describe different places. + String get _address { + if (lines.isEmpty) return ''; + final stop = lines.first.stop; + for (final key in const [ + 'pickupaddress', + 'PickupAddress', + 'pickup_address', + 'address', + ]) { + final raw = (stop[key] ?? '').toString().trim(); + if (raw.isNotEmpty && raw.toLowerCase() != 'null') return raw; + } + return ''; + } + static double _d(dynamic v) { if (v == null) return 0; if (v is num) return v.toDouble(); @@ -146,20 +175,22 @@ class PickupPreviewSheet extends StatelessWidget { // 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( + // ── The same shape as the stop sheet ── + // + // These two are the same object at two moments — *this is the place, and + // this is what is there* — and they were laid out as two different + // screens: this one opened with a map and hung a title under it, while + // the stop sheet identified itself first and drew the map as evidence. + // A rider moving between them had to re-find everything. + // + // Identity, then the leg, then what is at it, then one action. 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), + _header(), + SizedBox(height: 14.h), + if (place != null) ...[_map(place), SizedBox(height: 18.h)], _manifest(), SizedBox(height: 18.h), _actions(), @@ -168,6 +199,74 @@ class PickupPreviewSheet extends StatelessWidget { ); } + /// Who this place is, and what kind of place it is. + /// + /// The tonal square and the brand tint are the stop sheet's, so the two read + /// as one family; the caption is what this sheet is *for* — a counter you are + /// about to ride to, with a load waiting on it. + Widget _header() { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 42.w, + height: 42.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Narrative.burgundyWash, + borderRadius: BorderRadius.circular(Narrative.radiusChip), + ), + child: Icon( + StopKind.pickup.icon, + size: 20.sp, + color: Narrative.burgundy, + ), + ), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + placeName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: MilerType.body.copyWith( + fontSize: 19.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + height: 1.15, + color: Narrative.ink, + ), + ), + SizedBox(height: 2.h), + // ── The address, where there is one ── + // + // `Pickup location` was a label restating the icon beside it. It + // stays only as the fallback for a payload with no address, + // because a name with nothing under it reads as a missing line. + // + // Three lines, not one: an Indian street address is routinely + // `door, street, area, city` and clipping it at the first comma + // leaves the rider the half he already had from the heading. + Text( + _address.isEmpty ? 'Pickup location' : _address, + maxLines: _address.isEmpty ? 1 : 3, + overflow: TextOverflow.ellipsis, + style: MilerType.micro.copyWith( + fontSize: 13.sp, + height: 1.35, + color: Narrative.inkMuted, + ), + ), + ], + ), + ), + ], + ); + } + /// The leg, drawn once. Him, the counter, and the line between them. /// /// Deliberately not interactive and deliberately not a navigation surface: @@ -179,10 +278,17 @@ class PickupPreviewSheet extends StatelessWidget { riderLat != null && riderLng != null && riderLat != 0 && riderLng != 0; final riderPos = hasRider ? LatLng(riderLat!, riderLng!) : null; - return ClipRRect( - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + final distance = (meters != null && meters! > 0) + ? RouteMetricsHelper.formatDistance(meters) + : null; + final time = ride > Duration.zero + ? '~${RouteMetricsHelper.formatDuration(ride)}' + : null; + + final frame = ClipRRect( + borderRadius: BorderRadius.circular(Narrative.radiusInner), child: SizedBox( - height: 190.h, + height: 175.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 @@ -202,59 +308,66 @@ class PickupPreviewSheet extends StatelessWidget { ), ), ); - } - /// `➤ 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(); + if (distance == null && time == null) return frame; - // 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( + // ── The leg is written on the picture of the leg ── + // + // It was a line of figures under the map, which is a caption for a thing + // that can carry its own. Same strip, same words and the same scrim as the + // stop sheet, so the two surfaces state the one fact identically. + return Stack( 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), + frame, + Positioned( + left: 0, + right: 0, + bottom: 0, + child: ClipRRect( + borderRadius: BorderRadius.vertical( + bottom: Radius.circular(Narrative.radiusInner), ), - ), - ], - 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, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), + color: Narrative.card.withValues(alpha: 0.9), + child: Row( + children: [ + Icon( + LucideIcons.navigation, + size: 13.sp, + color: Narrative.ink, + ), + SizedBox(width: 6.w), + if (distance != null) + Flexible( + child: Text( + '$distance away', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.body.copyWith( + fontSize: 12.5.sp, + fontWeight: FontWeight.w700, + color: Narrative.ink, + ), + ), + ), + if (distance != null && time != null) + Flexible( + child: Text( + ' · $time ride', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.micro.copyWith( + fontSize: 12.sp, + color: Narrative.inkMuted, + ), + ), + ), + ], ), ), ), - ], + ), ], ); } @@ -271,78 +384,103 @@ class PickupPreviewSheet extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Text( - BagManifest.countLabel(lines.length), - style: MilerType.body.copyWith( - fontWeight: FontWeight.w700, - color: ColorConstants.slateText, + 'WHAT TO COLLECT', + style: MilerType.eyebrow.copyWith( + fontSize: 9.5.sp, + letterSpacing: 0.8, + color: Narrative.inkMuted, ), ), - 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(height: 8.h), + // ── The manifest is a block, not a loose list ── + // + // It was rows on the sheet's own white, which left the bag column and + // the names floating with nothing holding them, and the count sitting + // above as a heading for air. On a tonal block — the stop sheet's own + // idiom for "here is what is at this place" — it is one object, and the + // count belongs to it. + Container( + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 4.h), + decoration: BoxDecoration( + color: Narrative.inset, + borderRadius: BorderRadius.circular(Narrative.radiusInner), + ), + child: 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: 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, + 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, + ), ), ), - child: Text( - line.bag, - maxLines: 1, - style: MilerType.label.copyWith( - fontSize: 12.sp, - fontWeight: FontWeight.w600, - color: ColorConstants.secondaryText, + 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, + ), ), ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), ), ), + SizedBox(height: 8.h), + Text( + BagManifest.countLabel(lines.length), + style: MilerType.micro.copyWith( + fontSize: 12.5.sp, + fontWeight: FontWeight.w600, + color: Narrative.inkMuted, + ), + ), ], ); } diff --git a/lib/views/Dashboard/home/route_timeline.dart b/lib/views/Dashboard/home/route_timeline.dart index 8ead8a1..a2be9fe 100644 --- a/lib/views/Dashboard/home/route_timeline.dart +++ b/lib/views/Dashboard/home/route_timeline.dart @@ -121,6 +121,14 @@ class RouteGroup { /// The rung every choosable order in this group is on. final StopState state; + /// Each of [allStops]' own state, in the same order. + /// + /// The visible half is [stopStates]; this is the whole bucket, and it is what + /// lets [groupSize] tell an order that is still this counter's business from + /// one that has finished and left it. Empty when a caller does not track it, + /// in which case [groupSize] falls back to the raw count. + final List allStopStates; + /// Each of [stops]' own state, in the same order. /// /// A group's rung is the rung it is *worked* on, and a group can hold orders @@ -150,7 +158,42 @@ class RouteGroup { /// number again. int get onRungCount => stopStates.where((s) => s == state).length; - int get groupSize => allStops.isNotEmpty ? allStops.length : stops.length; + /// The denominator of `Accepted 1/4` — **this counter's load**. + /// + /// ── Why it is not `allStops.length` ── + /// + /// It was, and the reasoning was sound as far as it went: an order the rider + /// has collected leaves the visible list but did not leave the counter, so + /// counting only what is on screen would renumber the place under him. + /// + /// What that missed is that [allStops] is every order the trip ever bucketed + /// under this source — including the ones already **delivered**, which are + /// Activity's business and not this card's, and the ones he **declined**, + /// which he has finished with. On a full day off one kitchen that is most of + /// them: a rider with ten orders in the dropdown and one accepted read + /// `Accepted 1/63`, where 63 was the whole day and 10 was the question in + /// front of him. A fraction whose denominator the rider cannot see anywhere + /// on the screen is worse than no fraction. + /// + /// So the load is stated the way he would state it: **what is still in the + /// dropdown, plus what he has already taken off this counter**. Collected + /// orders keep their place in the arithmetic, which was the original point; + /// finished and declined ones drop out of it, which was the bug. + /// + /// Skipped orders are out too. One is still owed at the counter, but it is + /// parked for a return visit and it is not part of the accept decision this + /// chip is a fraction of — see [onRungCount], which counts the accepted rung. + int get groupSize { + // A caller that does not supply the states gets the old arithmetic rather + // than a wrong number: better to over-count than to invent a denominator. + if (allStopStates.length != allStops.length) { + return allStops.isNotEmpty ? allStops.length : stops.length; + } + final carried = allStopStates + .where((s) => s == StopState.collected) + .length; + return stops.length + carried; + } /// True when the header **is** the stop: one order, at one address, with no /// place above it worth naming. @@ -169,6 +212,7 @@ class RouteGroup { required this.firstIndex, required this.state, required this.stopStates, + this.allStopStates = const [], this.travel = Duration.zero, this.meters, this.flat = false, @@ -245,7 +289,7 @@ class RouteGroup { bool get isDone => state == StopState.collected || state == StopState.done; /// The rider is on this stop right now. - bool get isLive => state == StopState.active; + bool get isLive => state.isAtSource; } /// The whole run: a head, then one expandable group per pickup place. @@ -270,10 +314,24 @@ class RouteTimeline extends StatefulWidget { /// Toggles one order's tick. final ValueChanged? onToggleSelect; - /// Opens one order's detail sheet. Progressive disclosure for the address, - /// the phone and everything else a step deliberately does not carry. + /// Opens a **place's** detail sheet — a flat group's header, where one stop + /// is one location. Progressive disclosure for the address, the phone and + /// everything else a step deliberately does not carry. final ValueChanged>? onStopTap; + /// Opens **one order's** detail sheet, from a row inside an expanded place. + /// + /// Separate from [onStopTap] because they are about different subjects, and + /// the sheet has to know which. Every order under a kitchen shares that + /// kitchen's address, so a sheet opened from an order row that pins the + /// kitchen draws the same map once per order and answers nothing about the + /// one the rider tapped — what distinguishes it is the door it is going to. + /// The place's own map is on the card's preview, beside the call. + /// + /// Falls back to [onStopTap] when unset, so a caller that does not care keeps + /// the old single-callback behaviour. + final ValueChanged>? onOrderTap; + /// Opens navigation for a whole group. **One per group, never per order** — /// five Navigate buttons that go to the same counter was the single largest /// source of clutter on the old screen. @@ -311,6 +369,7 @@ class RouteTimeline extends StatefulWidget { this.onSelectAll, this.onToggleSelect, this.onStopTap, + this.onOrderTap, this.onNavigateGroup, this.onCallGroup, this.onPreviewGroup, @@ -351,6 +410,7 @@ class _RouteTimelineState extends State { /// would put him back on a screen he has to scroll. String? get _defaultOpenKey { for (final rung in const [ + StopState.arrived, StopState.active, StopState.accepted, StopState.pending, @@ -402,6 +462,7 @@ class _RouteTimelineState extends State { onSelectAll: widget.onSelectAll, onToggleSelect: widget.onToggleSelect, onStopTap: widget.onStopTap, + onOrderTap: widget.onOrderTap ?? widget.onStopTap, onNavigate: widget.onNavigateGroup, onCall: widget.onCallGroup, onPreview: widget.onPreviewGroup, @@ -641,7 +702,7 @@ class SelectAllAction extends StatelessWidget { // [PickupTimelineGroup._railWidth] — so matching the number instead // of the axis left a 2pt drift. The axis is the thing being matched; // `home_grid_test.dart` measures it so the two cannot part again. - padding: EdgeInsets.only(left: scoped ? 10.w : 0), + padding: EdgeInsets.only(right: scoped ? 2.w : 0), child: Row( // ── Inside a panel it closes the row; at the run head it opens it ── // @@ -653,20 +714,21 @@ class SelectAllAction extends StatelessWidget { // // Unscoped — the run head — it is the first thing on the screen // above the route and keeps the reading edge. - // ── Both versions read from the left ── + // ── The scoped one closes the row; the run head opens it ── // - // The scoped one was pushed to the right edge, on the argument - // that a control with nothing beside it should sit on an edge and - // that a list's own controls live on the right. That was an - // argument about a *panel* — it had a right edge to sit on, and - // the orders were inside it. + // Inside a group the select-all is a *control over a list*, and a + // list's own controls sit on the right — the same edge the bag + // labels and the chevrons below it already use. On the left it + // headed the list like a label, which is what made it read as a + // stray tick above the first name rather than as the thing that + // ticks all five. // - // On the open ground there is no panel, and the right edge now - // belongs to the bag column. A select-all floating out there reads - // as a fourth thing in the Bag axis. It heads the list it selects, - // so it starts where the list starts: the same left axis the - // customer names below it keep. - mainAxisAlignment: MainAxisAlignment.start, + // The run-head version has no list beside it and no right edge to + // close: it is the first thing above the route, so it keeps the + // reading edge. + mainAxisAlignment: scoped + ? MainAxisAlignment.end + : MainAxisAlignment.start, children: [ // ── One rail, not two ── // @@ -680,16 +742,25 @@ class SelectAllAction extends StatelessWidget { // the same arrangement every other railed row on this screen // uses. `home_grid_test.dart` measures the axis so the pair // cannot drift apart again. - // The mark is drawn by the rail on a scoped row — see - // [PickupTimelineGroup._selectAllMark] — so only the run-head - // version, which has no rail, carries it inline. - if (!scoped) ...[_glyph(all, some, accent), SizedBox(width: 8.w)], + // ── The mark goes where the words went ── + // + // The scoped row's tick was drawn by the rail, in the node + // column on the far left, while the label it belongs to sits on + // the right edge. A control split across the full width of a + // card is two controls as far as the eye is concerned: the tick + // read as a mark on the spine and the words as a caption, and + // nothing said they were one thing you press. + // + // Both versions carry it inline now, immediately before the + // words, and the rail draws nothing for this row. + _glyph(all, some, accent), + SizedBox(width: 8.w), Flexible( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, - textAlign: TextAlign.left, + textAlign: scoped ? TextAlign.right : TextAlign.left, style: MilerType.label.copyWith( fontWeight: FontWeight.w700, color: accent, @@ -733,6 +804,9 @@ class PickupTimelineGroup extends StatelessWidget { final ValueChanged>? onSelectAll; final ValueChanged? onToggleSelect; final ValueChanged>? onStopTap; + + /// One order's sheet — see [RouteTimeline.onOrderTap]. + final ValueChanged>? onOrderTap; final ValueChanged>? onNavigate; final ValueChanged>? onCall; final ValueChanged? onPreview; @@ -753,6 +827,7 @@ class PickupTimelineGroup extends StatelessWidget { this.onSelectAll, this.onToggleSelect, this.onStopTap, + this.onOrderTap, this.onNavigate, this.onCall, this.onPreview, @@ -777,7 +852,7 @@ class PickupTimelineGroup extends StatelessWidget { static bool _isChoosable(StopState state) => state == StopState.pending || (ServiceProfile.active.handsOffAtCollection && - (state == StopState.accepted || state == StopState.active)); + (state == StopState.accepted || state.isAtSource)); /// The one group the rider is working, given a ground of its own. /// @@ -833,7 +908,7 @@ class PickupTimelineGroup extends StatelessWidget { // daylight ground. What used to be conveyed by an edge is conveyed by the // node the header hangs off and by the indent the orders keep to. return Padding( - padding: EdgeInsets.only(bottom: 24.h), + padding: EdgeInsets.only(bottom: 12.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -1023,7 +1098,7 @@ class PickupTimelineGroup extends StatelessWidget { return Padding( // Zero on the right: the utilities finish flush with the card's own edge. // This is the limit — the next point out is outside the card. - padding: EdgeInsets.fromLTRB(_railWidth, 0, 0, 12.h), + padding: EdgeInsets.fromLTRB(_railWidth, 0, 0, 0), child: wide ? Row( children: [ @@ -1315,7 +1390,7 @@ class PickupTimelineGroup extends StatelessWidget { // flush instead: a disc's silhouette curves away from the // margin, so it needs to overshoot a text edge by a few points // to look level with it. Optical alignment, not metric. - padding: EdgeInsets.fromLTRB(2.w, 14.h, 6.w, 12.h), + padding: EdgeInsets.fromLTRB(2.w, 8.h, 6.w, 8.h), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -1448,6 +1523,14 @@ class PickupTimelineGroup extends StatelessWidget { // the distances line up on one right edge, so "which is // nearest" is answered by looking down a column rather // than reading five blocks. + if (group.flat) ...[ + SizedBox(width: 4.w), + Icon( + LucideIcons.chevronRight, + size: 20.sp, + color: ColorConstants.borderStrong, + ), + ], if (showJourney) ...[ SizedBox(width: 6.w), _JourneyColumn( @@ -1460,43 +1543,26 @@ class PickupTimelineGroup extends StatelessWidget { prominent: isCurrent, ), ], - // ── The disclosure says it is one ── + // ── The chevron is gone; the journey takes the corner ── // - // The chevron was removed when the header became the - // toggle, and its *signal* went with it: a collapsed + // It came back once as an *indicator* — a passive glyph + // inside the header's own InkWell — because a collapsed // place showed nothing that said "there is a list under - // me", so the orders were discoverable only by accident - // — on the control the rider uses more than any other - // on this screen. + // me". That argument held while the header's right edge + // was empty. // - // It returns as an INDICATOR, not a target. The old bug - // was two tap arenas over one pixel; this glyph is - // passive inside the header's own InkWell, so there is - // exactly one gesture and it owns the whole card. It - // rotates with the state (the app's state duration, no - // bounce), and a flat stop — whose tap pushes the stop - // detail instead — wears the pointing variant, the same - // vocabulary every row of Account uses for "this goes - // somewhere". - SizedBox(width: 4.w), - group.flat - ? Icon( - LucideIcons.chevronRight, - size: 20.sp, - color: ColorConstants.borderStrong, - ) - : AnimatedRotation( - turns: expanded ? 0.5 : 0, - duration: DesignConstants.motionState, - curve: Curves.easeOutCubic, - child: Icon( - LucideIcons.chevronDown, - size: 20.sp, - color: expanded - ? ColorConstants.secondaryText - : ColorConstants.borderStrong, - ), - ), + // It is not empty: the distance and the ride time live + // there, and they are the reason a rider looks at this + // corner in the first place. The chevron was standing + // between them and the card's edge, so the one column he + // scans down a multi-kitchen run — *which of these is + // nearest* — was pushed 24 points off the right margin + // and no longer lined up with anything. + // + // What the chevron was signalling is still signalled: an + // open group is visibly open (its orders are on screen, + // indented under it), and a flat stop is the one case + // with nothing to open, so it keeps a pointing glyph. ], ), ), @@ -1604,6 +1670,9 @@ class PickupTimelineGroup extends StatelessWidget { // breathing chip among outlined ones — so it survives sunlight and a // grayscale screen. The stop under way used to be hidden from Home // entirely and announced by a red banner over the foot of the page. + // Both rungs take the live mark — from this rail's distance the useful + // fact is *this is the one he is on* — and each says which rung it is. + StopState.arrived => const LiveMark(label: 'Arrived'), StopState.active => const LiveMark(), StopState.collected || StopState.done => const _StateWord( label: 'Collected', @@ -1648,91 +1717,34 @@ class PickupTimelineGroup extends StatelessWidget { Widget _body(BuildContext context, List manifest, int chosen) { final ids = _choosableIds; - final selectable = _isChoosable(group.state) && onToggleSelect != null; - // ── The orders are a panel *inside* the card ── + // ── The orders are the kitchen's contents, and sit inside it ── // - // The ground ran the full width of the card and squared off against its - // edges, so it read as a band stuck to the bottom of the header rather than - // as the card's contents — the orders looked like they had spilled out of - // the thing that owns them. - // - // Inset on all four sides with the app's inner radius, it is unambiguously - // *in* the card: a panel with the place's name above it. That is one nested - // surface, not "a collection of cards inside a card" — the orders themselves - // stay rows on it, with no frame of their own. - // - // The inset moves the step rail right by [_panelInset], so the order nodes - // no longer share an axis with the place node above them. That is correct - // now: the spine stopped running between them when the card became the thing - // that groups them (see the note at `lineBelow`), so there is no line to - // bend — and a nested panel that ignored its own container's inset would - // read as broken. `stop_row_alignment_test.dart` pins the new relationship. + // They were flush with the group's own left edge, which put a stop's rail + // on the same axis as the kitchen heading above it — so an open group read + // as two lists that happen to be adjacent rather than as one thing + // containing another. A small indent is the whole signal; the spine does + // the grouping, so it does not need a fill or a box to go with it. return Padding( - padding: EdgeInsets.fromLTRB( - _panelInset, - 0, - _panelInset, - _grounded ? _panelInset : 0, - ), - // ── The well is back, because the ground changed under it ── - // - // The tinted panel behind the orders was removed when Home's rows sat - // directly on a white page — a second rectangle then had nothing to - // separate itself from. The route now stands on a white [MilerPanel], - // which put the open list back to white-on-white: an accordion whose - // open state looked exactly like its closed neighbours, which is the - // flatness being fixed on this screen. - // - // So the open orders sit in a canvas-toned well — the one grey that - // measurably stands on this white (1.29:1) and the same tone the page - // ground already is, so the well reads as the card opening *down to the - // page* rather than as a new object. One nested surface, the ladder's - // permitted inset; the rows on it still have no frames of their own. + padding: EdgeInsets.zero, child: Container( - decoration: _grounded - ? BoxDecoration( - color: MilerSurface.canvas, - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - ) - : null, - // 8 horizontal, not 10: at 320pt and 2.0× text the two extra points - // per side were the difference between fitting and a 31px overflow — - // the accessibility sweep in `home_structure_test` is the referee. - padding: _grounded - ? EdgeInsets.fromLTRB(8.w, 8.h, 8.w, 10.h) - : EdgeInsets.only(top: 4.h, bottom: 8.h), + decoration: null, + // 8 → 4. Enough that the rail is visibly inside the group rather than + // on the same axis as its heading, and not so much that the stops read + // as a second column. The indent is the whole signal; it does not need + // to be large to be read. + padding: EdgeInsets.only(left: 4.w, top: 4.h, bottom: 4.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // ── The journey action lives with the orders ── - // - // It sat on the collapsed card, which read well but put the - // screen's loudest control on every place in the run at once — - // three kitchens, three red buttons, none of them the one he is - // working. Inside the dropdown there is exactly one on screen, - // because exactly one place is open: opening a counter *is* the - // rider saying "this is the one", and the ride to it is what he - // does next. - // ── One control row, not two ── - // - // The journey button sat on its own line *above* the select-all, so - // opening a kitchen produced two stacked rows of chrome before the - // first order — and they are the two things a rider does with an - // open counter, which makes them one row. Select-all leads: it is - // what he does first and the quieter of the two. Navigate closes the - // row on the right, where the primary action belongs. - // No spine on this row. It sits above the first order, so a line - // through it is a length of route drawn above node 1 — the thing - // the rail is supposed to start at. if (_railRow(context, ids, chosen) != null) _railed( _railRow(context, ids, chosen)!, line: false, - // The tick sits where the order nodes sit, so the label lands - // on the same axis as the customer names it gathers. - node: _selectAllMark(ids, chosen), + // No node: the tick travels with its own label now — see the + // note in [SelectAllAction]. + node: null, nodeSize: _StepNode.size, ), for (final (i, line) in manifest.indexed) @@ -1743,14 +1755,15 @@ class PickupTimelineGroup extends StatelessWidget { selected: selectedIds.contains(line.orderId), busy: busyIds.contains(line.orderId), selectable: selectable, - // The spine closes inside the card now: the run's continuity is - // carried by the cards themselves, so the last order's connector - // has nothing below it to reach. first: i == 0, last: i == manifest.length - 1, lineColor: _lineColor, onTap: () => onToggleSelect?.call(line.orderId), - onOpen: onStopTap == null ? null : () => onStopTap!(line.stop), + // The order's own sheet, not the place's — see + // [RouteTimeline.onOrderTap]. + onOpen: onOrderTap == null + ? null + : () => onOrderTap!(line.stop), onUnreject: onUnreject == null ? null : () => onUnreject!(line.stop), @@ -1783,24 +1796,6 @@ class PickupTimelineGroup extends StatelessWidget { /// /// Null when there is nothing to select, so an empty row never reserves height /// above the first order. - /// The select-all's tick, drawn in the rail's node column. - Widget _selectAllMark(List ids, int chosen) { - final all = chosen == ids.length && ids.isNotEmpty; - final some = chosen > 0 && !all; - final accent = (all || some) - ? ColorConstants.primary - : ColorConstants.secondaryText; - return Icon( - all - ? LucideIcons.circleCheck - : some - ? LucideIcons.circleMinus - : LucideIcons.circleCheck, - size: 18.sp, - color: accent, - ); - } - Widget? _railRow(BuildContext context, List ids, int chosen) { final selectAll = !group.flat && ids.isNotEmpty && onSelectAll != null ? SelectAllAction( @@ -1820,7 +1815,7 @@ class PickupTimelineGroup extends StatelessWidget { } /// The inset that turns the expanded ground into a panel *inside* the card. - static double get _panelInset => 10.w; + static double get _panelInset => 0; /// Anything that hangs under the header on the group's own spine, so the line /// runs unbroken past it rather than restarting at the next node. @@ -1997,159 +1992,91 @@ class PickupTimelineStep extends StatelessWidget { color: lineColor, ), Expanded( - child: Padding( - padding: EdgeInsets.only(bottom: 6.h, right: 2.w), - // ── No tile behind an order ── - // - // Three grounds used to run here — brand for selected, slate for - // accepted, white for pending — each on its own rounded tile. - // Every one of them was a rectangle inside the panel inside the - // card, and with both of those gone the tiles are five loose - // shapes on open ground: a list that reads as five objects - // rather than as five steps of one visit. - // - // Both facts are still carried, and by the thing that is already - // per-order: the **node**. Selected is a filled brand disc with - // a tick; accepted is a filled slate disc; pending is an - // outlined ring with its sequence number. Shape and fill, which - // survive sunlight — and the name's weight follows, so the row - // reads decided or undecided without a wash across it. - child: SizedBox( - width: double.infinity, - child: Row( - children: [ - // ── The body ticks ── - // - // The commonest action on a step, so it gets the largest - // target: everything left of the bag block. - Expanded( - child: Semantics( - button: true, - checked: selectable ? selected : null, - label: selectable - ? 'Select $customer, ${line.bag}' - : '$customer, ${line.bag}', - excludeSemantics: true, - // ── The body selects; the corner opens ── - // - // Selecting is what a rider does to an order row five - // times per counter; reading its address is what he - // does to one of them occasionally. The frequent action - // gets the large target — the whole row — and the - // occasional one gets the block that already looks like - // a way in: the bag, the order id and a chevron in the - // right corner ([_OrderOpenBlock]). - // - // The two are **siblings**, never nested: two tap - // recognisers over the same pixels resolve to neither, - // which is how the card's chevron came to be dead for a - // build. - // - // It falls back to opening only where there is nothing - // to select, so a settled row is never inert. - child: InkWell( - onTap: selectable && !busy - ? onTap - : (canOpen ? onOpen : null), - borderRadius: BorderRadius.circular( - DesignConstants.radiusLg, - ), - child: Padding( - padding: EdgeInsets.fromLTRB(12.w, 9.h, 8.w, 9.h), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - customer, - maxLines: 1, - overflow: TextOverflow.ellipsis, - // ── A child of the kitchen, not a sixth kitchen ── - // - // 15sp w500 in the page's mid-tone keeps a name - // comfortably readable outdoors while letting the - // header lead. Ticking promotes it to full slate, - // because a selected row *is* the thing he is - // acting on. - // Weight is the third channel, after the node's - // shape and the tile's ground: a taken order is - // set at w700 in full slate, an undecided one at - // w500 in the mid-tone. Three channels means the - // distinction survives losing any one of them. - style: MilerType.body.copyWith( - fontSize: 15.sp, - fontWeight: selected || accepted - ? FontWeight.w600 - : FontWeight.w500, - color: rejected - ? ColorConstants.secondaryText - : selected || accepted - ? ColorConstants.slateText - : ColorConstants.onSurfaceVariant, - decoration: rejected - ? TextDecoration.lineThrough - : null, - ), - ), - // ── Where the bag is going ── - // - // The row carried the customer and the booking - // reference. The reference is what the office reads - // down a phone once, when something has gone wrong; - // the *drop area* is what the rider is deciding on - // every time he looks at this list — it is how he - // knows whether Joe and Priya are the same trip out - // of the kitchen or opposite ends of the city. - // - // So the id moves to the detail sheet, where the - // rare question lives, and the area takes the line - // under the name. Absent rather than blank when the - // payload carries no drop. - if (dropArea.isNotEmpty) ...[ - SizedBox(height: 2.h), + child: Container( + decoration: BoxDecoration( + border: last + ? null + : Border( + bottom: BorderSide( + color: ColorConstants.borderSubtle, + width: 1.0, + ), + ), + ), + child: Padding( + padding: EdgeInsets.only(bottom: 4.h, top: 4.h, right: 2.w), + child: SizedBox( + width: double.infinity, + child: Row( + children: [ + Expanded( + child: Semantics( + button: true, + checked: selectable ? selected : null, + label: selectable + ? 'Select $customer, ${line.bag}' + : '$customer, ${line.bag}', + excludeSemantics: true, + child: InkWell( + onTap: selectable && !busy + ? onTap + : (canOpen ? onOpen : null), + borderRadius: BorderRadius.circular( + DesignConstants.radiusLg, + ), + child: Padding( + padding: EdgeInsets.fromLTRB(8.w, 6.h, 8.w, 6.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ Text( - dropArea, - // ── The area wraps; it does not clip ── - // - // At 2× `Peelamedu` became `SNS…`, which - // is not a shorter answer to "where is - // this going" — it is no answer. Height is - // the cheapest thing on this row to spend, - // and the row is already allowed to grow. - maxLines: 2, + customer, + maxLines: 1, overflow: TextOverflow.ellipsis, - style: MilerType.micro.copyWith( - fontSize: 12.5.sp, + style: MilerType.body.copyWith( + fontSize: 15.sp, + fontWeight: selected || accepted + ? FontWeight.w600 + : FontWeight.w500, color: rejected - ? ColorConstants.borderStrong - : ColorConstants.secondaryText, + ? ColorConstants.secondaryText + : selected || accepted + ? ColorConstants.slateText + : ColorConstants.onSurfaceVariant, + decoration: rejected + ? TextDecoration.lineThrough + : null, ), ), + if (dropArea.isNotEmpty) ...[ + SizedBox(height: 2.h), + Text( + dropArea, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: MilerType.micro.copyWith( + fontSize: 12.5.sp, + color: rejected + ? ColorConstants.borderStrong + : ColorConstants.secondaryText, + ), + ), + ], ], - ], + ), ), ), ), ), - ), - // The UNDO branch is gone: a declined order leaves Home for - // Activity the moment it is declined, so there is no row - // here to un-decline from. See `TripCard._showsOnHome`. - // - // ── The bag, the id and the chevron are one target ── - // - // "Which bag" and "which order" are the two things a rider - // reads off a step, and "tell me more about this order" is - // the action they lead to — so they are one block and one - // gesture, marked by the chevron that has always meant - // *opens*. - _OrderOpenBlock( - bag: rejected ? 'Rejected' : line.bag, - orderId: line.orderId, - selected: selected, - onTap: canOpen ? onOpen : null, - ), - ], + _OrderOpenBlock( + bag: rejected ? 'Rejected' : line.bag, + orderId: line.orderId, + selected: selected, + onTap: canOpen ? onOpen : null, + ), + ], + ), ), ), ), @@ -2537,7 +2464,7 @@ class _GroupNode extends StatelessWidget { @override Widget build(BuildContext context) { final done = state == StopState.collected || state == StopState.done; - final live = state == StopState.active; + final live = state.isAtSource; final taken = state == StopState.accepted; // ── Exactly one red node on the route, and it is the next one ── @@ -2664,7 +2591,7 @@ class _StepNode extends StatelessWidget { final done = state == StopState.collected || state == StopState.done; final rejected = state == StopState.rejected; final skipped = state == StopState.skipped; - final live = state == StopState.active && !selected; + final live = state.isAtSource && !selected; final accepted = state == StopState.accepted; final Color accent = selected diff --git a/lib/views/Dashboard/home/stop_action_sheet.dart b/lib/views/Dashboard/home/stop_action_sheet.dart index c7a9c34..3ff82a8 100644 --- a/lib/views/Dashboard/home/stop_action_sheet.dart +++ b/lib/views/Dashboard/home/stop_action_sheet.dart @@ -5,8 +5,6 @@ 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'; @@ -16,6 +14,7 @@ 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'; +import 'package:miler/views/helpers/widgets/slide_action.dart'; /// ───────────────────────────────────────────────────────────────────────── /// THE RUNG SHEET — one deliberate gesture, at the place it describes. @@ -220,7 +219,7 @@ class _StopActionSheetState extends State { (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); + return ('Nothing was handed over — call your office before you leave.', true); } if (_missing.isEmpty) { return ( @@ -273,7 +272,7 @@ class _StopActionSheetState extends State { @override Widget build(BuildContext context) { return MilerSheetScaffold( - padding: EdgeInsets.fromLTRB(18.w, 0, 18.w, 16.h), + padding: EdgeInsets.fromLTRB(20.w, 0, 20.w, 18.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 @@ -285,19 +284,25 @@ class _StopActionSheetState extends State { children: [ Row( children: [ + // ── The tile carries the sheet's subject ── + // + // 38 → 46, and the tint resolved rather than laid over: an + // alpha wash takes its colour from whatever is behind it, and + // this sits on a sheet that is white today and was glass + // yesterday. See [ColorConstants.tint]. Container( - width: 38.w, - height: 38.w, + width: 46.w, + height: 46.w, alignment: Alignment.center, decoration: BoxDecoration( - color: _accent.withValues(alpha: 0.12), + color: ColorConstants.tint(_accent, 0.10), borderRadius: BorderRadius.circular( DesignConstants.radiusLg, ), ), child: Icon( _isArrival ? LucideIcons.mapPin : LucideIcons.shoppingBag, - size: 19.sp, + size: 22.sp, color: _accent, ), ), @@ -311,19 +316,19 @@ class _StopActionSheetState extends State { maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( - fontSize: 17.sp, + fontSize: 19.sp, fontWeight: FontWeight.w800, - letterSpacing: -0.3, + letterSpacing: -0.5, height: 1.2, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), - SizedBox(height: 3.h), + SizedBox(height: 4.h), Text( _subtitle, style: TextStyle( - fontSize: 12.5.sp, + fontSize: 13.sp, fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -376,6 +381,17 @@ class _StopActionSheetState extends State { color: ColorConstants.errorRed, ), ], + // ── The gesture gets its own zone ── + // + // A hairline and real air above it, so the slide reads as the + // sheet's conclusion rather than as the next item in a list. It is + // the only thing on here that writes anything. + SizedBox(height: 18.h), + Divider( + height: 1, + thickness: 1, + color: ColorConstants.borderSubtle, + ), SizedBox(height: 18.h), _slider(), SizedBox(height: 6.h), @@ -607,88 +623,79 @@ class _StopActionSheetState extends State { ); } + /// ── One slide control, shared with the map sheet ── + /// + /// This was `SlideToSubmit.custom` from a package: a 56pt pale-wash pill with + /// a coloured knob, whose internals do not reflow — at 2.0× system text its + /// own row ran off the right edge, so the type had to be clamped to stop it. + /// + /// The map sheet's control is the one this app actually designed: 72pt, a + /// solid accent track, a white thumb, a travelling shimmer and three chevrons + /// that say *slide* before anything is touched, a haptic on grab, and a tick + /// on the thumb when it lands. There is no reason the two most consequential + /// gestures in the app should be different objects, and every reason they + /// should not — a rider learns one control and meets it everywhere. + /// + /// So it moved out of `map.dart` into [MilerSlideAction] and both sheets use + /// it. The package went with it. + /// + /// ── Disabled is a different shape, not a dimmed one ── + /// + /// `_canSlide` is false when there is nothing to collect. A slide control at + /// reduced opacity still looks like a slide control and still invites the + /// gesture; the rider drags, nothing happens, and he learns the sheet is + /// broken. So it becomes a plain, obviously-inert bar that says what is + /// missing — the same move `stop_verify`'s blocked CTA makes. 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 (_) {} - } - }, + if (!_canSlide) { + return Container( + height: MilerSlideAction.height, + alignment: Alignment.center, + decoration: BoxDecoration( + color: ColorConstants.neutralLight, + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), ), - ), + child: Text( + _slideLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ); + } + + if (_busy) { + // The same footprint as the control it stands in for, so the sheet does + // not jump the moment the rider commits. + return Container( + height: MilerSlideAction.height, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _accent, + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + ), + child: SizedBox( + width: 24.w, + height: 24.w, + child: const CircularProgressIndicator( + strokeWidth: 2.5, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + ); + } + + return MilerSlideAction( + label: _slideLabel, + icon: _isArrival ? LucideIcons.mapPin : LucideIcons.package, + color: _accent, + onCommit: _run, ); } } diff --git a/lib/views/Dashboard/home/stop_card.dart b/lib/views/Dashboard/home/stop_card.dart index ac0e799..f21f8d4 100644 --- a/lib/views/Dashboard/home/stop_card.dart +++ b/lib/views/Dashboard/home/stop_card.dart @@ -136,7 +136,9 @@ class StopCard extends StatelessWidget { this.advanceLabel, }); - bool get _live => state == StopState.active; + /// Both rungs a rider can be standing on. They render the same mark; what + /// differs is the word — see [LiveMark.label]. + bool get _live => state == StopState.active || state == StopState.arrived; bool get _rejected => state == StopState.rejected; @override @@ -274,7 +276,7 @@ class StopCard extends StatelessWidget { /// is the only one that changes. Widget _statusRow(StopKind kind) { final chip = _live - ? const LiveMark() + ? LiveMark(label: state == StopState.arrived ? 'Arrived' : 'Active') : (state == StopState.pending && !ServiceProfile.active.handsOffAtCollection) ? null @@ -719,7 +721,20 @@ class SelectBox extends StatelessWidget { /// 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}); + /// The word beside the pulse. + /// + /// It was the literal `'Active'`, always. Since `stopStateOf` folded arrival + /// into [StopState.active], sliding *confirm arrival* moved the rung, fired + /// `/reached` and wrote the local record — and then printed **Active** on the + /// row. From the rider's side that is indistinguishable from the arrival not + /// registering at all, which is exactly how it was reported. + /// + /// The two states are separate now; this is how the mark says which one it + /// is. `Active` stays the default because the delivery half of the day is + /// where most live stops are. + final String label; + + const LiveMark({super.key, this.label = 'Active'}); @override State createState() => _LiveMarkState(); @@ -793,7 +808,7 @@ class _LiveMarkState extends State SizedBox(width: 6.w), Flexible( child: Text( - 'Active', + widget.label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( @@ -834,6 +849,13 @@ class StopStateChip extends StatelessWidget { color: ColorConstants.serviceAccent, icon: LucideIcons.shoppingBag, ), + // At the door, not on the road: the pin, not the bike. The two used to + // share this row and the chip printed `Active` for both — see + // [StopState.arrived]. + StopState.arrived => ( + color: ColorConstants.primary, + icon: LucideIcons.mapPin, + ), StopState.active => (color: ColorConstants.primary, icon: LucideIcons.bike), StopState.done => ( color: ColorConstants.acceptGreen, diff --git a/lib/views/Dashboard/home/stop_detail_sheet.dart b/lib/views/Dashboard/home/stop_detail_sheet.dart index 50b9be3..ea9336d 100644 --- a/lib/views/Dashboard/home/stop_detail_sheet.dart +++ b/lib/views/Dashboard/home/stop_detail_sheet.dart @@ -12,10 +12,8 @@ import 'package:miler/views/helpers/constants/Colorconstants.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'; -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/narrative.dart'; /// ───────────────────────────────────────────────────────────────────────── /// STOP DETAIL SHEET @@ -43,6 +41,12 @@ class StopDetailSheet extends StatelessWidget { /// Stop's position in the route, 1-based. final int stopNumber; + /// How many stops the route has, when the caller knows. `Stop 3 of 8` is the + /// one thing a rider cannot work out from the card he tapped, and it is the + /// difference between "another stop" and "the last one". Zero means unknown, + /// and then only the position is printed — never an invented total. + final int stopCount; + /// Where the rider is now — draws the "you are here" leg on the map. final double? riderLat; final double? riderLng; @@ -56,14 +60,40 @@ class StopDetailSheet extends StatelessWidget { /// delivery leg. Empty (Home's caller) leaves the raw kind in charge. final Set collectedIds; + /// True when this sheet was opened for **one order inside a place**, rather + /// than for a place. + /// + /// ── Why an order's map is not its kitchen's map ── + /// + /// Home's timeline opens this sheet from two different things. A flat group's + /// header *is* a place — one stop, one counter — and the question it asks is + /// "where is this". An order row inside an expanded kitchen is not: every + /// order in that group shares the same counter, so pinning the counter drew + /// the identical map five times over and told the rider nothing about the + /// order he actually tapped. What distinguishes one order from the next is + /// **where it is going**. + /// + /// The place still has a map — a better one, showing the whole leg from where + /// he is standing to the counter with the manifest waiting on it — and it is + /// on the card's own preview, beside the call. See [PickupPreviewSheet]. So + /// the two surfaces stop competing: the card answers *where is this counter*, + /// the order answers *where does this parcel go*. + /// + /// Honoured only as far as the payload allows: a row carrying no drop of its + /// own falls back to the pickup, and when it falls back it stops calling + /// itself a drop — see the resolution in [build]. + final bool ofOrder; + const StopDetailSheet({ super.key, required this.stop, required this.stopNumber, + this.stopCount = 0, this.riderLat, this.riderLng, this.bag = '', this.collectedIds = const {}, + this.ofOrder = false, }); /// Opens the sheet. Kept here so callers do not each re-declare the shape. @@ -71,23 +101,29 @@ class StopDetailSheet extends StatelessWidget { BuildContext context, { required Map stop, required int stopNumber, + int stopCount = 0, String bag = '', Set collectedIds = const {}, double? riderLat, double? riderLng, + bool ofOrder = false, }) { - // 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. + // ── Not the tall variant any more ── + // + // `large: true` pairs with a sheet that opens at a fixed 78% of the screen + // and therefore has a long way to travel. This one measures itself now, so + // most openings are half that height and the slow entrance read as lag. return showMilerSheet( context, - large: true, builder: (_) => StopDetailSheet( stop: stop, stopNumber: stopNumber, + stopCount: stopCount, bag: bag, collectedIds: collectedIds, riderLat: riderLat, riderLng: riderLng, + ofOrder: ofOrder, ), ); } @@ -143,33 +179,43 @@ class StopDetailSheet extends StatelessWidget { // (`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 preferred, List fallback) { - final v = _d(stop[preferred[0]] ?? stop[preferred[1]]); - return v != 0 ? v : _d(stop[fallback[0]] ?? stop[fallback[1]]); - } + // ── The leg is resolved once, and every part of the sheet reads it ── + // + // The pin, the eyebrow and the street used to be decided separately, with + // their own fallbacks, which is how a sheet ends up captioning a kitchen's + // address `DELIVER TO`. They are resolved together here: each half is + // "drop" only if the payload actually carries that half, so a partial row + // degrades to the pickup rather than to a contradiction. + final wantsDrop = ofOrder || _isDropLeg; - 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 dropLat = _d(stop['droplat'] ?? stop['DropLat']); + final dropLng = _d(stop['droplon'] ?? stop['DropLon']); + final dropAddress = _s(const [ + 'dropaddress', + 'DropAddress', + 'deliveryaddress', + ]); + + final pinIsDrop = wantsDrop && dropLat != 0 && dropLng != 0; + final addressIsDrop = wantsDrop && dropAddress.isNotEmpty; + + // What the distance is measured to, and what the map is centred on. + final drop = pinIsDrop; + + final lat = pinIsDrop ? dropLat : _d(stop['pickuplat'] ?? stop['PickupLat']); + final lng = pinIsDrop ? dropLng : _d(stop['pickuplon'] ?? stop['PickupLon']); final hasLocation = lat != 0 && lng != 0; final name = _s(['pickupcustomer', 'tenantname']); - final address = drop - ? _s([ - 'dropaddress', - 'DropAddress', - 'deliveryaddress', - 'pickupaddress', - 'PickupAddress', - ]) + final address = addressIsDrop + ? dropAddress : _s(['pickupaddress', 'PickupAddress']); + // The eyebrow names the leg the street below it belongs to — never the + // stop's raw classification, which on an uncollected meal order still says + // pickup while the address under it is the customer's door. + final addressLabel = addressIsDrop ? 'DELIVER TO' : kind.locationLabel; final phone = _s(['pickupcontactno']); final notes = _s(['notes', 'Notes']); - final otp = stopOtp(stop); final deliver = deliveryParcelCount(stop); final collect = pickupParcelCount(stop); final cash = stopCollectionAmount(stop); @@ -184,64 +230,135 @@ class StopDetailSheet extends StatelessWidget { toDrop: drop, ); - return DraggableScrollableSheet( - initialChildSize: 0.78, - minChildSize: 0.5, - maxChildSize: 0.95, - expand: false, - // 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: [ - const MilerSheetHandle(), - Expanded( - child: ListView( - controller: scrollController, - padding: EdgeInsets.fromLTRB(20.w, 14.h, 20.w, 20.h), - children: [ + // ── The sheet is as tall as what is on it ── + // + // It was a [DraggableScrollableSheet] at `initialChildSize: 0.78`, which + // claims 78% of the screen whether or not it has 78% of a screen to say. + // A meal order has a map, one address, one task row and a four-tile grid — + // and it ended at the CONTACT tile with a third of the sheet left over as + // blank paper below it. A surface that reserves space it does not use reads + // as content that failed to load. + // + // [MilerSheetScaffold] is a `mainAxisSize: min` column, so the sheet + // measures its content. The scroll view stays, capped below, for the rows + // that genuinely are long — a five-line Indian address with a note under + // it — and simply does not scroll when there is nothing to scroll. + // + // The drag-to-expand went with it, and nothing was lost: the two sizes it + // offered were "too tall" and "taller", and every other sheet in the app + // (the preview, the rung sheet) has always sized itself this way. + return ConstrainedBox( + // ── A ceiling on the WHOLE sheet, not on its list ── + // + // Constraining the scroll view instead left the handle and the bottom + // inset outside the budget, so a sheet capped at 86% measured 91% — the + // cap missed by exactly its own chrome. Wrapping the scaffold makes the + // number mean what it says: past this the sheet stops growing and the + // list inside it starts scrolling, which keeps the scrim — and the route + // behind it — visible at the top on every phone. + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.86, + ), + child: MilerSheetScaffold( + padding: EdgeInsets.fromLTRB(20.w, 0, 20.w, 18.h), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ _header(kind, name), SizedBox(height: 14.h), if (hasLocation) ...[ - _map(lat, lng, kind), - SizedBox(height: 10.h), - _distanceLine(meters), - SizedBox(height: 16.h), + // ── The leg is stated once ── + // + // It was stated twice, twenty points apart: a strip across + // the foot of the map, and a DISTANCE / EST. ARRIVAL pair + // under it. Two readings of one fact, and the second cost + // ~70pt on a sheet a rider already has to scroll — which is + // the whole reason it felt long. + // + // The strip stays, because it is written on the picture of + // the leg it describes, and it carries both halves: how far + // and how long. + _map(lat, lng, kind, meters), + SizedBox(height: 18.h), ], if (address.isNotEmpty) ...[ - _sectionLabel(kind.locationLabel), - SizedBox(height: 5.h), - // The full address, unabridged. The card above compresses it - // to two lines; this is where the rest lives. - SelectableText( - address, - style: TextStyle( - fontSize: 14.5.sp, - height: 1.45, - fontWeight: FontWeight.w500, - color: ColorConstants.slateText, - fontFamily: FontConstants.fontFamily, + _sectionLabel(addressLabel), + SizedBox(height: 8.h), + // ── The address is a block, not a paragraph ── + // + // It was loose text on the sheet's own white, at the same + // weight as everything around it, and a full Indian address + // is five lines long — so the thing a rider came here to + // read had nothing holding it. On a tonal block it is one + // object the eye can land on, and it is plainly the answer + // to the label above it. + Container( + width: double.infinity, + padding: EdgeInsets.symmetric( + horizontal: 14.w, + vertical: 13.h, + ), + decoration: BoxDecoration( + color: Narrative.inset, + borderRadius: BorderRadius.circular( + DesignConstants.radiusLg, + ), + ), + // The full address, unabridged. The card above compresses + // it to two lines; this is where the rest lives. + child: SelectableText( + address, + style: TextStyle( + fontSize: 14.sp, + height: 1.5, + fontWeight: FontWeight.w500, + color: Narrative.ink, + fontFamily: FontConstants.fontFamily, + ), ), ), - SizedBox(height: 16.h), + SizedBox(height: 18.h), ], - _sectionLabel('WHAT TO DO HERE'), + _sectionLabel('TASK DETAILS'), SizedBox(height: 8.h), + // ── The call sits on the job, not at the foot of the sheet ── + // + // Ringing the customer is not a step of the hand-over, it is + // what you do *about* the hand-over — a wrong door, a gate + // code, "I'm outside". As a full-width labelled button at the + // bottom it took a third of the sheet's action area and read + // as an alternative to delivering. As a disc on the row that + // names the job it is exactly what it is: one tap, beside the + // person it calls. if (kind.hasDelivery) _actionRow( icon: LucideIcons.truck, - color: ColorConstants.deliveryAccent, + color: Narrative.burgundy, title: _countLabel('Deliver', deliver), - detail: otp.isNotEmpty ? 'OTP $otp' : 'OTP + photo proof', + // ── No OTP is asked for at the door ── + // + // Nothing issues a delivery code on this backend — the + // `deliver` endpoint has no OTP column and cannot check + // one (see [MilerApi.deliver]) — so the sheet was + // instructing riders to collect a number that does not + // exist and that nobody would verify. The proof is the + // photo of the parcel, and now that is all it says. + detail: 'Photo of the parcel', + trailing: phone.isEmpty + ? null + : _CallDisc(onTap: () => _dial(phone)), ), if (kind.hasPickup) _actionRow( icon: LucideIcons.package, - color: ColorConstants.pickupAccent, + color: Narrative.burgundy, title: _countLabel('Collect', collect), - detail: cash > 0 - ? 'Collect ₹${cash.round()}' - : 'Photo + weight', + detail: cash > 0 ? 'Collect ₹${cash.round()}' : 'Photo', + trailing: (phone.isEmpty || kind.hasDelivery) + ? null + : _CallDisc(onTap: () => _dial(phone)), ), SizedBox(height: 6.h), _detailGrid(), @@ -249,12 +366,16 @@ class StopDetailSheet extends StatelessWidget { SizedBox(height: 16.h), _noteBox(notes), ], - SizedBox(height: 20.h), - _actions(context, phone, lat, lng, hasLocation), - ], - ), - ), - ], + // ── The sheet has no action bar ── + // + // It closed with **Call** beside **Navigate to customer**, + // and neither belonged. This is the *reference* surface — + // what is at this stop, where exactly, what it weighs, who + // to ring — opened from a card that already carries the + // stop's own action. Navigation is the map screen's whole + // job, and calling is now a disc on the row above. + ], + ), ), ), ); @@ -274,12 +395,21 @@ class StopDetailSheet extends StatelessWidget { 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. + // ── One accent on this sheet, and it is the brand ── + // + // The disc, the task tile and the call all took `kind.accent` — blue + // on a delivery, orange on a mixed stop — which is a *classification* + // colour: it exists so a rider can tell two kinds of stop apart in a + // list. There is one stop on this sheet and he already knows which, so + // the colour was classifying nothing and spending three tints doing + // it. The brand marks the sheet's own furniture instead, which is what + // the rest of the app does. Container( - width: 34.w, - height: 34.w, + width: 38.w, + height: 38.w, alignment: Alignment.center, decoration: BoxDecoration( - color: kind.accent.withValues(alpha: 0.12), + color: Narrative.burgundyWash, shape: BoxShape.circle, ), child: Text( @@ -287,7 +417,7 @@ class StopDetailSheet extends StatelessWidget { style: TextStyle( fontSize: 15.sp, fontWeight: FontWeight.w700, - color: kind.accent, + color: Narrative.burgundy, fontFamily: FontConstants.fontFamily, ), ), @@ -307,22 +437,45 @@ class StopDetailSheet extends StatelessWidget { // lines. The name leads now, as it does on every card. Text( name.isEmpty ? 'Stop $stopNumber' : name, + maxLines: 2, + overflow: TextOverflow.ellipsis, style: TextStyle( - fontSize: 20.sp, + fontSize: 19.sp, fontWeight: FontWeight.w700, letterSpacing: -0.5, height: 1.15, - color: ColorConstants.slateText, + color: Narrative.ink, fontFamily: FontConstants.fontFamily, ), ), - // ── The one fact the sheet never stated ── + // ── Where this stop sits in the run ── // - // 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(), + // The disc carries the number and the disc alone cannot say what + // it is a number *of*. `Stop 3 of 8` is the fact a rider cannot + // reconstruct from the card he tapped, and it is the difference + // between another stop and the last one. The total is printed + // only when the caller knows it — see [stopCount]. + SizedBox(height: 2.h), + Text( + stopCount > 0 + ? 'Stop $stopNumber of $stopCount' + : 'Stop $stopNumber', + maxLines: 1, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w500, + color: Narrative.inkMuted, + fontFamily: FontConstants.fontFamily, + ), + ), + // ── The lifecycle word is not printed here ── + // + // "Out for delivery" sat under the customer's name and said + // nothing the rider did not already know: he is holding the + // parcel, he opened this sheet from the Deliveries tab, and the + // card he opened it from carries the same word. A second, larger + // copy of it directly under the name pushed the address — the + // fact he came for — a line further down every time. ], ), ), @@ -330,34 +483,6 @@ 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 /// town is this?" in one glance and then gets out of the way. /// @@ -377,8 +502,11 @@ class StopDetailSheet extends StatelessWidget { /// lite mode was buying — not building a platform view during the entrance — /// is already paid for by [AfterEntrance] holding it back, so nothing is lost /// by dropping it. - Widget _map(double lat, double lng, StopKind kind) { - return ClipRRect( + Widget _map(double lat, double lng, StopKind kind, double? meters) { + final distance = RouteMetricsHelper.formatDistance(meters); + final ride = RouteMetricsHelper.travelTime(meters); + + final frame = ClipRRect( borderRadius: BorderRadius.circular(DesignConstants.radiusXl), child: SizedBox( height: 170.h, @@ -415,53 +543,70 @@ class StopDetailSheet extends StatelessWidget { ), ), ); - } - Widget _distanceLine(double? meters) { - final distance = RouteMetricsHelper.formatDistance(meters); - final travel = RouteMetricsHelper.travelTime(meters); - if (distance == '—') return const SizedBox.shrink(); + if (distance == '—') return frame; - // 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( + // ── The leg is written on the picture of the leg ── + // + // The distance used to be a line of text under the map, which is a caption + // for a thing that can carry its own: the map is the answer to "where is + // this", the strip is the answer to "how far", and a rider reads them in + // one look instead of two. Laid over a scrim so it holds on a pale street + // grid and on a dark one. + return Stack( children: [ - Icon( - LucideIcons.navigation, - size: 16.sp, - color: ColorConstants.secondaryText, - ), - SizedBox(width: 6.w), - Flexible( - child: Text( - '$distance away', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w700, - color: ColorConstants.slateText, - fontFamily: FontConstants.fontFamily, + frame, + Positioned( + left: 0, + right: 0, + bottom: 0, + child: ClipRRect( + borderRadius: BorderRadius.vertical( + bottom: Radius.circular(DesignConstants.radiusXl), ), - ), - ), - if (travel > Duration.zero) ...[ - Flexible( - child: Text( - ' · ${RouteMetricsHelper.formatDuration(travel)} ride', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13.sp, - fontWeight: FontWeight.w600, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), + color: ColorConstants.pureSurface.withValues(alpha: 0.88), + child: Row( + children: [ + Icon( + LucideIcons.navigation, + size: 13.sp, + color: Narrative.ink, + ), + SizedBox(width: 6.w), + Flexible( + child: Text( + '$distance away', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w700, + color: Narrative.ink, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + if (ride > Duration.zero) + Flexible( + child: Text( + ' · ${RouteMetricsHelper.formatDuration(ride)} ride', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w600, + color: Narrative.inkMuted, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], ), ), ), - ], + ), ], ); } @@ -472,7 +617,7 @@ class StopDetailSheet extends StatelessWidget { fontSize: 10.sp, fontWeight: FontWeight.w700, letterSpacing: 0.8, - color: ColorConstants.secondaryText, + color: Narrative.inkMuted, fontFamily: FontConstants.fontFamily, ), ); @@ -482,21 +627,39 @@ class StopDetailSheet extends StatelessWidget { required Color color, required String title, required String detail, + + /// Sits at the right of the row — the call disc, on the row that names + /// the person to call. Absent when the payload carries no number: a dead + /// control in a corner is worse than an empty corner. + Widget? trailing, }) { - return Padding( - padding: EdgeInsets.only(bottom: 8.h), + // ── The job is a card, not a line ── + // + // It sat as a bare row under an eyebrow, which put the one instruction on + // the sheet — what to actually do at this door — at the same weight as the + // reference facts under it. On its own tonal card it is the block the + // section heading promised, and the call disc has something to sit on + // instead of floating at the right margin. + return Container( + margin: EdgeInsets.only(bottom: 8.h), + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h), + decoration: BoxDecoration( + color: Narrative.inset, + borderRadius: BorderRadius.circular(Narrative.radiusInner), + ), child: Row( children: [ Container( - width: 32.w, - height: 32.w, + width: 38.w, + height: 38.w, + alignment: Alignment.center, decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + color: color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(Narrative.radiusInner), ), - child: Icon(icon, size: 17.sp, color: color), + child: Icon(icon, size: 18.sp, color: color), ), - SizedBox(width: 10.w), + SizedBox(width: 11.w), // ── The task and what it asks for, stacked ── // // These were side by side, with the requirement pushed hard against @@ -516,7 +679,7 @@ class StopDetailSheet extends StatelessWidget { style: TextStyle( fontSize: 15.sp, fontWeight: FontWeight.w700, - color: ColorConstants.slateText, + color: Narrative.ink, fontFamily: FontConstants.fontFamily, ), ), @@ -527,7 +690,7 @@ class StopDetailSheet extends StatelessWidget { Icon( LucideIcons.squareCheckBig, size: 12.sp, - color: ColorConstants.secondaryText, + color: Narrative.inkMuted, ), SizedBox(width: 4.w), Flexible( @@ -539,7 +702,7 @@ class StopDetailSheet extends StatelessWidget { style: TextStyle( fontSize: 12.5.sp, fontWeight: FontWeight.w600, - color: ColorConstants.secondaryText, + color: Narrative.inkMuted, fontFamily: FontConstants.fontFamily, ), ), @@ -550,6 +713,7 @@ class StopDetailSheet extends StatelessWidget { ], ), ), + if (trailing != null) ...[SizedBox(width: 10.w), trailing], ], ), ); @@ -582,46 +746,37 @@ class StopDetailSheet extends StatelessWidget { if (rows.isEmpty) return const SizedBox.shrink(); + // ── The facts are tiles on the ground, not cells in a table ── + // + // They were one white box cut by hairlines, on a white sheet — a table + // drawn on the same surface it sits on, which is a lot of line-work to say + // "these are facts". Two tonal tiles to a row instead: the same pairing, + // no rules to read past, and the same object the address and the task card + // above them are made of. 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: [ - for (var i = 0; i < rows.length; i += 2) ...[ - if (i > 0) - Container(height: 1, color: ColorConstants.borderSubtle), - // 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( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded(child: _factTile(rows[i])), - // A single trailing fact takes the full row rather than half - // of one — a lone tile beside empty space reads as a missing - // 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])), - ], + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < rows.length; i += 2) ...[ + if (i > 0) SizedBox(height: 8.h), + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: _factTile(rows[i])), + // A single trailing fact takes the full row rather than half + // of one — a lone tile beside empty space reads as a missing + // value, which on a sheet full of real ones is alarming. + if (i + 1 < rows.length) ...[ + SizedBox(width: 8.w), + 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. + ), ], - ), + ], ), ); } @@ -641,8 +796,12 @@ class StopDetailSheet extends StatelessWidget { /// it. Two to a row, so the whole set is scanned in a couple of saccades /// rather than seven. Widget _factTile(MapEntry fact) { - return Padding( - padding: EdgeInsets.fromLTRB(12.w, 11.h, 12.w, 11.h), + return Container( + padding: EdgeInsets.fromLTRB(13.w, 11.h, 13.w, 11.h), + decoration: BoxDecoration( + color: Narrative.inset, + borderRadius: BorderRadius.circular(Narrative.radiusInner), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, @@ -655,7 +814,7 @@ class StopDetailSheet extends StatelessWidget { fontSize: 9.5.sp, fontWeight: FontWeight.w700, letterSpacing: 0.9, - color: ColorConstants.secondaryText, + color: Narrative.inkMuted, fontFamily: FontConstants.fontFamily, ), ), @@ -679,7 +838,7 @@ class StopDetailSheet extends StatelessWidget { height: 1.25, fontWeight: FontWeight.w700, letterSpacing: -0.2, - color: ColorConstants.slateText, + color: Narrative.ink, fontFamily: FontConstants.fontFamily, ), ), @@ -694,7 +853,7 @@ class StopDetailSheet extends StatelessWidget { height: 1.25, fontWeight: FontWeight.w700, letterSpacing: -0.2, - color: ColorConstants.slateText, + color: Narrative.ink, fontFamily: FontConstants.fontFamily, ), ), @@ -708,7 +867,7 @@ class StopDetailSheet extends StatelessWidget { padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 11.h), decoration: BoxDecoration( color: ColorConstants.warningContainer, - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + borderRadius: BorderRadius.circular(Narrative.radiusInner), border: Border.all( color: ColorConstants.warning.withValues(alpha: 0.35), ), @@ -739,55 +898,6 @@ class StopDetailSheet extends StatelessWidget { ); } - Widget _actions( - BuildContext context, - String phone, - double lat, - double lng, - bool hasLocation, - ) { - return Row( - children: [ - if (phone.isNotEmpty) ...[ - Expanded( - child: MilerButton( - label: 'Call', - icon: LucideIcons.phone, - variant: MilerButtonVariant.outlined, - color: ColorConstants.acceptGreen, - onPressed: () => _dial(phone), - ), - ), - SizedBox(width: 10.w), - ], - Expanded( - flex: 2, - child: Builder( - builder: (_) { - // ── 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, - onPressed: target == null - ? null - : () => _navigate(target.lat, target.lng), - ); - }, - ), - ), - ], - ); - } - Future _dial(String phone) async { final clean = phone.replaceAll(RegExp(r'[^\d+]'), ''); if (clean.isEmpty) return; @@ -798,21 +908,52 @@ class StopDetailSheet extends StatelessWidget { ); } catch (_) {} } +} - Future _navigate(double lat, double lng) async { - // Native intent first, web as the fallback — same order as the stop screen. - try { - final native = Uri.parse('google.navigation:q=$lat,$lng&mode=d'); - if (await launchUrl(native, mode: LaunchMode.externalApplication)) return; - } catch (_) {} - try { - await launchUrl( - Uri.parse( - 'https://www.google.com/maps/dir/?api=1' - '&destination=$lat,$lng&travelmode=driving', +/// The call, as a disc. +/// +/// Tonal rather than filled: it supports the job on the row rather than being +/// it. +/// +/// ── And green, like every other phone in the app ── +/// +/// It was brand for a while, on the argument that this sheet had settled on a +/// single accent and a lone green disc among rose tints reads as borrowed from +/// another screen. That argument was about the *sheet*; the rider's is about +/// the control. Green is what a handset means before any palette gets a say — +/// it is the colour of the phone on the map sheet, on the queue card and on +/// every app he already carries — and a call button that changes hue by screen +/// is a control he has to re-find. Consistency across the app beats +/// consistency within one surface. +class _CallDisc extends StatelessWidget { + final VoidCallback onTap; + + const _CallDisc({required this.onTap}); + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: 'Call this customer', + child: Material( + color: Narrative.emeraldWash, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: SizedBox( + // At the accessibility floor. This is pressed one-handed, at a + // door, often in the rain. + width: ButtonSizes.minTapTarget, + height: ButtonSizes.minTapTarget, + child: Icon( + LucideIcons.phone, + size: 18.sp, + color: Narrative.emerald, + ), + ), ), - mode: LaunchMode.externalApplication, - ); - } catch (_) {} + ), + ); } } diff --git a/lib/views/Dashboard/home/trip.dart b/lib/views/Dashboard/home/trip.dart index cf583e6..de45bba 100644 --- a/lib/views/Dashboard/home/trip.dart +++ b/lib/views/Dashboard/home/trip.dart @@ -5,6 +5,7 @@ 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'; +import 'package:miler/data/work_domain.dart'; /// ───────────────────────────────────────────────────────────────────────── /// A TRIP — one slot's worth of work, as a single unit. @@ -105,6 +106,16 @@ enum DayPart { /// /// 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. +/// +/// ── This split is the client's, and the backend has confirmed it ── +/// +/// Asked directly (24 Aug 2026): *does the console own Trip 1/2/3?* Answer: +/// **no — there is no `tripid` or `slotid` backend-side.** The day-part +/// grouping is therefore documented client behaviour rather than a guess +/// standing in for a field that exists, and the app is not waiting on one. +/// +/// If that ever changes, this is the class to delete: the ids would arrive on +/// the booking row and the split would stop being derived at all. extension TripSlots on List { /// 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. @@ -212,6 +223,61 @@ class Trip { int get stopCount => stops.length; + /// The same trip, recounted over **only the work still owed on Home**. + /// + /// ── Why the brief was reading the wrong number ── + /// + /// The four figures in [TripBriefStrip] — duration, distance, parcels, + /// payment — were computed once, over every stop the slot ever held, and + /// never moved again. The headline above them says `20 stops left` and + /// counts down as the rider works; the figures under it kept describing the + /// morning he started with. + /// + /// So a rider with five collections to go read `≈4h 15m · 29.0 km · 25 + /// parcels` — the whole day, including the twenty stops already in his box + /// and gone to Deliveries. The one question the card exists to answer, *what + /// is left in front of me*, was the one thing on it that was stale. + /// + /// ── Where the line is ── + /// + /// [WorkBoundary.pickupComplete] — the same boundary Home and Deliveries + /// already split on, so a stop cannot be counted here and owned there. Once + /// the collection is done the stop belongs to the delivery leg and is not + /// this card's business. Skipped and cancelled work is dropped for the same + /// reason: it is a record now, not a stop to plan around. + /// + /// [origin] is the point the round trip is measured from — the rider's own + /// position, which is what the page passes to [Trip.fromStops] as the hub. + /// Passing it keeps the recount on the same footing as the original + /// measurement; omitting it would silently fall back to per-stop kilometres + /// and read as a distance that changed for no reason. + /// + /// Returns `this` unchanged when nothing has been collected yet, so the + /// common case allocates nothing. + Trip outstanding({ + Set collectedIds = const {}, + double? originLat, + double? originLng, + }) { + final left = >[ + for (final s in stops) + if (!WorkBoundary.pickupComplete(s, collectedIds: collectedIds) && + !stopStatusOf(s).isCancelled && + !stopStatusOf(s).isSkipped) + s, + ]; + if (left.length == stops.length) return this; + + return Trip.fromStops( + id: id, + stops: left, + slotStart: slotStart, + slotEnd: slotEnd, + hubLat: originLat, + hubLng: originLng, + ); + } + /// The number the rider is actually planning around: ride time plus every /// minute spent standing at a door. Duration get totalDuration => travelDuration + serviceDuration; @@ -847,6 +913,24 @@ enum StopState { /// [ServiceProfile.handoffAt] and the collected store. collected, + /// He is **at** the stop and has said so — the arrival is recorded, the + /// pickup is not yet made. + /// + /// ── Why this is not [active] ── + /// + /// It was. `stopStateOf` folded `arrived` and `active` into one value, so the + /// Home row had no arrived to render and the live chip printed the literal + /// word **Active** the moment a rider slid *confirm arrival*. He was told his + /// arrival had not registered, when in fact it had: the rung moved, the API + /// fired, the store was written, and the one word on screen said something + /// else. + /// + /// They are different facts. Arrived is a rung on the pickup ladder that the + /// rider puts the stop on. Active is an operational statement about which + /// stop is being worked — see `_activePickupOrderId` — and it is the word the + /// backend reserves for a delivery under way. + arrived, + /// The rider is physically on this stop right now. active, @@ -874,12 +958,35 @@ extension StopStateX on StopState { bool get isCommitted => this == StopState.accepted || this == StopState.collected || - this == StopState.active || + this.isAtSource || this == StopState.skipped; /// In the rider's hands right now. bool get isCollected => this == StopState.collected; + /// He is **at** the source and the pickup is not yet made. + /// + /// ── Why this is a predicate and not a comparison ── + /// + /// [StopState.arrived] and [StopState.active] were one value. Splitting them + /// apart is what finally let the row say *Arrived* instead of *Active* — and + /// it silently changed the answer at every site that had written + /// `== StopState.active` to mean **is he standing at the counter**. There + /// were a dozen, and each of them began answering `false` for the one rung + /// whose entire point is that he is standing there: + /// + /// • Home's visibility filter — so a stop *left the screen* the moment he + /// marked it arrived, and Deliveries would not take it either because + /// arrival is pre-pickup. The stop was in neither place. + /// • the selection bar's action, which offered *Accept* for a stop he was + /// already at + /// • the card's own **Mark as picked** button, which stopped being built + /// • which group opens by default, and which one counts as live + /// + /// One question asked in one place, so the next rung added to the ladder + /// cannot quietly repeat it. + bool get isAtSource => this == StopState.arrived || this == StopState.active; + /// What this state is called on a card. /// /// ── Two vocabularies, one state machine ── @@ -899,6 +1006,7 @@ extension StopStateX on StopState { StopState.pending => 'Awaiting your decision', StopState.accepted => 'Accepted', StopState.collected => 'Collected', + StopState.arrived => 'Arrived', StopState.active => 'In progress', StopState.done => 'Completed', StopState.skipped => 'Skipped', @@ -908,6 +1016,7 @@ extension StopStateX on StopState { StopState.pending => 'Pending', StopState.accepted => 'Accepted', StopState.collected => 'Collected', + StopState.arrived => 'Arrived', StopState.active => 'Out for delivery', StopState.done => 'Delivered', StopState.skipped => 'Skipped', @@ -946,8 +1055,11 @@ StopState stopStateOf( return StopState.done; } - // 2. The rider is physically on the stop — also not a local decision. - if (raw == 'active' || raw == 'arrived') return StopState.active; + // 2. He is at the stop — also not a local decision. + // + // Two values, not one. Folding them lost the arrival: see [StopState.arrived]. + if (raw == 'arrived') return StopState.arrived; + if (raw == 'active') return StopState.active; if (raw == 'skipped') return StopState.skipped; // 2b. Released for delivery — which `pickup-complete` does by itself on diff --git a/lib/views/Dashboard/home/trip_brief_strip.dart b/lib/views/Dashboard/home/trip_brief_strip.dart index 64161b2..851a722 100644 --- a/lib/views/Dashboard/home/trip_brief_strip.dart +++ b/lib/views/Dashboard/home/trip_brief_strip.dart @@ -70,6 +70,19 @@ import 'package:miler/views/helpers/constants/Font_constant.dart'; /// ───────────────────────────────────────────────────────────────────────── class TripBriefStrip extends StatefulWidget { final Trip trip; + + /// The same trip recounted over **only the stops still owed a collection**. + /// + /// The two are deliberately separate. [trip] is the slot as assigned and is + /// what the progress head measures against — `20 stops left` only means + /// anything against a total that does not move. [remaining] is the work in + /// front of the rider right now, and it is what the four figures describe. + /// + /// Merging them would break the head; leaving it out is what made the + /// figures describe a morning the rider had already half finished. See + /// [Trip.outstanding]. + final Trip remaining; + final RouteBrief brief; /// Completion across the trip, 0–100. Supplied by the page because it is @@ -85,7 +98,8 @@ class TripBriefStrip extends StatefulWidget { required this.brief, required this.percent, this.onViewRoute, - }); + Trip? remaining, + }) : remaining = remaining ?? trip; @override State createState() => _TripBriefStripState(); @@ -107,9 +121,22 @@ class TripBriefStrip extends StatefulWidget { /// 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 { - bool _open = false; + /// ── Open on arrival ── + /// + /// It was closed, which meant the four journey figures were shipped and + /// unreachable: the chevron was pressed rarely enough that a rider could not + /// see the day's duration, distance, parcel count or cash without discovering + /// a control nobody told him about. + /// + /// Open, and the chevron becomes what it should always have been — a way to + /// put the reference material away once it has been read, on a screen whose + /// subject is the route below it. + bool _open = true; Trip get _trip => widget.trip; + + /// What is still in front of the rider — see [TripBriefStrip.remaining]. + Trip get _left => widget.remaining; RouteBrief get _brief => widget.brief; int get percent => widget.percent; VoidCallback? get onViewRoute => widget.onViewRoute; @@ -158,6 +185,17 @@ class _TripBriefStripState extends State { child: _head(), ), ), + // ── What the chevron actually controls ── + // + // The four figures were moved onto the head for a while, so they were + // always drawn — and the chevron above them went on rotating while + // nothing under it moved. A disclosure that does not disclose is worse + // than no disclosure: the rider presses it, the arrow turns, the card + // stays the same size, and he learns the control is broken. + // + // They are back under the fold and the fold **opens by default**, so + // the card still arrives showing them. The difference is that closing + // it now closes them, which is what the arrow has been promising. ClipRect( child: AnimatedSize( duration: DesignConstants.motionState, @@ -214,8 +252,21 @@ class _TripBriefStripState extends State { // 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. + // ── No vertical padding of its own, and that is the whole bug ── + // + // This carried `symmetric(vertical: 14)` while the [MilerPanel] wrapping it + // carries `MilerSurface.panelPad` — 16 — on the same two edges. So the head + // sat inside **30 points** of padding above and below, from two widgets + // neither of which could see the other's, and the headline read as floating + // in a band of air. + // + // The panel owns the card's inset, on all four sides. It is the widget that + // knows where the card's edge is; the head is content inside it and content + // does not pad itself off a boundary it cannot see. The gaps *between* the + // three things in here — headline, bar, journey line — are the head's, and + // those are the eights below. return Padding( - padding: EdgeInsets.symmetric(vertical: 14.h), + padding: EdgeInsets.zero, // 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 @@ -236,7 +287,18 @@ class _TripBriefStripState extends State { color: ColorConstants.acceptGreen, ), SizedBox(width: 8.w), - Flexible( + // ── Expanded, not Flexible-plus-Spacer ── + // + // The row was `Flexible(text)` … `Spacer()`, and both are + // flex 1: the free width was split down the middle, so the + // caption took half of it and the chevron came to rest in + // the *middle of the card* instead of on its right edge. A + // disclosure that is not on an edge reads as a stray glyph + // beside the words rather than as the control for the panel. + // + // The caption takes the width and the chevron closes the + // row, which is where every other disclosure in the app sits. + Expanded( child: Text( 'All $total done', maxLines: 1, @@ -251,38 +313,50 @@ class _TripBriefStripState extends State { ), ), ] else ...[ - Text( - '$left', - style: TextStyle( - fontSize: 28.sp, - fontWeight: FontWeight.w800, - letterSpacing: -1, - height: 1.0, - fontFeatures: const [FontFeature.tabularFigures()], - color: ColorConstants.slateText, - fontFamily: FontConstants.fontFamily, - ), - ), - SizedBox(width: 7.w), - 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, - ), + // ── One phrase, centred, at one weight ── + // + // It was a 28pt numeral with `stops left` set beside it at 14 + // and pushed down six points to sit on its baseline — two + // sizes, two weights and a manual nudge to make a figure and + // its unit look like one thing. They are one thing, so they + // are one string. + // + // Centred because this is the card's title and the card is + // symmetrical: the bar under it runs the full width, and a + // left-aligned headline over a centred bar reads as two + // elements that failed to line up. + Expanded( + child: Text( + left == 1 ? '1 stop left' : '$left stops left', + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 17.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.4, + // ── 1.2 → 1.0 ── + // + // Line height on a **single** line is not spacing, it + // is invisible padding baked into the glyph box: at 1.2 + // a 17pt headline carried ~3.4pt above and below that no + // `SizedBox` accounts for and nobody can find by reading + // the layout. Stacked on the card's own vertical padding + // and the gap under the row, it is what made the space + // around `17 stops left` look like a mistake. + // + // One line needs no leading. The gaps either side of it + // are the gaps either side of it. + height: 1.0, + fontFeatures: const [FontFeature.tabularFigures()], + color: ColorConstants.slateText, + 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: 8.w), AnimatedRotation( turns: _open ? 0.5 : 0, duration: DesignConstants.motionState, @@ -295,7 +369,10 @@ class _TripBriefStripState extends State { ), ], ), - SizedBox(height: 12.h), + // 12 → 8. The bar measures what the headline states, so they are + // one statement and the gap between them should read as tighter + // than the gap around the pair. + SizedBox(height: 8.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 @@ -324,29 +401,41 @@ class _TripBriefStripState extends State { ), ), ), - SizedBox(height: 9.h), + // ── `33 of 50 done` came off this line ── + // + // The header states it now, on the trip tab it belongs to, and + // this card sits directly under the header — so the same ratio was + // being read twice within about 150 points of screen, once as a + // tab's metadata and once as a caption. It was also the third + // statement of the same fact on the card itself: the numeral above + // is `left` and the bar between them is `done ÷ total`. + // + // What is left under the bar is the journey — how long and how far + // — which nothing else on this screen says. + // + // It went flush against the bar when the ratio came off this line, + // because the gap belonged to the line that was removed. Eight, + // matching the one above the bar, so the bar sits evenly between + // the two things it separates. + SizedBox(height: 8.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, - ), + // ── The journey leads the line now ── + // + // It used to be the *fallback* — shown only when there was no + // shift and no slot, because the clock had the row. With the + // ratio gone there is space for both, and how long and how far + // is the pair a rider is actually planning against. It yields + // while the grid below is open so nothing prints twice. + Flexible( + child: _open ? const SizedBox.shrink() : _journeyGlance(), ), 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(), - ), + // allowed to carry bad news; see [_timeLeftLine]. It prints + // nothing at all when the hub has set neither a shift nor a + // slot, which is when the journey has the row to itself. + Flexible(child: _timeLeftLine()), ], ), ], @@ -373,7 +462,7 @@ class _TripBriefStripState extends State { // time it shared a row with the headline, both flexible. return FittedBox( fit: BoxFit.scaleDown, - alignment: Alignment.centerRight, + alignment: Alignment.centerLeft, child: Text( parts.join(' \u00b7 '), maxLines: 1, @@ -483,12 +572,11 @@ class _TripBriefStripState extends State { // VIEW ROUTE is not here any more either; it is an action, and it now sits on // the START · HUB row where the route actually begins. See `_hubRow`. Widget _body() { - final cash = _trip.cashToCollect; - final duration = roundTripDuration(_trip.totalDuration); - return Padding( - // Horizontally flush with [_head], for the same reason. - padding: EdgeInsets.only(bottom: 14.h), + // Nothing at the foot either: the panel's own `panelPad` closes the card, + // and 14 here on top of it put the opened state 30 points off its own + // bottom edge. Horizontally flush with [_head], for the same reason. + padding: EdgeInsets.zero, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -520,66 +608,163 @@ class _TripBriefStripState extends State { SizedBox(height: 14.h), ], - // ── Band 2: the journey, four centred figures on one row ── + // ── Band 2: the four facts, two by two ── // - // `IntrinsicHeight` so the four columns share a height and their - // captions land on one baseline even when a value scales down. + // The band the fold exists for. The shift window above it is read + // once in the morning; these are read every time the rider plans the + // next hour, which is why the fold now opens by default and the + // chevron is for putting them away rather than for finding them. + _metrics(_left.cashToCollect), + ], + ), + ); + } + + /// ── The four facts, two by two ── + /// + /// They were four centred columns on one line, inside the fold — a value over + /// a caption, ×4, packed across the card's width and reachable only by + /// opening something. Four across is also the shape that forces every figure + /// to a quarter of the width, which is why `≈3h 50m` and `26.9 km` were both + /// scaling themselves down to fit. + /// + /// Two by two, on the face. Each cell is a quiet icon tile, the fact's name + /// above it in grey, and the figure under that in the card's own ink — so a + /// rider looking for the distance finds one of four places to look rather + /// than one of four columns to parse, and each figure gets half the width + /// instead of a quarter. + Widget _metrics(double cash) { + // ── Every figure here is about the work LEFT, not the slot as assigned ── + // + // These read `_trip` — the whole slot — while the headline eighty points + // above them counted down. So a rider with five collections to go was told + // `≈4h 15m · 29.0 km · 25 parcels`: the morning he started with, including + // everything already collected and gone to Deliveries. + // + // The card's subject is what is in front of him. See [Trip.outstanding] + // for where the line between the two screens is drawn. + final duration = roundTripDuration(_left.totalDuration); + final cells = <(IconData, String, String, bool)>[ + ( + LucideIcons.clock, + 'Duration', + duration > Duration.zero + ? '\u2248${formatTripDuration(duration)}' + : '\u2014', + duration <= Duration.zero, + ), + ( + // `ruler`, 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. + LucideIcons.ruler, + 'Distance', + _left.routeMeters > 0 + ? RouteMetricsHelper.formatDistance(_left.routeMeters) + : '\u2014', + _left.routeMeters <= 0, + ), + ( + LucideIcons.package, + _left.totalParcels == 1 ? 'Parcel' : 'Parcels', + '${_left.totalParcels}', + _left.totalParcels == 0, + ), + ( + LucideIcons.banknote, + 'Payment', + // The em dash, not a word: it is what every other figure in the app + // prints when there is nothing to print, and `None` set in figure type + // reads as data. + cash > 0 ? '\u20b9${_money(cash)}' : '\u2014', + cash <= 0, + ), + ]; + + return Column( + children: [ + for (var row = 0; row < 2; row++) ...[ + if (row > 0) SizedBox(height: 16.h), IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: _figure( - icon: LucideIcons.clock, - value: '≈${formatTripDuration(duration)}', - label: 'DURATION', - ), - ), - Expanded( - child: _figure( - // `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), - label: 'DISTANCE', - ), - ), - Expanded( - child: _figure( - icon: LucideIcons.package, - value: '${_trip.totalParcels}', - label: _trip.totalParcels == 1 ? 'PARCEL' : 'PARCELS', - ), - ), - Expanded( - child: cash > 0 - ? _figure( - icon: LucideIcons.banknote, - value: '₹${_money(cash)}', - label: 'PAYMENT', - money: true, - ) - // Holds its column so a cash-free trip keeps the row on - // four even columns instead of redistributing the space - // and shifting the other three figures sideways. - : _figure( - icon: LucideIcons.banknoteX, - // 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', - muted: true, - ), - ), + for (var col = 0; col < 2; col++) ...[ + if (col > 0) SizedBox(width: 12.w), + Expanded(child: _metricCell(cells[row * 2 + col])), + ], ], ), ), ], - ), + ], + ); + } + + /// One cell of the grid: a tile, a name, a figure. + Widget _metricCell((IconData, String, String, bool) cell) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // A quiet tile rather than a bare glyph. At two across there are only + // two of them per row, so they group the pair without becoming the + // loudest thing on the card the way four in a line did. + Container( + width: 34.w, + height: 34.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: ColorConstants.tint(ColorConstants.slateText, 0.055), + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + child: Icon( + cell.$1, + size: 16.sp, + color: ColorConstants.secondaryText, + ), + ), + SizedBox(width: 10.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + cell.$2, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12.5.sp, + height: 1.2, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 2.h), + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + cell.$3, + maxLines: 1, + style: TextStyle( + fontSize: 15.sp, + height: 1.15, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + fontFeatures: const [FontFeature.tabularFigures()], + color: cell.$4 + ? ColorConstants.secondaryText + : ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ), + ], ); } @@ -635,84 +820,6 @@ class _TripBriefStripState extends State { ); } - /// One journey figure, centred on its column and standing on the page rather - /// than inside a box: icon, the number, a small-caps caption. - /// - /// The icon is bare, with no tinted disc behind it. At four across, a disc per - /// figure put four filled circles in a row that read as the loudest thing in - /// the brief while carrying none of its information — and the caption under - /// each value already says what the icon says. - Widget _figure({ - required IconData icon, - required String value, - required String label, - bool money = false, - bool muted = false, - }) { - // Only the money is coloured. The rest are facts about the journey, and - // tinting every category is the habit this screen has spent a long time - // unlearning. - final Color accent = money - ? ColorConstants.moneyGreen - : muted - ? ColorConstants.secondaryText - : ColorConstants.slateText; - - return Padding( - padding: EdgeInsets.symmetric(horizontal: 3.w), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon( - icon, - size: 17.sp, - color: money - ? ColorConstants.moneyGreen - : ColorConstants.secondaryText, - ), - SizedBox(height: 7.h), - // Fixed height with a scale-down inside it, so a long value shrinks - // rather than making one of the four columns taller than the rest. - SizedBox( - height: 21.h, - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.center, - child: Text( - value, - style: TextStyle( - fontSize: 16.5.sp, - fontWeight: FontWeight.w700, - letterSpacing: -0.5, - height: 1.05, - color: accent, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - ), - SizedBox(height: 4.h), - FittedBox( - fit: BoxFit.scaleDown, - child: Text( - label, - maxLines: 1, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 9.sp, - fontWeight: FontWeight.w700, - letterSpacing: 0.5, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - ], - ), - ); - } - /// `1234.0` → `1,234`, `1234.5` → `1,234.5`. Mirrors the trim the stat card /// used so the figure does not change shape as it moves into the strip. static String _money(double v) { diff --git a/lib/views/Dashboard/home/trip_card.dart b/lib/views/Dashboard/home/trip_card.dart index 58d2e6e..b270aa7 100644 --- a/lib/views/Dashboard/home/trip_card.dart +++ b/lib/views/Dashboard/home/trip_card.dart @@ -79,6 +79,10 @@ class TripCard extends StatelessWidget { /// Opens a single stop's detail sheet (map + full order details). final void Function(Map stop)? onStopTap; + /// One order's sheet, from a row inside an expanded place — see + /// [RouteTimeline.onOrderTap]. Falls back to [onStopTap] when unset. + final void Function(Map stop)? onOrderTap; + /// Rings the customer for this stop. final void Function(Map stop)? onCallStop; @@ -145,6 +149,7 @@ class TripCard extends StatelessWidget { this.onSelectAll, this.onUnrejectStop, this.onStopTap, + this.onOrderTap, this.onCallStop, this.onViewRoute, this.onContinueStop, @@ -175,7 +180,7 @@ class TripCard extends StatelessWidget { static bool _isChoosable(StopState state) => state == StopState.pending || (ServiceProfile.active.handsOffAtCollection && - (state == StopState.accepted || state == StopState.active)); + (state == StopState.accepted || state.isAtSource)); @override Widget build(BuildContext context) { @@ -247,6 +252,15 @@ class TripCard extends StatelessWidget { /// four surfaces it replaced were the wrong price for the information. Widget buildSummary() => TripBriefStrip( trip: trip, + // What the four figures describe: the collections still owed, measured + // from where the rider is standing — the same origin the page used to + // build the trip in the first place, so the recount and the original are + // on the same footing. See [Trip.outstanding]. + remaining: trip.outstanding( + collectedIds: collectedIds, + originLat: riderLat, + originLng: riderLng, + ), brief: brief ?? RouteBrief.empty, percent: trip.completionPercent( acceptedIds: acceptedIds, @@ -287,7 +301,13 @@ class TripCard extends StatelessWidget { int index, })? _nextGroup() { - for (final rung in const [StopState.active, StopState.accepted]) { + // Arrived outranks active: he is at that counter now, and a stop he has + // only been routed to can wait. + for (final rung in const [ + StopState.arrived, + StopState.active, + StopState.accepted, + ]) { for (var i = 0; i < trip.stops.length; i++) { if (_stateOf(trip.stops[i]) != rung) continue; final stop = trip.stops[i]; @@ -351,6 +371,7 @@ class TripCard extends StatelessWidget { onSelectAll: onSelectAll, onToggleSelect: onToggleSelect, onStopTap: onStopTap, + onOrderTap: onOrderTap ?? onStopTap, // One journey action per place. Five Navigate buttons pointing at // one counter was the largest single source of clutter on the old // screen, and the rider had to work out whether they differed. @@ -477,6 +498,10 @@ class TripCard extends StatelessWidget { firstIndex: indices.first, state: stops.isEmpty ? StopState.collected : _groupRung(stops), stopStates: [for (final s in stops) _stateOf(s)], + // The whole bucket's states, so the card can tell an order that is + // still this counter's business from one already delivered — see + // [RouteGroup.groupSize]. + allStopStates: [for (final s in all) _stateOf(s)], travel: trip.travelTimeToStop(indices.first), // ── Measured, or absent ── // @@ -530,7 +555,7 @@ class TripCard extends StatelessWidget { // give. `trip_brief_layout_test.dart` sweeps for exactly this. Flexible( child: Text( - ServiceProfile.active.endsAtHub ? 'RETURN · HUB' : 'END · HOME', + ServiceProfile.active.endsAtHub ? 'RETURN · BASE' : 'END · HOME', maxLines: 1, overflow: TextOverflow.ellipsis, style: MilerType.eyebrow, @@ -901,17 +926,17 @@ class TripCard extends StatelessWidget { isStart ? (ServiceProfile.active.sourceIsKitchen ? 'START · KITCHEN' - : 'START · HUB') + : 'START · BASE') // ── A milk run does not return anything ── // // He loads at the kitchen and his last address is a - // customer's door: there is no hub at the end of + // customer's door: there is no base at the end of // his day and nothing to carry back to one. The // logistics rider genuinely does return collected // shipments to the depot, so the label follows the // capability rather than the screen position. : (ServiceProfile.active.endsAtHub - ? 'RETURN · HUB' + ? 'RETURN · BASE' : 'END · HOME'), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -1076,7 +1101,9 @@ class TripCard extends StatelessWidget { /// what kept the row here in the first place. static bool _showsOnHome(StopState state) => state == StopState.pending || - state == StopState.active || + // Arrived belongs here for the same reason active does, and it is the + // reason this reads as a predicate: see [StopStateX.isAtSource]. + state.isAtSource || // Accepted stays only where the rider works that rung from this screen; // on a parcel route it moves to Bookings the moment he takes it. (state == StopState.accepted && @@ -1089,7 +1116,9 @@ class TripCard extends StatelessWidget { /// heading cannot end up describing different rungs. StopStatus _rungOf(StopState state) => switch (state) { StopState.accepted => StopStatus.accepted, - StopState.active => StopStatus.arrived, + // Both: this answers *which rung is the stop on*, and it is the same for + // either — he is at the counter and the next thing he does is collect. + StopState.arrived || StopState.active => StopStatus.arrived, _ => StopStatus.unknown, }; @@ -1161,7 +1190,7 @@ class TripCard extends StatelessWidget { // An **arrived** stop keeps a card-level rung, because by then the batch // question is settled: he is at the counter, and the sheet it opens acts // on every order from that counter anyway ([_advanceStopWithSheet]). - final advance = state == StopState.active + final advance = state.isAtSource ? (MilkRun.nextActionLabel(_rungOf(state)) == null ? null : 'Mark as picked') @@ -1281,7 +1310,7 @@ class TripCard extends StatelessWidget { // shrinking. Flexible( child: Text( - 'Trip complete · head back to the hub', + 'Trip complete · head back to base', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( diff --git a/lib/views/Dashboard/home/trip_empty_state.dart b/lib/views/Dashboard/home/trip_empty_state.dart index 76b0aa0..35c8945 100644 --- a/lib/views/Dashboard/home/trip_empty_state.dart +++ b/lib/views/Dashboard/home/trip_empty_state.dart @@ -94,7 +94,7 @@ class TripEmptyState extends StatelessWidget { illustrationSize: offline ? 180 : 208.w, title: offline ? 'You are off duty' : 'Trip not assigned yet', message: offline - ? 'Go on duty to start receiving trips from your hub.' + ? 'Go on duty to start receiving trips from your office.' : null, accent: offline ? cta : accent, actionLabel: offline ? 'Go on duty' : null, diff --git a/lib/views/Dashboard/home/trip_progress_rail.dart b/lib/views/Dashboard/home/trip_progress_rail.dart index 9cb6c46..aa050c6 100644 --- a/lib/views/Dashboard/home/trip_progress_rail.dart +++ b/lib/views/Dashboard/home/trip_progress_rail.dart @@ -534,7 +534,7 @@ class _TripProgressRailState extends State { // 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 base' : 'Round complete') : _footStatus(total: total, done: done), maxLines: 1, @@ -570,7 +570,7 @@ class _TripProgressRailState extends State { // 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', + : '${formatTripDuration(roundTripDuration(left))} left, then base', ]; return parts.join(' · '); } diff --git a/lib/views/Dashboard/home/trip_tabs.dart b/lib/views/Dashboard/home/trip_tabs.dart index b2f16d8..c38d6ca 100644 --- a/lib/views/Dashboard/home/trip_tabs.dart +++ b/lib/views/Dashboard/home/trip_tabs.dart @@ -1,52 +1,106 @@ import 'package:flutter/material.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:miler/views/Dashboard/home/trip.dart'; -import 'package:miler/views/helpers/constants/miler_surface.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; /// ───────────────────────────────────────────────────────────────────────── -/// TRIP TABS — Trip 1 · Trip 2 · Trip 3 +/// THE TRIP SELECTOR — 1 · 2 · 3, under the header on white /// -/// A rider is assigned at most three trips a day, so three tabs fit across a -/// phone and every trip is one tap away. Tabs beat a dropdown here for a -/// reason: with a dropdown the rider cannot see how many trips he has or how -/// far along each one is without opening it, and "how much is left today" is -/// the question he asks most. +/// A rider is assigned at most three trips a day, so three targets fit across a +/// phone and every trip is one tap away. /// -/// Each tab carries its own completion percentage, so the whole day reads at a -/// glance: `Trip 1 · 100% Trip 2 · 40% Trip 3 · 0%` +/// ── It has no container ── +/// +/// It has had three, and each was a box drawn around a control that already +/// had a boundary of its own. First a pinned grey strip below the header, with +/// its own hairline. Then chips floating on the brand. Then a translucent +/// rounded track holding three rounded segments — a rounded rectangle inside a +/// rounded rectangle inside the header's own rounded rectangle, three nested +/// shapes to say which of three words is selected. +/// +/// It is a row of pills now, on the white page under the header: +/// +/// ``` +/// ╭─────────╮ ╭─────────╮ ╭─────────╮ +/// │ Trip 1 │ │ Trip 2 │ │ Trip 3 │ +/// ╰─────────╯ ╰─────────╯ ╰─────────╯ +/// ━━━━━ +/// ``` +/// +/// The selected trip is a white pill with a brand-red edge and brand-red type, +/// under a short red rule that travels; the others are a quiet grey fill with +/// grey type. Nothing here is a card, nothing is nested, and the row sizes +/// itself to its labels rather than dividing the width into three — which is +/// what makes it read as a filter rather than as a segmented control. +/// +/// ── Why it came off the brand ground ── +/// +/// It sat inside the red header for a while, which put the one control on the +/// screen that changes what the *page below* shows inside the one surface that +/// never changes. On white it belongs to the content it governs, the header +/// goes back to being identity and duty, and the red is an accent on a page +/// rather than a lighter shade of the thing it sits on. +/// +/// ── And it says only which trip it is ── +/// +/// The tabs carried `33 / 50` under each label for a while. That figure is the +/// run's, not the tab's: it is the headline of the card immediately below, in +/// type four times the size, and it was the third place on one screen the same +/// ratio appeared. A filter says what it filters to. /// -/// **Overflow.** Three is the norm, not a guarantee — a hub can assign more. -/// Past three the bar keeps the first two tabs and folds the rest into a -/// "More" menu, rather than shrinking every tab until the labels are -/// unreadable. The dropdown is the exception path, exactly as asked. /// ───────────────────────────────────────────────────────────────────────── -class TripTabs extends StatefulWidget { +class TripSelector extends StatefulWidget { final List trips; final int selectedIndex; final ValueChanged onSelect; - /// Optimistic accept/reject sets, for the per-tab completion figure. + /// Optimistic accept/reject sets, for the per-trip count. final Set acceptedIds; final Set rejectedIds; - /// Tabs shown inline before the rest collapse into the overflow menu. + /// Trips shown inline before the rest collapse into the overflow menu. /// - /// Also the number of tabs ALWAYS rendered: a rider's day is three trips, so - /// Trip 2 and Trip 3 are shown even before the hub has assigned them. A tab - /// that appears and disappears through the morning makes the screen feel - /// unstable; an empty Trip 3 that says "nothing assigned yet" answers the - /// question he actually has. + /// Also the number of segments ALWAYS rendered: a rider's day is three trips, + /// so 2 and 3 are shown before the hub has assigned them. A segment that + /// appears and disappears through the morning makes the screen feel unstable; + /// an empty third slot answers the question he actually has. static const int maxInlineTabs = 3; - /// The travelling selection marker at the foot of the pill. + /// The travelling selection indicator. static const Key markerKey = ValueKey('trip_tab_marker'); - const TripTabs({ + /// The control's height — the pill, the air under it, and the indicator. + /// + /// Fixed, because it sits in a header whose extent must not move as trips + /// arrive through the morning, and tight because every point of it is a point + /// of the rider's first stop. + /// + /// ── Why there is no `.h` in this sum ── + /// + /// The parts used to be a mix: type in `.sp`, which ScreenUtil scales by + /// screen **width**, and gaps in `.h`, which it scales by **height**. On a + /// phone shaped like the design size those agree and nothing shows. On + /// anything shorter and wider — a foldable open, a tablet in landscape, a + /// test surface — the box shrinks on one axis while the type inside it does + /// not, and the label gets squeezed while still technically fitting. + /// + /// So the control is measured on one axis only: [pillHeight] · 7 · + /// [indicator]. + static double get height => pillHeight + 7 + indicator; + + /// The pill itself. Comfortably over the 44pt tap floor without being a + /// button — this is a filter, and a filter that looks like a primary action + /// competes with the one at the foot of the card below it. + static double get pillHeight => 40.sp; + + /// The travelling rule's thickness — the spec's 2–3dp. + static const double indicator = 3; + + const TripSelector({ super.key, required this.trips, required this.selectedIndex, @@ -56,63 +110,71 @@ class TripTabs extends StatefulWidget { }); @override - State createState() => _TripTabsState(); + State createState() => _TripSelectorState(); } -class _TripTabsState extends State - with SingleTickerProviderStateMixin { - /// Carries the pill from the tab it was on to the tab just chosen. +class _TripSelectorState extends State + // Two tickers: the marker's journey and the running trip's pulse. They are + // independent — the pulse repeats forever, the marker fires on a tap — so + // they cannot share one controller. + with + TickerProviderStateMixin { + /// Carries the marker from the segment it was on to the one just chosen. /// - /// Same mechanism as the bottom nav bar, and for the same reason. The pill - /// used to belong to each tab — an `AnimatedContainer` per segment whose fill - /// went from transparent to white — so moving from Trip 3 to Trip 1 faded one - /// pill out on the right and a *different* one in on the left. Nothing ever - /// occupied the space between, so there was no object to travel across Trip 2 - /// with, and the marker appeared to teleport. + /// Same mechanism as the bottom nav bar, and for the same reason. The marker + /// used to belong to each segment — an `AnimatedContainer` per tab whose fill + /// went from transparent to white — so moving from 3 to 1 faded one out on + /// the right and a *different* one in on the left. Nothing ever occupied the + /// space between, so there was no object to travel across 2 with, and the + /// marker appeared to teleport. /// - /// There is one pill now, owned by the control, and this is its position in + /// There is one marker now, owned by the control, and this is its position in /// *slot* units — 1.5 is genuinely halfway between the second and third - /// segment. It also drives the tint on every label, so a tab warms up as the - /// pill passes over it instead of switching the instant it is tapped while the - /// pill is still two segments away. + /// segment. It also drives the ink on every segment, so one warms up as the + /// marker passes over it instead of switching the instant it is tapped while + /// the marker is still two segments away. /// /// Initialised on the declaration rather than in `initState`: a `late final` /// assigned in `initState` is never filled in on a `State` that already /// exists, and hot reload reuses the existing `State`. See the identical note /// in `Bottom_page.dart`. - late final AnimationController _pillAnim = AnimationController( - vsync: this, - // Matches the nav bar. A pill crossing two segments needs long enough for - // the eye to follow it; the 200ms the cross-fade used reads as a jump. - duration: const Duration(milliseconds: 380), - value: 1.0, - ); + late final AnimationController _markerAnim; + late final Animation _markerCurve; + late Tween _markerTween; - late final Animation _pillCurve = CurvedAnimation( - parent: _pillAnim, - curve: Curves.easeInOutCubic, - ); + @override + void initState() { + super.initState(); + _markerAnim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 220), + value: 1.0, + ); + _markerCurve = CurvedAnimation( + parent: _markerAnim, + curve: Curves.easeInOutCubic, + ); + _markerTween = Tween( + begin: _slotOf(_inlineTabs()), + end: _slotOf(_inlineTabs()), + ); + } - late Tween _pillTween = Tween( - begin: _slotOf(_inlineTabs()), - end: _slotOf(_inlineTabs()), - ); - - double get _pillPos => _pillTween.evaluate(_pillCurve); + double get _markerPos => _markerTween.evaluate(_markerCurve); /// Trip indices rendered as inline segments, left to right. /// /// Three is the norm, not a guarantee — a hub can assign more. Past three the - /// bar keeps the first two segments and folds the rest into a "More" menu - /// rather than shrinking every tab until the labels are unreadable. When the - /// selection lives in the overflow it is pulled into the last inline slot, so - /// the rider can always see which trip he is looking at. + /// control keeps the first two segments and folds the rest into a menu rather + /// than shrinking every target until nothing can be hit. When the selection + /// lives in the overflow it is pulled into the last inline slot, so the rider + /// can always see which trip he is looking at. List _inlineTabs() { - final tabCount = widget.trips.length > TripTabs.maxInlineTabs + final tabCount = widget.trips.length > TripSelector.maxInlineTabs ? widget.trips.length - : TripTabs.maxInlineTabs; - final overflows = tabCount > TripTabs.maxInlineTabs; - final inlineCount = overflows ? TripTabs.maxInlineTabs - 1 : tabCount; + : TripSelector.maxInlineTabs; + final overflows = tabCount > TripSelector.maxInlineTabs; + final inlineCount = overflows ? TripSelector.maxInlineTabs - 1 : tabCount; return [ for (var i = 0; i < inlineCount; i++) i, if (overflows && widget.selectedIndex >= inlineCount) @@ -121,11 +183,11 @@ class _TripTabsState extends State } List _hiddenTabs(List inline) { - final tabCount = widget.trips.length > TripTabs.maxInlineTabs + final tabCount = widget.trips.length > TripSelector.maxInlineTabs ? widget.trips.length - : TripTabs.maxInlineTabs; - final overflows = tabCount > TripTabs.maxInlineTabs; - final inlineCount = overflows ? TripTabs.maxInlineTabs - 1 : tabCount; + : TripSelector.maxInlineTabs; + final overflows = tabCount > TripSelector.maxInlineTabs; + final inlineCount = overflows ? TripSelector.maxInlineTabs - 1 : tabCount; return [ for (var i = inlineCount; i < tabCount; i++) if (!inline.contains(i)) i, @@ -140,609 +202,367 @@ class _TripTabsState extends State } @override - void didUpdateWidget(TripTabs old) { + void didUpdateWidget(TripSelector old) { super.didUpdateWidget(old); // Selection is owned by the page, not by this control, so the slide starts // from here rather than from a tap handler. final target = _slotOf(_inlineTabs()); - if ((target - _pillTween.end!).abs() < 0.001) return; - // Begin from where the pill actually *is*. Starting from the previous - // selection instead would snap it back and re-run the whole journey when the - // rider changes his mind mid-slide. - _pillTween = Tween(begin: _pillPos, end: target); - _pillAnim.forward(from: 0); + if ((target - _markerTween.end!).abs() < 0.001) return; + // Begin from where the marker actually *is*. Starting from the previous + // selection instead would snap it back and re-run the whole journey when + // the rider changes his mind mid-slide. + _markerTween = Tween(begin: _markerPos, end: target); + _markerAnim.forward(from: 0); } @override void dispose() { - _pillAnim.dispose(); + _markerAnim.dispose(); super.dispose(); } - /// The colour a segment settles on once the pill is over it. - Color _litColourOf(int tripIndex) { - if (widget.trips.tripAt(tripIndex) == null) { - // Selected-but-empty keeps a readable voice — the rider deliberately - // looked at this slot, so it answers — but never the brand: there is - // nothing here to be mission about. - return ColorConstants.secondaryText; - } - return _percentOf(tripIndex) >= 100 - ? ColorConstants.acceptGreen - : ColorConstants.primary; - } - - /// The colour a segment sits at with the pill elsewhere. - /// - /// A finished trip stays green whether or not you are looking at it — "Trip 1, - /// Done" is worth knowing from Trip 2 — so for those the two colours are the - /// same and nothing changes as the pill goes by. - Color _restColourOf(int tripIndex) { - if (widget.trips.tripAt(tripIndex) == null) { - // ── An empty slot is furniture, not a peer ── - // - // At `secondaryText` an unassigned tab was indistinguishable from a - // real unselected trip, so a morning screen read as three equal trips - // when two of them were placeholders. The disabled token is what the - // app already means by "nothing here to act on", and it is exactly one - // voice quieter — the slot is still legible, it has simply stopped - // competing. - return ColorConstants.disabledFill; - } - return _percentOf(tripIndex) >= 100 - ? ColorConstants.acceptGreen - : ColorConstants.secondaryText; - } - /// ── Slot, not position ── /// - /// Every lookup here goes through [TripSlots.tripAt]. Tab 1 is the morning - /// whether or not the rider has morning work, so `trips[i]` — which meant + /// Every lookup goes through [TripSlots.tripAt]. Slot 1 is the morning + /// whether or not the rider has morning work, so `trips[i]` — which means /// "the i-th trip he happens to have" — would put an afternoon run under the - /// Trip 1 tab on a day with no morning stops, and the rider's Trip 2 would + /// first segment on a day with no morning stops, and the rider's trip 2 would /// not be the hub's. - int _percentOf(int tripIndex) => - widget.trips - .tripAt(tripIndex) - ?.completionPercent( - acceptedIds: widget.acceptedIds, - rejectedIds: widget.rejectedIds, - ) ?? - 0; - - /// True when this trip has a stop in progress — the rider is on it now. - /// - /// Derived rather than stored: a trip is running when any of its stops is, and - /// [stopStateOf] already resolves that from the server status plus the local - /// accept/reject sets. - bool _isRunning(Trip trip) => trip.stops.any( - (s) => - stopStateOf( - s, - acceptedIds: widget.acceptedIds, - rejectedIds: widget.rejectedIds, - ) == - StopState.active, - ); - @override Widget build(BuildContext context) { final inline = _inlineTabs(); final hidden = _hiddenTabs(inline); - // ── A segmented control on a track ── - // - // Three bordered chips became a tab bar became this: one light track holding - // three segments, with the tab you are looking at marked by a pill that - // slides between them. It is the iOS/Material segmented pattern, and it is - // the right one here because the choice is *exclusive and complete* — a - // rider is always looking at exactly one of his three trips, and a track - // makes that set visible as a set. - return Container( - // 16 — the app's base gutter, and the narrowest inset on this screen. - // - // The cards above sit on 28 and the route below on 32, so the track is - // deliberately the widest element in the column. Two reasons it reads - // right rather than misaligned: - // - // • Every card here carries a drop shadow with an 18–20pt blur, which - // bleeds past the card's box on each side. A card's *visible* edge is - // softly wider than its layout edge, while the track is a flat fill - // with a hard edge and no bleed — so matching the numbers exactly made - // the track look inset further than everything around it. It was - // measured at an identical 28 and still read as narrower. - // - // • A segmented control is a *container* for the three trips, not another - // card in the stack. Running it wider than the content it governs is - // the usual way that reads, and it gives each of the three segments - // more room for its label and status line. - margin: EdgeInsets.fromLTRB(16.w, 2.h, 16.w, 4.h), - padding: EdgeInsets.all(3.w), - decoration: BoxDecoration( - // Layer 1 on the canvas, same as every working surface on Home. - // `neutralLight` separated from the old cream backing by 1.02:1 — - // a track you had to be told about. - color: MilerSurface.working, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - ), - // LayoutBuilder OUTSIDE IntrinsicHeight, not inside it. LayoutBuilder - // refuses to answer intrinsic-dimension queries, so nesting it under - // IntrinsicHeight throws rather than laying out. - child: LayoutBuilder( - builder: (context, box) { - // The overflow button is a fixed 48 plus a 6 gap, so the segments - // divide what is left. Without this the pill would sit off its tab - // whenever a hub assigned an unusual day. - final reserved = hidden.isEmpty ? 0.0 : 48.w + 6.w; - final slot = (box.maxWidth - reserved) / inline.length; + return SizedBox( + height: TripSelector.height, + child: AnimatedBuilder( + animation: _markerAnim, + builder: (context, _) { + final p = _markerPos; - return AnimatedBuilder( - animation: _pillAnim, - builder: (context, _) { - final pos = _pillPos; - - // The trailing underline travels *with* the pill as one object. - // Left in the segments it would have had to blink out under the - // old tab and in under the new one, which is the behaviour being - // removed. Its colour lerps between the two segments it is - // between, so crossing a finished trip shades red → green → red - // rather than snapping at the midpoint. - final lo = pos.floor().clamp(0, inline.length - 1); - final hi = pos.ceil().clamp(0, inline.length - 1); - final markColour = Color.lerp( - _litColourOf(inline[lo]), - _litColourOf(inline[hi]), - pos - pos.floor(), - )!; - - // No fixed height: the tab content is text, and a user with large - // font settings overflowed a hard 54.h by a few pixels. - // IntrinsicHeight sizes the row to its tallest tab instead, so - // every tab matches and nothing can overflow. - return IntrinsicHeight( - child: Stack( - children: [ - PositionedDirectional( - start: slot * pos, - top: 0, - bottom: 0, - width: slot, + return Stack( + children: [ + Row( + children: [ + for (var i = 0; i < inline.length; i++) ...[ + if (i > 0) SizedBox(width: 10.w), + // ── Flexible, because the labels grow ── + // + // The pills size themselves to their text and the text + // scales with the system font. Three of them at 2.0× are + // wider than a 320pt phone, and a `Row` of fixed-width + // children overflows rather than compressing. Loose flex + // lets each take its natural width until there is no room, + // and the label scales down inside it after that. + Flexible( + child: _TripTab( + index: inline[i], + // How lit this pill is: 1 with the rule under it, 0 + // once the rule is a whole slot away. Tied to the + // rule's position rather than to the selection, so a + // pill warms as the rule crosses instead of flipping + // the instant it is tapped while the rule is two slots + // away. + lit: (1.0 - (p - i).abs()).clamp(0.0, 1.0), + onTap: () => widget.onSelect(inline[i]), + onMeasured: (w) => _measure(i, w), + ), + ), + ], + if (hidden.isNotEmpty) ...[ + SizedBox(width: 10.w), + _OverflowChip( + width: 44.w, + hidden: hidden, + trips: widget.trips, + onSelect: widget.onSelect, + ), + ], + ], + ), + // ── The rule, travelling ── + // + // One object that moves, rather than a mark each pill draws for + // itself: moving from 3 to 1 used to fade one out on the right + // and a different one in on the left, with nothing ever occupying + // the space between, so it appeared to teleport across tab 2. + // + // The pills size themselves to their labels, so where the rule + // has to be is not arithmetic — it is measured, from the pills + // themselves. See [_measure]. + if (_widths.length >= inline.length) + Positioned( + left: _leftOf(p), + bottom: 0, + width: _widthAt(p), + child: Center( + child: FractionallySizedBox( + widthFactor: 0.52, child: Container( - margin: EdgeInsets.symmetric(horizontal: 2.w), + key: TripSelector.markerKey, + height: TripSelector.indicator, decoration: BoxDecoration( - // The same light glass red the nav bar uses for its - // selection. A white pill was doing the job with a - // drop shadow — fine on its own, but it made this the - // one selected-state in the app that was not the brand - // tint, so the two controls a rider uses most looked - // like they came from different apps. - color: ColorConstants.primary.withValues(alpha: 0.12), + color: ColorConstants.primary, borderRadius: BorderRadius.circular( - DesignConstants.radiusLg, + DesignConstants.radiusFull, ), - ), - child: Align( - alignment: Alignment.bottomCenter, - child: Container( - // Keyed so tests can follow it across the strip. - // Identifying it by colour instead breaks the moment - // it travels over an unassigned trip, where it is - // deliberately grey rather than the brand red. - key: TripTabs.markerKey, - height: 3.h, - margin: EdgeInsets.only( - left: 12.w, - right: 12.w, - bottom: 4.h, - ), - decoration: BoxDecoration( - color: markColour, - borderRadius: BorderRadius.circular( - DesignConstants.radiusFull, + // The one piece of decoration in this control, spent + // on the mark that says where you are: its own colour + // at a third, blurred wide, so the rule reads as lit + // rather than as a bar with a shadow. + boxShadow: [ + BoxShadow( + color: ColorConstants.primary.withValues( + alpha: 0.34, ), + blurRadius: 9, + offset: const Offset(0, 3), ), - ), + ], ), ), ), - Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for ( - var slotIndex = 0; - slotIndex < inline.length; - slotIndex++ - ) ...[ - Builder( - builder: (context) { - final i = inline[slotIndex]; - // How lit this segment is: 1 with the pill on it, - // 0 once the pill is a whole slot away. Cubed, so - // the glow stays tight around the pill — a linear - // ramp leaves every segment it crosses looking - // half-selected, and the control then reads as - // having no clear selection mid-slide. - final d = (pos - slotIndex).abs(); - final t = (1.0 - d).clamp(0.0, 1.0); - final lit = t * t * t; - - return Expanded( - child: _Tab( - label: Trip.tabLabel(i), - percent: _percentOf(i), - assigned: widget.trips.tripAt(i) != null, - selected: i == widget.selectedIndex, - active: switch (widget.trips.tripAt(i)) { - final t? => _isRunning(t), - _ => false, - }, - lit: lit, - restColour: _restColourOf(i), - litColour: _litColourOf(i), - onTap: () => widget.onSelect(i), - ), - ); - }, - ), - if (hidden.isNotEmpty && - slotIndex == inline.length - 1) - SizedBox(width: 6.w), - ], - if (hidden.isNotEmpty) - _OverflowMenu( - trips: widget.trips, - hidden: hidden, - onSelect: widget.onSelect, - acceptedIds: widget.acceptedIds, - rejectedIds: widget.rejectedIds, - ), - ], - ), - ], + ), ), - ); - }, + ], ); }, ), ); } -} -class TripTabsHeader extends SliverPersistentHeaderDelegate { - final List trips; - final int selectedIndex; - final ValueChanged onSelect; - final Set acceptedIds; - final Set rejectedIds; - - /// Reserved height. Pass [extentFor] unless you have measured otherwise. - final double extent; - - const TripTabsHeader({ - required this.trips, - required this.selectedIndex, - required this.onSelect, - required this.extent, - this.acceptedIds = const {}, - this.rejectedIds = const {}, - }); - - /// Height to reserve for the bar at this context's text scale. + /// Every pill's measured width, by slot. /// - /// Sums what a tab is actually made of — outer margin, border, padding, one - /// line of label, the gap, the progress underline — then adds a deliberate - /// safety margin. Over-reserving is the safe direction; see the class note. - static double extentFor(BuildContext context) { - final scaler = MediaQuery.textScalerOf(context); - final labelLine = scaler.scale(12.5.sp) * 1.4; - final statusLine = scaler.scale(10.5.sp) * 1.4; - return 6 - .h // Container margin, 2 top + 4 bottom - + - 6 - .w // track padding, 3 top + 3 bottom - + - 5 - .h // lead-in above the label - + - labelLine + - 1 - .h // gap between label and status - + - statusLine + - 4 - .h // gap above the running underline - + - 3 - .h // the underline - + - 4 - .h // tail below it - + - 6; // safety, so a rounding error adds air rather than a clip + /// A pill is as wide as its label, so the travelling rule cannot be placed by + /// dividing the row into equal parts. Each pill reports its width on layout + /// and the rule is positioned from the running total — which is also what + /// lets it slide smoothly between two pills of different widths. + final Map _widths = {}; + + void _measure(int slot, double width) { + if (_widths[slot] == width) return; + _widths[slot] = width; + // After layout, so a rebuild here would be a build-during-build. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() {}); + }); } - @override - double get minExtent => extent; + /// The gap between pills, matching the `SizedBox` in the row. + double get _gap => 10.w; - @override - double get maxExtent => extent; - - @override - Widget build( - BuildContext context, - double shrinkOffset, - bool overlapsContent, - ) { - return _PinnedSurface( - // True exactly when stops have scrolled underneath the bar. That is the - // moment it stops being part of the page and starts floating over it, so - // it is the moment it needs an edge — without one the tabs and the top of - // a stop card blur into each other. - floating: overlapsContent || shrinkOffset > 0, - child: TripTabs( - trips: trips, - selectedIndex: selectedIndex, - onSelect: onSelect, - acceptedIds: acceptedIds, - rejectedIds: rejectedIds, - ), - ); + double _leftOf(double p) { + final i = p.floor(); + final f = p - i; + double x = 0; + for (var k = 0; k < i; k++) { + x += (_widths[k] ?? 0) + _gap; + } + // Between two pills, walk the gap in proportion. + return x + f * ((_widths[i] ?? 0) + _gap); } - @override - bool shouldRebuild(TripTabsHeader old) => - old.extent != extent || - old.selectedIndex != selectedIndex || - old.trips != trips || - old.acceptedIds != acceptedIds || - old.rejectedIds != rejectedIds; -} - -/// Opaque backing for the pinned bar, with an edge that appears only once -/// content is passing underneath. -class _PinnedSurface extends StatelessWidget { - final bool floating; - final Widget child; - - const _PinnedSurface({required this.floating, required this.child}); - - @override - Widget build(BuildContext context) { - return AnimatedContainer( - duration: const Duration(milliseconds: 160), - decoration: BoxDecoration( - // Opaque, or the stop cards show through the bar as they pass under - // it. And it is the CANVAS, not `ColorConstants.surface` — that token - // is the warm near-white the surface audit flagged as "warm, unlike - // everything else", and here it painted a cream band across the top - // of a cool grey page. The bar is the page holding its position, so - // it wears the page's own ground; the white segmented track stands on - // it at the full canvas step. - color: MilerSurface.canvas, - border: Border( - bottom: BorderSide( - color: floating ? ColorConstants.borderStrong : Colors.transparent, - width: 1, - ), - ), - ), - // Centred rather than stretched: the reserved extent over-estimates by - // design, so the spare pixels become air above and below instead of - // stretching the tabs into a shape they were not designed for. - child: Center(child: child), - ); + double _widthAt(double p) { + final i = p.floor(); + final f = p - i; + final a = _widths[i] ?? 0; + final b = _widths[i + 1] ?? a; + return a + (b - a) * f; } } -class _Tab extends StatelessWidget { - final String label; - final int percent; +/// One pill: the trip's name, and whether it is the one being worked. +/// +/// See [TripSelector] for why there is no count on it and no card around it. +class _TripTab extends StatelessWidget { + final int index; - /// False when the hub has not assigned this trip yet. - final bool assigned; - - /// The tab the rider is looking at. Used for semantics only — everything - /// painted follows [lit] instead, so the label tracks the pill rather than - /// flipping the instant it is tapped. - final bool selected; - - /// The trip actually under way. - final bool active; - - /// How close the travelling pill is, 0 → 1. + /// 0 → 1, how far the travelling rule is over this pill. final double lit; - /// Colour with the pill elsewhere, and colour with the pill on this tab. - final Color restColour; - final Color litColour; - final VoidCallback onTap; - const _Tab({ - required this.label, - required this.percent, - required this.selected, + /// Reports this pill's laid-out width, so the rule can be placed against + /// pills that are as wide as their labels rather than evenly divided. + final ValueChanged onMeasured; + + const _TripTab({ + required this.index, required this.lit, - required this.restColour, - required this.litColour, required this.onTap, - this.assigned = true, - this.active = false, + required this.onMeasured, }); @override Widget build(BuildContext context) { - final complete = assigned && percent >= 100; - - // ── One thing is marked, and it is the tab you are on ── + // ── Selection is colour and edge, not fill ── // - // Colour follows the pill. It briefly followed a separate "this trip is - // running" signal, and the result on a real screen was that Trip 1 sat - // permanently red and underlined while the rider stood on Trip 2 — two tabs - // claiming to be the important one, and the tap he had just made appearing - // to do nothing. A navigation control has to answer "where am I" before it - // answers anything else. - // - // Running is not lost, it is demoted: the tab still says "Active" in its - // status line, in whatever colour that tab's state calls for. That is the - // right weight for it — useful to know, not something to fight the selection - // for the eye. - final Color accent = Color.lerp(restColour, litColour, lit)!; - - final String status = !assigned - // "Not set" read as a chore waiting for the rider. The hub fills - // these slots; all this tab can honestly say is that nothing has - // arrived for it yet. - ? 'None yet' - : complete - ? 'Done' - : active - ? 'Active' - : percent > 0 - ? '$percent% done' - : '$percent%'; + // The selected pill goes *lighter* — white, with a brand edge and brand + // type — and the unselected ones carry the grey. That is the inverse of the + // usual segmented control, and it is right here because the page under it + // is white: a filled red pill would be the heaviest object on the screen, + // competing with the action at the foot of the card below. + final Color ink = Color.lerp( + ColorConstants.secondaryText, + ColorConstants.primary, + lit, + )!; + final Color fill = Color.lerp( + ColorConstants.tint(ColorConstants.slateText, 0.06), + ColorConstants.pureSurface, + lit, + )!; + final Color edge = Color.lerp( + Colors.transparent, + ColorConstants.primary.withValues(alpha: 0.55), + lit, + )!; return Semantics( button: true, - selected: selected, - label: assigned - ? '$label, $status${active ? ', in progress' : ''}' - : '$label, not assigned yet', - child: InkWell( + selected: lit >= 0.999, + label: 'Trip ${index + 1}', + excludeSemantics: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, onTap: onTap, - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox(height: 5.h), - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12.5.sp, - // A placeholder slot also gives up the headline weight — - // colour alone was not enough separation on a sunlit LCD. - fontWeight: assigned ? FontWeight.w700 : FontWeight.w600, - letterSpacing: -0.3, - color: accent, - fontFamily: FontConstants.fontFamily, + child: _Measured( + onMeasured: onMeasured, + child: AnimatedContainer( + duration: DesignConstants.motionState, + curve: Curves.easeOut, + height: TripSelector.pillHeight, + alignment: Alignment.center, + padding: EdgeInsets.symmetric(horizontal: 16.w), + decoration: BoxDecoration( + color: fill, + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + border: Border.all(color: edge, width: 1.4), + ), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + // Sentence case. `TRIP 1` set in caps is a label shouting a + // proper noun; the tab is the trip's name, and a name is written + // the way it is said. + 'Trip ${index + 1}', + maxLines: 1, + style: TextStyle( + fontSize: 14.5.sp, + height: 1.0, + // Weight carries the selection alongside the colour, so the + // state survives a greyscale screenshot and a rider with a + // colour-shifted screen. + fontWeight: lit > 0.5 ? FontWeight.w700 : FontWeight.w600, + letterSpacing: -0.1, + color: ink, + fontFamily: FontConstants.fontFamily, + ), ), ), - SizedBox(height: 1.h), - // Progress as words, not a bar. The underline is reserved for "this - // is the one you are on", and one mark cannot mean two things — but - // "how far through is each trip" is why tabs beat a dropdown here, - // so the figure has to survive somewhere legible. - Text( - status, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 10.5.sp, - fontWeight: FontWeight.w600, - color: accent, - fontFamily: FontConstants.fontFamily, - ), - ), - SizedBox(height: 4.h), - // Where the underline used to be drawn. It now travels with the - // pill, but the space it occupied is still reserved here so every - // segment keeps the height `TripTabsHeader.extentFor` reserves for - // it and nothing shifts. - SizedBox(height: 3.h), - SizedBox(height: 4.h), - ], + ), ), ), ); } } -/// "More" button holding trips 4+ when a hub assigns an unusual day. -class _OverflowMenu extends StatelessWidget { - final List trips; - final List hidden; - final ValueChanged onSelect; - final Set acceptedIds; - final Set rejectedIds; +/// Reports its child's laid-out width once, after layout. +/// +/// A `LayoutBuilder` cannot do this: it hands over the *incoming constraints*, +/// and these pills are unconstrained on the main axis — they are as wide as +/// their own text. So the width is read off the render object instead, in the +/// frame after it exists. +class _Measured extends SingleChildRenderObjectWidget { + final ValueChanged onMeasured; - const _OverflowMenu({ - required this.trips, + const _Measured({required this.onMeasured, required super.child}); + + @override + _MeasuredBox createRenderObject(BuildContext context) => + _MeasuredBox(onMeasured); + + @override + void updateRenderObject(BuildContext context, _MeasuredBox box) { + box.onMeasured = onMeasured; + } +} + +class _MeasuredBox extends RenderProxyBox { + ValueChanged onMeasured; + + _MeasuredBox(this.onMeasured); + + @override + void performLayout() { + super.performLayout(); + onMeasured(size.width); + } +} + +/// The way into trips four and beyond, on the rare day a hub assigns them. +class _OverflowChip extends StatelessWidget { + final double width; + final List hidden; + final List trips; + final ValueChanged onSelect; + + const _OverflowChip({ + required this.width, required this.hidden, + required this.trips, required this.onSelect, - required this.acceptedIds, - required this.rejectedIds, }); @override Widget build(BuildContext context) { - return Semantics( - button: true, - label: '${hidden.length} more trips', - child: Material( - color: Colors.white, - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + return SizedBox( + width: width, + child: Semantics( + button: true, + label: '${hidden.length} more trips', + excludeSemantics: true, child: PopupMenuButton( - onSelected: onSelect, - tooltip: 'More trips', - position: PopupMenuPosition.under, + tooltip: '', + padding: EdgeInsets.zero, + color: ColorConstants.pureSurface, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), - itemBuilder: (_) => [ + onSelected: onSelect, + itemBuilder: (context) => [ for (final i in hidden) PopupMenuItem( value: i, - child: Row( - children: [ - Text( - Trip.tabLabel(i), - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w700, - color: ColorConstants.slateText, - fontFamily: FontConstants.fontFamily, - ), - ), - SizedBox(width: 8.w), - Text( - trips.tripAt(i)?.slotLabel ?? 'Not assigned', - style: TextStyle( - fontSize: 11.5.sp, - fontWeight: FontWeight.w500, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, - ), - ), - ], - ), - ), - ], - child: Container( - width: 48.w, - alignment: Alignment.center, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - LucideIcons.chevronDown, - size: 22.sp, - color: ColorConstants.slateText, - ), - Text( - '+${hidden.length}', + child: Text( + // The one place a word is worth spending: a menu row has no + // shape of its own to carry the meaning. + 'Trip ${i + 1}', style: TextStyle( - fontSize: 11.sp, + fontSize: 15.sp, fontWeight: FontWeight.w700, - color: ColorConstants.secondaryText, + color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), - ], + ), + ], + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(20.r), + ), + child: Center( + child: Text( + '+${hidden.length}', + style: TextStyle( + fontSize: 17.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), ), ), ), diff --git a/lib/views/Dashboard/orders/orderstaus_button.dart b/lib/views/Dashboard/orders/orderstaus_button.dart index 8d2dfd1..c9cc7eb 100644 --- a/lib/views/Dashboard/orders/orderstaus_button.dart +++ b/lib/views/Dashboard/orders/orderstaus_button.dart @@ -148,7 +148,7 @@ class _OrderStatusRowState extends State { children: [ const MilerSheetHeader( title: 'Reject booking', - subtitle: 'The hub re-routes it once it knows why.', + subtitle: 'Your office re-routes it once it knows why.', ), const SizedBox(height: 14), for (String reason in reasons) @@ -208,7 +208,7 @@ class _OrderStatusRowState extends State { children: [ const MilerSheetHeader( title: 'Cancel this booking?', - subtitle: 'It returns to pending and the hub reassigns it.', + subtitle: 'It returns to pending and your office reassigns it.', ), const SizedBox(height: 14), CheckboxListTile( diff --git a/lib/views/Dashboard/pickups/card.dart b/lib/views/Dashboard/pickups/card.dart index 4773651..83512ea 100644 --- a/lib/views/Dashboard/pickups/card.dart +++ b/lib/views/Dashboard/pickups/card.dart @@ -816,12 +816,23 @@ class _CallButton extends StatelessWidget { /// the kitchen's. See [StopContact]. final String? semanticLabel; - const _CallButton({required this.phone, this.size, this.semanticLabel}); + /// A disc rather than a rounded square. The sheets draw it beside a round + /// avatar, where a square tile reads as a control belonging to something + /// else; the cards keep the square, which matches the tiles around it. + final bool round; + + const _CallButton({ + required this.phone, + this.size, + this.semanticLabel, + this.round = false, + }); @override Widget build(BuildContext context) { return _SquareIconButton( icon: LucideIcons.phone, + round: round, size: size ?? math.max(52.0, 52.w), // ── Green, and the same green everywhere a phone appears ── // @@ -833,9 +844,9 @@ class _CallButton extends StatelessWidget { // app he already uses — it is what the glyph means before the palette // gets a say — and "done" is carried by a tick and a word, not by a hue // on a handset. - background: ColorConstants.acceptGreen.withValues(alpha: 0.12), + background: Narrative.emeraldWash, border: Colors.transparent, - iconColor: ColorConstants.acceptGreen, + iconColor: Narrative.emerald, semanticLabel: semanticLabel ?? 'Call this stop', onTap: () async { // A dialer that fails to open is silent by default: the rider is at @@ -859,6 +870,7 @@ class _SquareIconButton extends StatelessWidget { final Color iconColor; final String semanticLabel; final VoidCallback onTap; + final bool round; const _SquareIconButton({ required this.icon, @@ -868,25 +880,29 @@ class _SquareIconButton extends StatelessWidget { required this.iconColor, required this.semanticLabel, required this.onTap, + this.round = false, }); @override Widget build(BuildContext context) { + final radius = BorderRadius.circular( + round ? DesignConstants.radiusFull : DesignConstants.radiusXl, + ); return Semantics( button: true, label: semanticLabel, child: Material( color: background, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + borderRadius: radius, child: InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + borderRadius: radius, child: Container( width: size, height: size, alignment: Alignment.center, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + borderRadius: radius, border: Border.all(color: border, width: 1.5), ), child: Icon(icon, color: iconColor, size: 22.sp), @@ -1044,71 +1060,21 @@ class _QueueHeading extends StatelessWidget { ), ), ], - if (order != null) ...[ - const Spacer(), - Flexible(child: _OrderProvenance(source: order!)), - ], + // ── The provenance chip is gone ── + // + // `As assigned` / `Nearest first` sat at the head of the queue, in a + // tinted pill with a glyph, telling the rider where the *ordering* of + // his list came from. It is a fact about this app's sorting, not + // about his work: he cannot act on it, he cannot change it, and on + // the common day it said the same three words every time he opened + // the tab. What he needs from that row is which trip and how far + // through it he is, and both are already on it. ], ), ); } } -/// `Hub route` / `Nearest first` — where the queue's order came from. -/// -/// Deliberately quiet. It is a provenance mark, not a warning: nearest-first -/// is a reasonable order when nobody has assigned one, and the rider does not -/// need alarming about it. What he needs is to not be told it is his route. -class _OrderProvenance extends StatelessWidget { - final RouteOrderSource source; - - const _OrderProvenance({required this.source}); - - @override - Widget build(BuildContext context) { - final assigned = source.isAdmin; - final tint = assigned - ? ColorConstants.acceptGreen - : ColorConstants.secondaryText; - - return Tooltip( - message: source.explanation, - child: Container( - padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h), - decoration: BoxDecoration( - color: tint.withValues(alpha: assigned ? 0.10 : 0.07), - borderRadius: BorderRadius.circular(DesignConstants.radiusFull), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - assigned ? LucideIcons.route : LucideIcons.navigation, - size: 11.sp, - color: tint, - ), - SizedBox(width: 4.w), - Flexible( - child: Text( - source.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 10.5.sp, - fontWeight: FontWeight.w700, - letterSpacing: 0.2, - color: tint, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - ], - ), - ), - ); - } -} - /// `Bag 1 · Joe` — the pairing, as one object. /// /// ── Why they are one line and not two facts ── diff --git a/lib/views/Dashboard/pickups/collect_payment.dart b/lib/views/Dashboard/pickups/collect_payment.dart index cb54113..989bfba 100644 --- a/lib/views/Dashboard/pickups/collect_payment.dart +++ b/lib/views/Dashboard/pickups/collect_payment.dart @@ -322,9 +322,10 @@ class _CollectPaymentPageState extends State { if (_due <= 0) return _nothingToCollect(); return Scaffold( - // White page, tinted panels — the same inversion the verification screens - // it follows now use. See the note there. - backgroundColor: ColorConstants.pureSurface, + // Grey page, white bands — the same shape the verification screens this + // follows now take, so the money step reads as the next page of one flow + // rather than a different screen. See [MilerBand]. + backgroundColor: ColorConstants.daylightSurface, // Same bar as the verification screens this follows: taller, clear of // the notch, title at a size that survives sunlight and arm's length. appBar: milerPageBar( @@ -336,18 +337,19 @@ class _CollectPaymentPageState extends State { Expanded( child: SingleChildScrollView( physics: const BouncingScrollPhysics(), - padding: EdgeInsets.fromLTRB(14.w, 14.h, 14.w, 24.h), + // The bands carry the gutter. See [MilerBand]. + padding: EdgeInsets.only(bottom: 24.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _amountCard(), - SizedBox(height: 14.h), + SizedBox(height: MilerBand.gap), _orderCard(), - SizedBox(height: 14.h), + SizedBox(height: MilerBand.gap), _methodCard(), - SizedBox(height: 14.h), + SizedBox(height: MilerBand.gap), _shortCard(), - SizedBox(height: 14.h), + SizedBox(height: MilerBand.gap), _custodyNote(), ], ), @@ -366,13 +368,8 @@ class _CollectPaymentPageState extends State { // door, and a number he can edit is a number he will be argued into // changing. Widget _amountCard() { - return Container( - width: double.infinity, - padding: EdgeInsets.fromLTRB(18.w, 20.h, 18.w, 18.h), - decoration: BoxDecoration( - color: ColorConstants.cardSurface, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - ), + return MilerBand( + padding: EdgeInsets.fromLTRB(MilerBand.pad, 20.h, MilerBand.pad, 18.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -822,13 +819,10 @@ class _CollectPaymentPageState extends State { // once, plainly, at the moment he takes it. Widget _custodyNote() { final onBehalf = _shipper.isNotEmpty ? _shipper : 'the shipper'; - return Container( - width: double.infinity, - padding: EdgeInsets.all(13.r), - decoration: BoxDecoration( - color: ColorConstants.tertiary.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - ), + return MilerBand( + color: ColorConstants.tint(ColorConstants.tertiary, 0.08), + rail: ColorConstants.tertiary, + padding: EdgeInsets.fromLTRB(MilerBand.pad, 13.h, MilerBand.pad, 13.h), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -837,7 +831,7 @@ class _CollectPaymentPageState extends State { Expanded( child: Text( 'This money belongs to $onBehalf, not to you and not to Miler. ' - 'Deposit it at the hub at the end of your shift.', + 'Hand it in at the end of your shift.', style: TextStyle( fontSize: 12.sp, height: 1.4, @@ -1049,13 +1043,7 @@ class _CollectPaymentPageState extends State { // ── Shared pieces ────────────────────────────────────────────────────── Widget _card({required String title, required Widget child}) { - return Container( - width: double.infinity, - padding: EdgeInsets.all(16.r), - decoration: BoxDecoration( - color: ColorConstants.cardSurface, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - ), + return MilerBand( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/views/Dashboard/pickups/delivery_actions.dart b/lib/views/Dashboard/pickups/delivery_actions.dart index 133f1e0..be53055 100644 --- a/lib/views/Dashboard/pickups/delivery_actions.dart +++ b/lib/views/Dashboard/pickups/delivery_actions.dart @@ -214,25 +214,142 @@ Future resolveConsignmentId(Map stop) async { /// (it left `Collected_By_Miler` some other way) and a lingering /// `IDEMPOTENCY_IN_PROGRESS` after [MilerApi] has waited the write out. /// Neither is answerable from the error itself. +/// Why the last [releaseForDelivery] said no, in the rider's words. +/// +/// ── One message was covering four different problems ── +/// +/// Every failure below returned a bare `false`, and the caller printed *"This +/// parcel isn't ready to go out yet. Check your connection and try again."* for +/// all of them. Three of the four have nothing to do with a connection, and +/// two of them cannot be fixed by trying again at all: +/// +/// • **No consignment.** The pickup never converted, or its id was lost. The +/// rider can retry this until his battery dies. +/// • **At a hub.** A cross-city parcel is `Inwarded_at_Hub` and will be +/// delivered by somebody else. There is nothing here for him to start. +/// • **Refused.** The server gave a reason and it was thrown away. +/// • **Unreachable.** The only one where "check your connection" is true. +/// +/// Telling a rider to check a working connection, about a parcel that is not +/// his to deliver, is how a screen teaches him to ignore what it says. Null +/// after a success. +String? lastReleaseFailure; + +/// Whether a "somebody else has this" hold is even possible on this rider's +/// line. +/// +/// ── The state that cannot happen, refusing the rider anyway ── +/// +/// `Inwarded_at_Hub`, `Tripsheet_Loaded` and `In_Transit` are the logistics +/// network's own rungs: a cross-city parcel is dropped at a collection point +/// and delivered by a different rider on a different day. That is a real hold +/// and it must keep blocking on the logistics line. +/// +/// On a meal run **it cannot happen at all.** Every DailyGrubs order is +/// hyperlocal — collected at the kitchen and carried straight to the door, one +/// rider, one leg, no network in between. So one of those states coming back +/// for a meal stop is not news about the food; it is a bad read, and the two +/// ways it happens are both known: a consignment reference that resolved to +/// the wrong record, and a row whose state belongs to a different booking. +/// +/// The rider was shown the hold anyway, mid-round, on a slider whose only job +/// is to set him off — and there was nothing he could do about it, because the +/// thing it described was not true of his stop. +/// +/// So a hold is only believed on a line that has somewhere to hold things. On +/// every other line the state is treated as unreadable, which sends the +/// question to the server: it is the judge of its own consignment, and one +/// refusal carrying its own reason beats a client-side refusal inventing one. +bool get _handoffHoldIsPossible => ServiceProfile.active.endsAtHub; + Future releaseForDelivery(Map stop) async { + lastReleaseFailure = null; + final consignmentId = await resolveConsignmentId(stop); if (consignmentId.isEmpty) { debugPrint('[MILKRUN] no consignment id for ${MilkRun.idOf(stop)}'); + lastReleaseFailure = + "This stop has no delivery reference yet, so it can't be started. " + 'Ask your office to check it — sliding again will not help.'; return false; } // What the list row already told us, before spending a request on it. var state = consignmentStateFromRaw(stop['consignmentstatus']); - if (state == ConsignmentState.unknown) { + // ── A row saying `Created` is the one row worth re-reading ── + // + // `Created` is the state a consignment holds for as long as it takes the + // pivot to route it, and `GET /miler/bookings` is a poll behind the rider by + // design. So a stop collected seconds ago can arrive on this tab carrying + // the state it had *before* `pickup-complete` finished with it, and every + // other state on the row is durable enough not to have that problem. + // + // Acting on it cost the rider the round: the guard below read it as hub + // custody and refused, and no amount of sliding cleared a row that only the + // next poll was going to correct. It is asked directly instead — one request, + // on the one state where the row is not evidence. + if (state == ConsignmentState.unknown || + state == ConsignmentState.created) { state = await ConsignmentGate.stateOf(consignmentId); } - if (state.isDeliverable || state.isDelivered) return true; - if (!state.needsRelease && state != ConsignmentState.unknown) { + if (state.isDeliverable || state.isDelivered) { + // ── Released without a request, and the trace has to say so ── + // + // In compatibility mode the pivot has already released the consignment, so + // Start ride spends nothing here and the console was *already* Active + // before the rider slid. A silent `true` made that indistinguishable from + // a release this press actually made, which is the exact ambiguity the + // production trace has to resolve. + debugPrint( + '[TRACE][START-RIDE] consignment=$consignmentId ' + 'state="${state.name}" startDeliveryCalled=false ' + 'reason=already-released-by-pickup-complete (compatibility mode)', + ); + return true; + } + // ── What may be *attempted*, as against what may be *claimed* ── + // + // This refused everything that was not `Collected_By_Miler` or unreadable, + // and `Created` fell into it wearing the hub's wording. Both halves were + // wrong for a parcel on the rider's own back. + // + // `Created` now goes to the server like `unknown` does, for the reason + // `unknown` does: the app is not the judge of a state it cannot fully + // account for, and one 400 carrying the backend's own sentence is worth more + // than a client-side refusal that invents one. A hyperlocal consignment the + // pivot has not finished releasing is let through; a hub-routed one is + // refused by the server, and the rider is told what the server said. + // A hold that cannot exist on this line is a bad read, not a fact about the + // stop. See [_handoffHoldIsPossible]. + final heldElsewhere = state.awaitsHub && _handoffHoldIsPossible; + if (state.awaitsHub && !_handoffHoldIsPossible) { + debugPrint( + '[MILKRUN] $consignmentId read as ${state.name} on a line with no ' + 'handoff — treating as unreadable and asking the server', + ); + } + if (!state.needsRelease && + !state.awaitsHubInward && + !(state.awaitsHub && !_handoffHoldIsPossible) && + state != ConsignmentState.unknown) { debugPrint('[MILKRUN] $consignmentId is ${state.name} — not releasable'); + lastReleaseFailure = heldElsewhere + // The honest answer for a cross-city parcel: it is not his round, and + // saying so without naming a building he has never been to. + ? "This one has already been handed on — it's not yours to deliver." + : 'This parcel cannot be started yet. Ask your office to check it.'; return false; } + debugPrint( + '[TRACE][START-RIDE] consignment=$consignmentId state="${state.name}" ' + 'POST /miler/consignments/$consignmentId/start-delivery — calling', + ); final res = await MilerApi.startDelivery(consignmentId); + debugPrint( + '[TRACE][START-RIDE] consignment=$consignmentId startDeliveryCalled=true ' + '-> ${res.status} ${res.code} ok=${res.ok} raw=${res.raw}', + ); if (res.ok) return true; debugPrint( @@ -242,7 +359,16 @@ Future releaseForDelivery(Map stop) async { // Already on the road, or already handed over — either way this press is // not what stands between the rider and the door. final after = await ConsignmentGate.stateOf(consignmentId); - return after.isDeliverable || after.isDelivered; + if (after.isDeliverable || after.isDelivered) return true; + + // `status: 0` is the request never leaving; anything else is the server + // answering, and its own words beat a guess about the network. + lastReleaseFailure = res.status == 0 + ? 'Could not reach your office. Check your connection and slide again.' + : (res.message.trim().isNotEmpty + ? res.message.trim() + : 'Your office would not start this delivery.'); + return false; } /// Brings this device into line with a delivery the server already has. @@ -386,15 +512,42 @@ Future?> _closeDelivery( } return {'outcome': 'completed', 'reconciled': true}; - case DeliverGate.awaitingHub: - // The guard that must survive: a logistics consignment sitting at a - // hub genuinely is not this rider's to hand over. - debugPrint('[DELIVERY] $orderId is with the hub — refusing'); + case DeliverGate.awaitingInward: + // Converted but never released and never inwarded. It is in his box, + // so the hub-hold sentence below would be false — but `deliver` will + // refuse it, so saying nothing and posting anyway would put a 400 on + // screen with no explanation attached. + debugPrint('[DELIVERY] $orderId is Created — not on the road yet'); if (context.mounted) { AppFeedback.error( context, - 'This parcel is still with the hub. It can be delivered once the ' - 'hub releases it.', + "This parcel hasn't been released for delivery yet. Slide to " + 'start the ride first, or ask your office to check it.', + ); + } + return null; + + case DeliverGate.awaitingHub: + // The guard that must survive: a logistics consignment inside the + // network genuinely is not this rider's to hand over. + // + // But only on a line that HAS a network. The same bad read that hit + // the Start-ride slider reaches this branch too, and refusing here + // is worse — the rider is at the door with the food in his hand. + // See [_handoffHoldIsPossible]. + if (!_handoffHoldIsPossible) { + debugPrint( + '[DELIVERY] $orderId read as held on a line with no handoff — ' + 'ignoring the read and letting the server judge', + ); + break; + } + debugPrint('[DELIVERY] $orderId is held elsewhere — refusing'); + if (context.mounted) { + AppFeedback.error( + context, + "This one has already been handed on, so it can't be delivered " + 'from here.', ); } return null; @@ -441,9 +594,18 @@ Future?> _closeDelivery( } } - if (outcome != DeliveryOutcome.cancelled && consignmentId.isEmpty) { - // Cancel is a *booking* route, so it is the one outcome that still works - // without a consignment. + if (outcome == DeliveryOutcome.delivered && consignmentId.isEmpty) { + // ── Only `deliver` needs a consignment ── + // + // This guard used to catch **skip** as well, and skip is the one thing a + // rider does when a delivery goes wrong — so the outcome he reaches for + // precisely when something is already wrong was the one refused with "this + // order's delivery reference is missing". Nothing he could do cleared it. + // + // Cancel was already exempt: it is a *booking* route. Skip now has one too + // (`POST /miler/bookings/:id/skip`, shipped 24 Aug), so both work without a + // consignment and only the hand-over — which genuinely keys on one — is + // held here. // // Reaching here means four sources came back empty, which is no longer a // client-side gap: it means the pickup never converted this booking. The @@ -492,7 +654,7 @@ Future?> _closeDelivery( context, collected ? "This order's delivery reference is missing, so it cannot be " - 'completed from the app. Ask your hub to check it — ' + 'completed from the app. Ask your office to check it — ' 'collecting it again will not help.' : 'This order was never picked up on the system — mark it picked ' 'up from Home first, then deliver.', @@ -516,6 +678,38 @@ Future?> _closeDelivery( // No fix, and the outcome still has to be recordable. See above. } + // ── The photograph leaves the phone ── + // + // It never could: `deliver` takes `photourl`, which wants a URL, and nothing + // on the contract accepted an upload — so proof of delivery lived in the + // app's own directory and died with the next reinstall, on the one record + // somebody asks about weeks later. + // + // `POST /miler/uploads/sign` (24 Aug) closes it: sign, PUT the bytes, send + // the public URL back on the delivery. + // + // **A failed upload never blocks a hand-over.** The parcel is in the + // customer's hands whatever the network did, and `deliver` accepts an empty + // `photourl`. The local copy is kept either way, so a failure costs the hub + // its copy and costs the rider nothing. + String proofUrl = ''; + if (outcome == DeliveryOutcome.delivered && proofPath.isNotEmpty) { + final uploaded = await MilerApi.uploadProof( + File(proofPath), + purpose: MilerApi.proofDelivery, + consignmentId: consignmentId.isEmpty ? null : consignmentId, + ); + if (uploaded != null) { + proofUrl = uploaded; + } else { + ApiConfig.logGap( + 'deliver', + 'the proof photo for $orderId could not be uploaded; the delivery is ' + 'being recorded without one and the copy stays on the device.', + ); + } + } + bool ok = false; try { switch (outcome) { @@ -523,6 +717,7 @@ Future?> _closeDelivery( ok = await dc.updateDeliveredStatus( pickupId: pickupIdInt, consignmentId: consignmentId, + proofImage: proofUrl, deliveredToName: (stop['dropcustomer'] ?? stop['pickupcustomer'] ?? @@ -544,15 +739,55 @@ Future?> _closeDelivery( // fall back to the booking id and skip the wrong thing. // // The pickup-leg skip keeps that method; this is the delivery leg's. - final res = await MilerApi.skipConsignment( - consignmentId, - reason: notes.isEmpty ? 'Skipped by rider' : notes, - lat: riderFix?.latitude, - lon: riderFix?.longitude, - ); + // + // ── Two routes, chosen by what the stop actually has ── + // + // With a consignment the skip belongs to it — that is the attempt + // counter the hub reads. Without one, the booking route is not a + // fallback but the *correct* call: a stop with no consignment has not + // been converted, so there is nothing on the delivery side to bump. + // Posting an empty id to `/consignments//skip` — which is what this + // did whenever the id was unreachable — 404s and tells the rider his + // own stop is broken. + final res = consignmentId.isEmpty + ? await MilerApi.skipBooking( + stop['bookingid'] ?? stop['orderheaderid'] ?? orderId, + reason: notes.isEmpty ? 'Skipped by rider' : notes, + lat: riderFix?.latitude, + lon: riderFix?.longitude, + ) + : await MilerApi.skipConsignment( + consignmentId, + reason: notes.isEmpty ? 'Skipped by rider' : notes, + lat: riderFix?.latitude, + lon: riderFix?.longitude, + ); ok = res.ok; if (!ok) debugPrint('[DELIVERY][skip] ${res.status} ${res.message}'); + // ── The attempt counter is the hub's, and it has a ceiling ── + // + // `skip` returns `attemptcount` and it is the source of truth: the + // consignment stays `Out_for_Delivery` and the parcel stays in the + // rider's hands, so nothing here may close the stop. At **3** the + // backend raises an Undeliverable exception for the hub, and there is + // no automated return or reassignment behind it — a human picks it up + // from there. Saying so is the difference between a rider trying a + // fourth time and a rider ringing the hub. + if (ok) { + final body = res.data; + final attempts = body is Map + ? int.tryParse('${body['attemptcount'] ?? ''}') ?? 0 + : 0; + if (attempts >= 3 && context.mounted) { + AppFeedback.info( + context, + 'Third attempt on this stop — your office has been told and will ' + 'take it from here.', + ); + } + } + case DeliveryOutcome.cancelled: ok = await dc.updateCancelledStatus( pickupId: pickupIdInt, @@ -574,10 +809,26 @@ Future?> _closeDelivery( // show a finished screen while the office still had the order live. The // rider keeps the stop and gets told why. if (context.mounted) { + // ── Cancel is a booking route, and a collected parcel is past it ── + // + // `POST /miler/bookings/:id/cancel` is refused once the stop is picked + // up — by design: cancelling releases the booking for reassignment, and + // a parcel already in a rider's box cannot be handed to somebody else. + // The generic "could not record — try again" invited exactly the retry + // that can never work, so the one outcome with a *permanent* reason says + // it. + final collectedAlready = + outcome == DeliveryOutcome.cancelled && + (stopStatusOf(stop).isPicked || stopStatusOf(stop).isDeliveryLeg); + AppFeedback.error( context, - dc.lastBlockedReason ?? - 'Could not record ${outcome.pastTense.toLowerCase()} — try again', + collectedAlready + ? 'You are already carrying this parcel, so it cannot be ' + 'cancelled from the app. Skip it to try again later, or ask ' + 'your office to cancel it.' + : dc.lastBlockedReason ?? + 'Could not record ${outcome.pastTense.toLowerCase()} — try again', ); } return null; diff --git a/lib/views/Dashboard/pickups/delivery_proof_page.dart b/lib/views/Dashboard/pickups/delivery_proof_page.dart index 9f9ee72..fc8ebf8 100644 --- a/lib/views/Dashboard/pickups/delivery_proof_page.dart +++ b/lib/views/Dashboard/pickups/delivery_proof_page.dart @@ -11,6 +11,8 @@ 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_app_bar.dart'; +import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart'; +import 'package:miler/views/helpers/constants/narrative.dart'; /// ───────────────────────────────────────────────────────────────────────── /// PROOF OF DELIVERY @@ -42,6 +44,37 @@ import 'package:miler/views/helpers/widgets/miler_app_bar.dart'; /// without a photo, and null when he backed out entirely — three outcomes the /// caller can tell apart. /// ───────────────────────────────────────────────────────────────────────── +/// How a stop ended, as this page reports it. +/// +/// The page is reached by sliding **deliver**, so *delivered* is the answer it +/// exists to collect. The other two are the answers a rider only has once he is +/// standing at the door — and the door is the last place he should have to back +/// out of a screen to record one. +enum ProofOutcome { + /// Handed over. Carries the photograph, when there is one. + delivered, + + /// Not this time — a return visit. The parcel stays on board. + skipped, + + /// It is not going to happen. The order ends here. + cancelled, +} + +/// What [DeliveryProofPage] pops: the outcome, and the proof if the outcome +/// was a hand-over. +class ProofResult { + final ProofOutcome outcome; + + /// Where the photograph was saved. `''` means the rider completed without + /// one — a camera that would not open, a customer who objected — which is a + /// real answer and not a failure. Always `''` on the other two outcomes: + /// there is nothing to photograph about a delivery that did not happen. + final String proofPath; + + const ProofResult(this.outcome, [this.proofPath = '']); +} + class DeliveryProofPage extends StatefulWidget { /// Names the stop on the page, so the rider can see he is closing the right /// one before he commits. @@ -56,6 +89,55 @@ class DeliveryProofPage extends StatefulWidget { required this.orderId, }); + /// The two answers a rider has when the hand-over did not happen. + /// + /// ── Public, because this page is no longer the only door ── + /// + /// It was private and reachable only from the link under **Mark as + /// delivered**, which put Skip and Cancelled two taps below a control the + /// rider has to slide *first*. A rider at a locked gate had to commit to + /// delivering before he could say he had not. + /// + /// The map sheet offers it beside its own slider now, so the answer is + /// available at the moment the rider learns it. One sheet, two entry points: + /// the wording, the order and the accents cannot drift between them, and a + /// third outcome added here appears in both places at once. + static Future askOutcome(BuildContext context) { + return showMilerSheet( + context, + builder: (ctx) => MilerSheetScaffold( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const MilerSheetHeader( + title: 'What happened at this stop?', + subtitle: 'This is recorded with the office straight away.', + ), + SizedBox(height: 8.h), + MilerSheetChoiceRow( + label: 'Skip — keep it on board and try again later', + icon: LucideIcons.clock, + accent: ColorConstants.warning, + selected: false, + showRule: false, + onTap: () => Navigator.pop(ctx, ProofOutcome.skipped), + ), + MilerSheetChoiceRow( + label: 'Cancelled — this order will not be delivered', + icon: LucideIcons.circleX, + accent: ColorConstants.errorRed, + selected: false, + showRule: true, + onTap: () => Navigator.pop(ctx, ProofOutcome.cancelled), + ), + SizedBox(height: 4.h), + ], + ), + ), + ); + } + @override State createState() => _DeliveryProofPageState(); } @@ -104,7 +186,27 @@ class _DeliveryProofPageState extends State { // A failed copy must not block the hand-over — the delivery is the thing // that matters, and the caller is told there is no proof rather than // being handed a path that resolves to nothing. - Navigator.of(context).pop(saved ?? ''); + Navigator.of(context).pop(ProofResult(ProofOutcome.delivered, saved ?? '')); + } + + /// ── The two ways a stop ends without a hand-over ── + /// + /// They used to live under the slide on the map sheet, which is one screen + /// too early: a rider slides *deliver* because he intends to deliver, and he + /// finds out he cannot at the door — on this page. Backing out of here to + /// reach them meant the only way to report a failure was to abandon the + /// screen that records outcomes. + /// + /// Behind a link and on a sheet, not as two more buttons: both are + /// irreversible and one of them ends the order, and this page is held + /// one-handed at a doorstep. The happy path keeps the only filled control. + Future _cannotDeliver() async { + if (_busy) return; + final outcome = await DeliveryProofPage.askOutcome(context); + if (!mounted || outcome == null) return; + // No photograph travels with either of these: there is nothing to + // photograph about a delivery that did not happen. + Navigator.of(context).pop(ProofResult(outcome)); } @override @@ -120,12 +222,15 @@ class _DeliveryProofPageState extends State { children: [ Expanded( child: SingleChildScrollView( - padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 8.h), + // Bands, like the verification screen this page belongs + // beside: full-bleed white on the page's ground, the gap + // between them the only separator. See [MilerBand]. + padding: EdgeInsets.only(bottom: 8.h), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _who(), - SizedBox(height: 16.h), + _who(hasShot), + SizedBox(height: MilerBand.gap), hasShot ? _preview() : _empty(), ], ), @@ -138,10 +243,23 @@ class _DeliveryProofPageState extends State { ); } - /// Which stop this closes. Named, because the one mistake this page can - /// enable is proving the wrong delivery. - Widget _who() => GlassCard( - padding: EdgeInsets.fromLTRB(16.w, 14.h, 16.w, 14.h), + /// Which stop this closes, and the way to re-take the photo. + /// + /// ── Retake came up here from the foot of the page ── + /// + /// It sat at the bottom as a full-width outlined button beside **Mark as + /// delivered**, and that pairing was wrong twice over. It gave a *correction* + /// the same weight and the same row as the act that closes the order, on a + /// page whose one job is to close the order — so the rider's thumb had two + /// equal targets to choose between at the moment he least wants a choice. + /// And it put the control a screen away from the thing it controls: the photo + /// is at the top, and the button that replaces it was at the bottom. + /// + /// It is a small round control on this band now, next to the person being + /// handed to. Reachable, obviously secondary, and beside the shot it retakes. + /// The bottom of the page is one button again. + Widget _who(bool hasShot) => MilerBand( + padding: EdgeInsets.fromLTRB(MilerBand.pad, 14.h, MilerBand.pad, 14.h), child: Row( children: [ Container( @@ -149,7 +267,7 @@ class _DeliveryProofPageState extends State { height: 40.w, alignment: Alignment.center, decoration: BoxDecoration( - color: ColorConstants.acceptGreen.withValues(alpha: 0.12), + color: ColorConstants.tint(ColorConstants.acceptGreen, 0.12), borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), child: Icon( @@ -189,108 +307,164 @@ class _DeliveryProofPageState extends State { ], ), ), + // Only once there is a shot to replace. Before that, taking the first + // one is the *primary* thing to do and it has the big target in the + // middle of the page — a second camera control up here would be the + // same action offered twice. + if (hasShot) ...[ + SizedBox(width: 10.w), + Semantics( + button: true, + label: 'Retake the photo', + excludeSemantics: true, + child: GestureDetector( + onTap: _busy ? null : _capture, + child: Container( + width: 46.w, + height: 46.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: ColorConstants.tint(ColorConstants.slateText, 0.07), + shape: BoxShape.circle, + border: Border.all(color: ColorConstants.borderSubtle), + ), + child: Icon( + LucideIcons.rotateCw, + size: 20.sp, + color: ColorConstants.slateText, + ), + ), + ), + ), + ], ], ), ); /// The shot, at the size a judgement can be made from. - Widget _preview() => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - child: AspectRatio( - aspectRatio: 3 / 4, - child: Image.file( - File(_shotPath!), - fit: BoxFit.cover, - errorBuilder: (_, _, _) => ColoredBox( - color: ColorConstants.cardSurface, - child: Center( - child: Icon( - LucideIcons.imageOff, - size: 32.sp, - color: ColorConstants.secondaryText, + Widget _preview() => MilerBand( + padding: EdgeInsets.fromLTRB(MilerBand.pad, 16.h, MilerBand.pad, 16.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + child: AspectRatio( + aspectRatio: 3 / 4, + child: Image.file( + File(_shotPath!), + fit: BoxFit.cover, + errorBuilder: (_, _, _) => ColoredBox( + color: ColorConstants.cardSurface, + child: Center( + child: Icon( + LucideIcons.imageOff, + size: 32.sp, + color: ColorConstants.secondaryText, + ), ), ), ), ), ), - ), - SizedBox(height: 12.h), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - LucideIcons.circleCheck, - size: 16.sp, - color: ColorConstants.acceptGreen, - ), - SizedBox(width: 6.w), - Flexible( - child: Text( - 'Photo captured — it stays on this record', - maxLines: 1, - overflow: TextOverflow.ellipsis, + SizedBox(height: 12.h), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + LucideIcons.circleCheck, + size: 16.sp, + color: ColorConstants.acceptGreen, + ), + SizedBox(width: 6.w), + Flexible( + child: Text( + 'Photo captured — it stays on this record', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ], + ), + ); + + /// Before the first shot, or after the rider dismissed the camera. + /// + /// ── The whole band opens the camera ── + /// + /// It was inert artwork, and the way back to the camera was an **Open + /// camera** button at the foot of the page. That button is gone — the foot of + /// the page is one big *Mark as delivered* now — so this had to become the + /// control rather than a picture of one, or a rider who dismissed the camera + /// would have no way back to it at all. + /// + /// It is the better target anyway: a whole band in the middle of the screen, + /// where the eye already is, instead of a third of a row at the bottom. + Widget _empty() => Semantics( + button: !_cameraFailed, + label: _cameraFailed + ? 'Camera unavailable. You can still complete this delivery.' + : 'Take a photo of the handover', + excludeSemantics: true, + child: GestureDetector( + onTap: _busy || _cameraFailed ? null : _capture, + child: MilerBand( + padding: EdgeInsets.fromLTRB(MilerBand.pad, 32.h, MilerBand.pad, 32.h), + child: Column( + children: [ + Container( + width: 84.w, + height: 84.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: ColorConstants.tint(ColorConstants.primary, 0.08), + shape: BoxShape.circle, + ), + child: Icon( + _cameraFailed ? LucideIcons.imageOff : LucideIcons.camera, + size: 38.sp, + color: ColorConstants.primary, + ), + ), + SizedBox(height: 16.h), + Text( + _cameraFailed + ? 'Camera unavailable' + : 'Take a photo of the handover', + textAlign: TextAlign.center, style: TextStyle( - fontSize: 13.sp, - fontWeight: FontWeight.w600, + fontSize: 17.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.4, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 6.h), + Text( + _cameraFailed + ? 'You can still complete this delivery without a photo.' + : 'The parcel at the door, or with the person taking it.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13.5.sp, + height: 1.45, + fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), - ), - ], + ], + ), ), - ], - ); - - /// Before the first shot, or after the rider dismissed the camera. - Widget _empty() => GlassCard( - padding: EdgeInsets.fromLTRB(20.w, 32.h, 20.w, 32.h), - child: Column( - children: [ - Container( - width: 72.w, - height: 72.w, - alignment: Alignment.center, - decoration: BoxDecoration( - color: ColorConstants.primary.withValues(alpha: 0.08), - shape: BoxShape.circle, - ), - child: Icon( - _cameraFailed ? LucideIcons.imageOff : LucideIcons.camera, - size: 32.sp, - color: ColorConstants.primary, - ), - ), - SizedBox(height: 16.h), - Text( - _cameraFailed ? 'Camera unavailable' : 'Take a photo of the handover', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 17.sp, - fontWeight: FontWeight.w700, - letterSpacing: -0.4, - color: ColorConstants.slateText, - fontFamily: FontConstants.fontFamily, - ), - ), - SizedBox(height: 6.h), - Text( - _cameraFailed - ? 'You can still complete this delivery without a photo.' - : 'The parcel at the door, or with the person taking it.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13.5.sp, - height: 1.45, - fontWeight: FontWeight.w500, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, - ), - ), - ], ), ); @@ -313,34 +487,56 @@ class _DeliveryProofPageState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Row( - children: [ - Expanded( - child: MilerButton( - label: hasShot ? 'Retake' : 'Open camera', - icon: hasShot ? LucideIcons.rotateCw : LucideIcons.camera, - variant: MilerButtonVariant.outlined, - color: ColorConstants.slateText, - height: ButtonSizes.primary, - onPressed: _busy ? null : _capture, - ), + // ── One button, and it is the size of the decision ── + // + // This row held two: an outlined **Retake** at one third and **Mark as + // delivered** at two thirds. Retake has gone up beside the photo it + // replaces (see [_who]), which leaves the act that closes the order + // alone at the foot of the page with the whole width to itself. + // + // 72pt rather than the app's ordinary 56. It is the last thing a rider + // does at a door, one-handed, often holding a parcel with the other — + // and it is the only control here, so nothing is crowded by giving it + // the room. The same height as the slide control on the map sheet, for + // the same reason: see [_SlideAction.height]. + SizedBox( + height: 72.h, + child: MilerButton( + label: 'Mark as delivered', + icon: LucideIcons.circleCheck, + color: ColorConstants.acceptGreen, + height: 72.h, + loading: _busy, + onPressed: _busy ? null : _confirm, + ), + ), + // ── The way out, under the way forward ── + // + // Quiet, centred, and second: a rider who cannot hand over is the + // exception, and an exception must never be as easy to hit as the + // happy path. It is also the only route to Skip and Cancelled in the + // app, so it cannot be hidden either — a link under the button is + // where both of those are true at once. + SizedBox(height: 4.h), + TextButton( + onPressed: _busy ? null : _cannotDeliver, + style: TextButton.styleFrom( + minimumSize: Size(double.infinity, ButtonSizes.minTapTarget), + padding: EdgeInsets.zero, + ), + child: Text( + "Couldn't hand it over?", + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w600, + letterSpacing: -0.1, + color: Narrative.inkMuted, + fontFamily: FontConstants.fontFamily, ), - SizedBox(width: 10.w), - Expanded( - flex: 2, - child: MilerButton( - label: 'Mark as delivered', - icon: LucideIcons.circleCheck, - color: ColorConstants.acceptGreen, - height: ButtonSizes.primary, - loading: _busy, - onPressed: _busy ? null : _confirm, - ), - ), - ], + ), ), if (!hasShot) ...[ - SizedBox(height: 8.h), + SizedBox(height: 4.h), Text( 'No photo attached to this delivery', textAlign: TextAlign.center, diff --git a/lib/views/Dashboard/pickups/done.dart b/lib/views/Dashboard/pickups/done.dart index 0de31c1..430ece8 100644 --- a/lib/views/Dashboard/pickups/done.dart +++ b/lib/views/Dashboard/pickups/done.dart @@ -710,7 +710,7 @@ class _PickupsDoneState extends State ), SizedBox(height: 8.h), Text( - 'Head back to the hub to drop the parcels and hand in any cash ' + 'Head back to base to drop the parcels and hand in any cash ' 'you collected.', textAlign: TextAlign.center, style: TextStyle( @@ -753,7 +753,7 @@ class _PickupsDoneState extends State // A milk run ends at the last door, not at a depot — see // [ServiceProfile.endsAtHub]. Capability, not screen position. : (ServiceProfile.active.endsAtHub - ? 'Return to hub' + ? 'Return to base' : 'Finish my round'), icon: hasNext ? LucideIcons.arrowRight @@ -980,7 +980,7 @@ class _PickupsDoneState extends State label: hasNext ? 'Move to next stop' : (ServiceProfile.active.endsAtHub - ? 'Return to hub' + ? 'Return to base' : 'Finish my round'), icon: hasNext ? LucideIcons.arrowRight diff --git a/lib/views/Dashboard/pickups/map.dart b/lib/views/Dashboard/pickups/map.dart index 4302f75..0af3384 100644 --- a/lib/views/Dashboard/pickups/map.dart +++ b/lib/views/Dashboard/pickups/map.dart @@ -62,7 +62,7 @@ class _PickupMapScreenState extends State<_PickupMapScreen> /// Clears the in-flight flag **and rebuilds**. /// - /// `_buildSwipeButton` renders a spinner in place of Navigate / I've arrived + /// `_buildPrimaryAction` renders a spinner in place of the one button /// while this is true, and every exit from `_startPickupNavigation` except /// the happy one used to clear it with a bare assignment. No rebuild followed, /// so the map sat there spinning with no buttons — a screen that looks hung @@ -1157,14 +1157,50 @@ class _PickupMapScreenState extends State<_PickupMapScreen> // has, and it is why that sheet reads at arm's length. child: ListView( controller: scrollController, - padding: EdgeInsets.fromLTRB(20.w, 18.h, 20.w, 8.h), + padding: EdgeInsets.fromLTRB(20.w, 16.h, 20.w, 8.h), children: [ _LiveEtaHeader( meters: _liveMetersToStop, speedMps: _livePosition?.speed, live: _livePosition != null, + liveLabel: _isDeliveryLeg ? 'Picked up' : 'Active', ), - SizedBox(height: 16.h), + SizedBox(height: 18.h), + // ── Where the stop is in its life, as a line ── + // + // The sheet said how far away the door was and what to + // press, and never *where in the job* the rider was. The + // hairline that used to sit here was doing nothing but + // separating two blocks; a three-step rail separates + // them and answers the question at the same time — which + // is the whole difference between chrome and + // information. + _StageRail( + labels: _isDeliveryLeg + ? const ['Collected', 'On the way', 'Delivered'] + : const ['Accepted', 'On the way', 'Picked up'], + // Carrying it · riding · handing over, drawn. See + // [_StageRail] for why the words came off. + icons: _isDeliveryLeg + ? const [ + LucideIcons.package, + LucideIcons.bike, + LucideIcons.house, + ] + : const [ + LucideIcons.clipboardCheck, + LucideIcons.bike, + LucideIcons.package, + ], + current: + (_isDeliveryLeg ? _setOff : _hasOpenedNavigation) + ? 1 + : 0, + ), + SizedBox(height: 18.h), + // The rail answers "where am I in this job"; everything + // below answers "whose job is it". Two subjects, one + // hairline. Divider( height: 1, thickness: 1, @@ -1172,8 +1208,8 @@ class _PickupMapScreenState extends State<_PickupMapScreen> ), SizedBox(height: 16.h), _buildStopBrief(), - SizedBox(height: 24.h), - _buildSwipeButton(), + SizedBox(height: 20.h), + _buildPrimaryAction(), ], ), ), @@ -1233,6 +1269,13 @@ class _PickupMapScreenState extends State<_PickupMapScreen> delivery: MilkRun.navigatesToCustomer(widget.pickup), ); + // ── One control in this corner, and it is the phone ── + // + // A navigate tile sat beside it for a while. Two discs in the corner of an + // identity row is a toolbar, and the second one duplicated what the slide + // at the foot of the sheet already does — sliding Start delivery hands off + // to Maps by itself. Calling is the only thing here the primary control + // cannot do. return _StopBrief( pickup: widget.pickup, trailing: contact.isEmpty @@ -1240,23 +1283,91 @@ class _PickupMapScreenState extends State<_PickupMapScreen> : _CallButton( phone: contact.number, size: 46.w, + round: true, semanticLabel: contact.action, ), ); } - Widget _buildSwipeButton() { + /// True when the job at this stop is the hand-over rather than the + /// collection — the leg that starts and ends with this sheet's one button. + bool get _isDeliveryLeg => MilkRun.workingKind( + widget.pickup, + collectedIds: widget.parentState?._collectedIds ?? const {}, + ).isDelivery; + + /// True once the rider has set off from **this screen** — slid Start + /// delivery, or opened navigation on the collection leg. + /// + /// ── Why this is not read from the consignment ── + /// + /// It was, and it was wrong in the common case. `pickup-complete` already + /// leaves a hyperlocal consignment `Out_for_Delivery` on today's backend, so + /// the row arrives at this screen with nothing left to release — and the + /// sheet opened straight on *Slide to deliver*, skipping the setting-off + /// step entirely. A rider who has not left the kitchen was being offered the + /// control that closes the order. + /// + /// The consignment answers "may this be delivered", which is [_startDelivery]'s + /// business and is asked there. What the sheet needs to know is whether *he* + /// has set off, and only this screen sees that. + bool _setOff = false; + + /// ───────────────────────────────────────────────────────────────────── + /// ONE BUTTON, AND IT IS ALWAYS THE NEXT THING TO DO + /// + /// The foot of this sheet used to carry two controls side by side — a + /// **Navigate** that leaves the app, and beside it whatever advanced the + /// stop. That is one control too many on the screen a rider looks at while + /// moving: two filled buttons of similar weight, only one of which is the + /// thing he came here to do, and the answer to "what now?" written twice. + /// + /// There is one primary control now, and which one it is depends only on + /// where the stop stands: + /// + /// ``` + /// delivery not set off → ⟶ slide to start ride + /// (releases the load, then opens Maps) + /// on the road → ⟶ slide to deliver (photo optional) + /// pickup not started → ⟶ slide to start ride (opens Maps) + /// on the road → ⟶ slide to say you have arrived + /// ``` + /// + /// ── And all four of them slide ── + /// + /// The delivery leg slid and the collection leg tapped, on the reasoning that + /// only a delivery's two acts are writes the rider cannot take back, and that + /// a slide is friction worth paying only for those. + /// + /// The reasoning was about the *write*. What decides the control is the + /// *hand*: this is the one sheet in the app that is looked at while moving, + /// with the phone on a mount or held against a bar, and a 56pt tap target at + /// the foot of it is how any of these four get fired by a knuckle, a glove or + /// a pocket. Arriving is not a small mistake either — it stamps a time the + /// hub schedules against, and it is the one a bumped screen will fire while + /// the rider is still two streets away. + /// + /// So the foot of this sheet is one control with one gesture, whichever leg + /// is running. That is worth more than matching each act's weight exactly: + /// a rider who has to work out which of two shapes today's stop is wearing + /// before he can act is paying more than the slide ever costs him. Both + /// remain reachable by a screen reader's double tap — see [MilerSlideAction]. + /// ───────────────────────────────────────────────────────────────────── + Widget _buildPrimaryAction() { if (_isNavigating) { + // The same footprint as the control it stands in for — see + // [MilerSlideAction.height]. A busy state a size smaller than the thing it + // replaces makes the sheet jump every time the rider commits. return Container( - height: ButtonSizes.primary, + height: MilerSlideAction.height, decoration: BoxDecoration( color: ColorConstants.acceptGreen, - borderRadius: BorderRadius.circular(ButtonSizes.radius), + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), ), child: const Center( child: SizedBox( - width: 20, - height: 20, + width: 24, + height: 24, child: CircularProgressIndicator( color: Colors.white, strokeWidth: 2.5, @@ -1268,118 +1379,318 @@ class _PickupMapScreenState extends State<_PickupMapScreen> // ── A delivery has no arrival step ── // - // On the delivery leg this row used to be Navigate + **I've arrived**, and - // the arrival half was doing nothing: there is no endpoint that records - // reaching a customer's door (see [MilkRun.deliveryArrivalIsLocalOnly]), so - // the button wrote nothing and existed only to reveal a confirmation sheet - // — which then asked the rider to confirm a *pickup* for a bag already in - // his box. - // - // Once he sets off the stop is simply **active**. The only question left is - // how it ended, and that is one deliberate control: slide to open, then - // Delivered / Skip / Cancelled. See [_DeliveryStatusSlider]. - if (MilkRun.workingKind( - widget.pickup, - collectedIds: widget.parentState?._collectedIds ?? const {}, - ).isDelivery) { - return _DeliveryStatusSlider( - pickup: widget.pickup, - onNavigate: () { - _hasOpenedNavigation = false; - _openGoogleMapsNavigation(); - }, - onChoose: (outcome, {String proofPath = ''}) => - _closeDelivery(outcome, proofPath: proofPath), + // There is no endpoint that records reaching a customer's door (see + // [MilkRun.deliveryArrivalIsLocalOnly]), so a button for it would write + // nothing. Once he sets off the stop is simply **active**, and the only + // question left is whether it was handed over. + if (_isDeliveryLeg) { + final onTheRoad = _setOff; + final slide = MilerSlideAction( + // The act, in the rider's words. "Start delivery" is what he is about + // to do; the track says how to do it. + label: onTheRoad ? 'Order delivered' : 'Slide to start ride', + icon: onTheRoad ? LucideIcons.check : LucideIcons.chevronRight, + color: ColorConstants.acceptGreen, + onCommit: onTheRoad ? _markDelivered : _startDelivery, ); - } + if (!onTheRoad) return slide; - // Two clear actions: re-launch navigation (in case the rider dismissed - // Google Maps) and confirm arrival. Slides are reserved for money. - // - // ── Both green, only one filled ── - // - // These were both `kPrimary` — the brand maroon — which put the forward - // action of the whole flow in the same hue as Reject, Cancel and every - // destructive confirmation in the app. One rule holds now: green advances - // the job, maroon is brand and navigation, red destroys. - // - // Navigate stays *outlined* because it is the secondary of the pair — a way - // to re-open Maps, not the thing this screen is for — but it takes the same - // green so the two read as one family rather than as two unrelated - // decisions. - // ── Calling the customer, from the screen he is driving on ── - // - // The number was reachable from the booking card and from the Home stop - // sheet, and then not from this screen — the one the rider is actually - // looking at between accepting a stop and reaching it, and the exact moment - // he needs it: a gate code, a wrong door, "I'm outside". Getting to it meant - // leaving the map, going back to the list and finding the row again. - // - // Same control as the card's, so it looks and behaves identically wherever - // he finds it — neutral rather than green, because calling is an affordance, - // not a state. See [_CallButton]. - // - // Call has moved up to the identity block — see `_buildStopBrief`. This row - // is about the journey, and two controls that both concern it read better - // than three that do not. - return SizedBox( - height: ButtonSizes.primary, - width: double.infinity, - child: Row( + // ── The other two answers, at the moment he learns them ── + // + // The slider says *delivered*, because that is what a rider intends. It + // is not always what happens: the customer is out, the gate is locked, + // the order is called off while he is standing there. + // + // Those two outcomes existed, but only inside [DeliveryProofPage] — + // behind a slide he had to complete first and a grey link under the + // button after that. So the way to report a delivery that did **not** + // happen was to commit to one that did, and a rider who could not find + // it either abandoned the screen or slid *delivered* over a parcel still + // in his box, which is the one lie the console cannot recover from. + // + // It sits under the slider now, quiet and second — the exception must be + // reachable without being as easy to hit as the happy path. Same sheet + // as the proof page's, so the two cannot word it differently. See + // [DeliveryProofPage.askOutcome]. + return Column( + mainAxisSize: MainAxisSize.min, children: [ - Expanded( - flex: 4, - child: MilerButton( - // ── Navigation is brand, and brand is red ── - // - // It was an outlined green button beside a filled green one — two - // controls in one hue where one takes the rider somewhere and the - // other closes the job, distinguished only by whether they were - // filled. On a map screen, at arm's length, that is not a - // distinction. - // - // The app's own rule already said where this belongs: *green - // advances the job, maroon is brand and navigation*. Navigate is - // navigation. Filled, because on the screen the rider opens to - // drive somewhere it is the primary action, and the pair now - // differs in colour rather than in weight. - label: 'Navigate', - icon: LucideIcons.navigation, - color: ColorConstants.primary, - onPressed: () { - _hasOpenedNavigation = false; - _openGoogleMapsNavigation(); - }, + slide, + SizedBox(height: 4.h), + TextButton( + onPressed: _isNavigating ? null : _reportCannotDeliver, + style: TextButton.styleFrom( + minimumSize: Size(double.infinity, ButtonSizes.minTapTarget), + padding: EdgeInsets.zero, ), - ), - const SizedBox(width: 12), - Expanded( - flex: 6, - child: MilerButton( - // ── It says he is there, not what happened ── - // - // This read "I've handed over" on a meal run, and then opened a - // sheet asking whether the food was delivered, not delivered or - // skipped. The button had already answered its own question, so - // the two disagreed whenever the answer was anything but the - // happy one: a rider at a locked gate had to press *I've handed - // over* to be able to report that he had not. - // - // One label for both lines now. Arriving is a fact he knows at - // the moment he presses it; the outcome is the next screen's - // question, and it is the only question that screen asks. - label: "I've arrived", - icon: LucideIcons.circleCheck, - color: ColorConstants.acceptGreen, - onPressed: () async { - debugPrint('[ARRIVED] Tapped arrival on map screen'); - await _handleArrived(); - }, + child: Text( + "Couldn't deliver? Skip or cancel", + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w600, + letterSpacing: -0.1, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), ), ), ], + ); + } + + // The collection leg. Same shape, same gesture, and the same rule: set off, + // then say you are there. What arriving *asks for* is [_handleArrived]'s + // business. + final onTheWay = _hasOpenedNavigation; + return MilerSlideAction( + // ── It says he is there, not what happened ── + // + // This read "I've handed over" on a meal run, and then opened a sheet + // asking whether the food was delivered, not delivered or skipped. The + // control had already answered its own question, so the two disagreed + // whenever the answer was anything but the happy one: a rider at a + // locked gate had to say *I've handed over* to be able to report that he + // had not. + // "Slide to start ride" on both legs, and it is the same act on both: the + // rider is setting off. Naming it after the app that opens — navigate — + // described the side effect rather than the thing he is doing, and it + // read as a different job from the delivery leg's identical step. + label: onTheWay + ? "Slide to confirm you've arrived" + : 'Slide to start ride', + icon: onTheWay ? LucideIcons.check : LucideIcons.navigation, + // Green advances the job, maroon is brand and navigation. + color: onTheWay ? ColorConstants.acceptGreen : ColorConstants.primary, + onCommit: () async { + if (!onTheWay) { + _hasOpenedNavigation = false; + await _openGoogleMapsNavigation(); + if (mounted) setState(() {}); + return; + } + debugPrint('[ARRIVED] Slid to arrive on map screen'); + await _handleArrived(); + }, + ); + } + + /// Posts the arrival and records it locally. + /// + /// Deliberately the same two acts `_advanceStop` performs on Home, through + /// the same controller method, so the two entry points into the ARRIVED rung + /// cannot drift: one posts `reached` and one does not is precisely the state + /// this app was in. + /// + /// Never throws and never blocks. See the call site. + /// Returns false only when the **server refused** the arrival. A network + /// failure returns true: see the call site. + Future _recordArrival() async { + final d = widget.pickup; + final orderId = (d['orderid'] ?? d['OrderId'] ?? '').toString(); + final pickupId = + int.tryParse( + '${d['PickupId'] ?? d['pickupId'] ?? d['pickupid'] ?? 0}', + ) ?? + 0; + if (pickupId <= 0) return true; + + try { + final dc = Get.put(PickupsController(), permanent: true); + final ok = await dc.updateArrivedStatus( + pickupId: pickupId, + orderHeaderId: + int.tryParse('${d['orderheaderid'] ?? d['OrderHeaderId'] ?? 0}') ?? + 0, + pickupLat: _parseD(d['pickuplat'] ?? d['PickupLat']).toStringAsFixed(6), + pickupLng: _parseD(d['pickuplon'] ?? d['PickupLon']).toStringAsFixed(6), + ); + + if (!ok && dc.lastArrivalRefusal != null) { + if (mounted) AppFeedback.error(context, dc.lastArrivalRefusal!); + return false; + } + + // The rung the rider sees, on the row he is looking at and in the store + // that survives the refresh this flow triggers. `reached` does not + // persist on every deployment yet — see `getArrivedOrderIds` — and the + // precedence rule means this can only ever speak where the server has + // said nothing further along. + d['orderstatus'] = 'arrived'; + if (orderId.isNotEmpty) { + await addArrivedOrderIds([orderId]); + await stampOrderEvent(orderId, OrderEvent.arrivedAtPickup); + } + if (mounted) setState(() {}); + return true; + } catch (e) { + // A thrown exception is this app's own fault, not the hub's answer. The + // rider carries on for the same reason a dead network lets him. + debugPrint('[ARRIVED] could not record arrival: $e'); + return true; + } + } + + /// Sends the verification page's parcel photo through the signed-upload + /// route, and hands back the public URL — or `''` if anything went wrong. + /// + /// ── Why this is keyed on the booking ── + /// + /// It runs before `pickup-complete`, so the consignment does not exist yet. + /// `/miler/uploads/sign` takes `purpose: "pickup_proof"` with a `bookingid` + /// for exactly this window; see [MilerApi.uploadProof]. + /// + /// ── And why it never throws ── + /// + /// The parcel is in the rider's hands whatever the network did. Every failure + /// here — no photo, a dead signature, a refused PUT — resolves to an empty + /// string, which is what this call site was sending unconditionally until + /// now. The local copy stays on the device either way. + Future _uploadParcelProof(String path, int bookingId) async { + if (path.isEmpty || bookingId <= 0) return ''; + try { + final url = await MilerApi.uploadProof( + File(path), + purpose: MilerApi.proofPickup, + consignmentId: bookingId, + ); + if (url == null || url.isEmpty) { + ApiConfig.logGap( + 'uploads/sign', + 'the parcel photo for booking $bookingId could not be uploaded; the ' + 'pickup is being recorded without one and the copy stays on the ' + 'device.', + ); + return ''; + } + return url; + } catch (e) { + debugPrint('[PICKUP] parcel proof upload failed: $e'); + return ''; + } + } + + /// Puts the load on the road: releases the consignment, records the round + /// starting, then hands off to turn-by-turn. + /// + /// The write comes first and the hand-off only follows a write that landed. + /// The other order — open Maps, then post — is how the hub ends up watching + /// a rider ride to a door for a parcel it still believes is on the counter. + Future _startDelivery() async { + if (_isNavigating || !mounted) return; + setState(() => _isNavigating = true); + + bool released; + try { + final parent = widget.parentState; + // `startRound` is the Deliveries bar's own path — the server release + // plus the local bookkeeping (`Out_for_Delivery`, the order event, the + // set the queue reads). Reusing it is what stops a delivery started + // from the map and one started from the list recording different things. + released = parent != null + ? (await parent.startRound([widget.pickup])) > 0 + : await releaseForDelivery(widget.pickup); + } catch (e) { + debugPrint('[DELIVERY] could not start: $e'); + released = false; + } + + if (!mounted) return; + setState(() { + _isNavigating = false; + _setOff = released; + }); + + if (!released) { + // ── The reason, not a guess ── + // + // This printed "This parcel isn't ready to go out yet. Check your + // connection and try again" for every failure — including a parcel that + // is sitting in a hub two cities away and will never be his to deliver, + // and a stop whose consignment reference was never recorded. Both of + // those a rider can slide at forever. + // + // `releaseForDelivery` now says which it was. See [lastReleaseFailure]. + AppFeedback.error( + context, + lastReleaseFailure ?? + "This parcel isn't ready to go out yet. Check your connection and " + 'try again.', + ); + return; + } + + HapticFeedback.mediumImpact(); + _hasOpenedNavigation = false; + await _openGoogleMapsNavigation(); + } + + /// Records a stop that could not be handed over. + /// + /// The same chooser the proof page shows, reached without having to slide + /// *delivered* first. No photograph travels with either outcome — there is + /// nothing to photograph about a delivery that did not happen — so this goes + /// straight to the write [_markDelivered] reaches through the proof page. + /// See [DeliveryProofPage.askOutcome]. + Future _reportCannotDeliver() async { + if (_isNavigating || !mounted) return; + final outcome = await DeliveryProofPage.askOutcome(context); + if (!mounted || outcome == null) return; + await _closeDelivery( + switch (outcome) { + ProofOutcome.delivered => DeliveryOutcome.delivered, + ProofOutcome.skipped => DeliveryOutcome.skipped, + ProofOutcome.cancelled => DeliveryOutcome.cancelled, + }, + ); + } + + /// The end of the stop, whichever of the three it is. + /// + /// The slide says *deliver* because that is what the rider intends. What + /// actually happened is known at the door, so [DeliveryProofPage] collects + /// all three answers rather than only the happy one: it comes back + /// **delivered** with a photo (or `''` for a deliberate completion without + /// one), **skipped**, **cancelled**, or null when he backs out and keeps the + /// stop. + /// + /// The photo is offered, never demanded — a camera that will not open must + /// not strand a rider holding a parcel he has handed over. + Future _markDelivered() async { + if (_isNavigating || !mounted) return; + final result = await openScreen( + context, + DeliveryProofPage( + customer: _customerName(), + orderId: (widget.pickup['orderid'] ?? '').toString(), ), ); + if (!mounted || result == null) return; + + // One vocabulary maps to the other. The proof page is a view and must not + // depend on the pickups library's own enum; this is the one place the two + // meet, so a third outcome added on either side fails to compile here + // rather than silently recording the wrong thing. + final outcome = switch (result.outcome) { + ProofOutcome.delivered => DeliveryOutcome.delivered, + ProofOutcome.skipped => DeliveryOutcome.skipped, + ProofOutcome.cancelled => DeliveryOutcome.cancelled, + }; + await _closeDelivery(outcome, proofPath: result.proofPath); + } + + /// The name shown on the proof page, so the rider can see which hand-over + /// he is about to commit to. + String _customerName() { + for (final k in const [ + 'dropcustomer', + 'pickupcustomer', + 'PickupCustomer', + 'name', + ]) { + final v = (widget.pickup[k] ?? '').toString().trim(); + if (v.isNotEmpty) return v; + } + return 'Customer'; } /// Ends the delivery, then leaves the screen with the outcome. @@ -1440,6 +1751,38 @@ class _PickupMapScreenState extends State<_PickupMapScreen> // going, what it weighs, what it costs. Skipping it is what left the // backend routing consignments from a pin the customer dropped last week // and billing them on an estimate nobody checked. + // ── The arrival is recorded HERE, before anything else ── + // + // It was not recorded at all. This method — the "I've arrived" control at + // the foot of the map sheet, and the only arrival control on the + // logistics line — went straight from the press into the verification + // form and then into `_startPickupNavigation`. `updateArrivedStatus` was + // called from `homepage.dart` and nowhere else, so on this path: + // + // • `POST /miler/bookings/:id/reached` never fired, + // • the local ARRIVED record was never written, + // • and the rung went straight from ACCEPTED to whatever came next. + // + // That is the whole of "tapping Arrived does not make it Arrived". It was + // never a status-mapping problem: there was no transition to map. + // + // ── It blocks on a refusal, and only on a refusal ── + // + // Two failures, opposite handling. A rider with no signal at a kitchen + // door must carry on — stranding him over the network is one broken + // endpoint becoming a stopped operation, and arrival is his own report of + // where he is standing. A rider whose arrival the **server rejected on a + // business rule** must not be walked into the verification form: he is + // not arrived, the hub has said so, and everything downstream would be + // built on a rung that does not exist. + // + // `PickupsController.lastArrivalRefusal` is the distinction, drawn from + // the HTTP code rather than from message prose. + if (!await _recordArrival()) { + _handlingArrival = false; + return false; + } + Map verifyResult = {'verified': true}; if (ServiceProfile.active.needsVerification) { @@ -1547,6 +1890,12 @@ class _PickupMapScreenState extends State<_PickupMapScreen> required String riderLat, required String riderLng, required String orderId, + + /// The verification page's parcel photo, on disk. Threaded in rather than + /// re-read, because this method runs from the review screen's callback and + /// the verification result lives one frame up in + /// [_startPickupNavigation]. + required String parcelPhotoPath, }) async { final dc = Get.put(PickupsController(), permanent: true); @@ -1593,7 +1942,24 @@ class _PickupMapScreenState extends State<_PickupMapScreen> ? 0 : (method == ShipmentPayMethod.upi ? 2 : 1), orderId: orderId, - proofImage: '', + // ── The parcel photo leaves the phone now ── + // + // This was `''`. The verification page takes a photo of the parcels and + // nothing was ever done with it: the single-stop logistics path sent an + // empty string, so a disputed first-mile collection had the rider's word + // and a count and no picture. + // + // It could not be fixed here before — `/miler/uploads/sign` documented + // only `consignmentid`, and this runs *before* `pickup-complete`, so + // there is no consignment yet. The backend confirmed on 25 Aug that + // `purpose: "pickup_proof"` accepts a **bookingid**, which is the one + // thing this moment does have. + // + // Best-effort, deliberately: a failed upload costs the hub a picture and + // must never cost the rider his stop. `proofImage` is a URL the server + // treats as optional, so an empty one is the same call it was making + // before. + proofImage: await _uploadParcelProof(parcelPhotoPath, pickupId), ); if (!ok) { @@ -1677,7 +2043,27 @@ class _PickupMapScreenState extends State<_PickupMapScreen> if (parentState != null) { parentState._activePickupOrderId = orderId; - d['orderstatus'] = 'active'; + // ── `active` is not a rung, and it was being written as one ── + // + // This said `d['orderstatus'] = 'active'`, and that is where the + // rider's "Picked" turned into "Active". Two separate facts were + // being written into one field: + // + // • **which stop is live** — `_activePickupOrderId`, right above, + // which is what the LIVE mark actually reads; and + // • **how far up the pickup ladder this stop is** — `orderstatus`. + // + // `updateActiveStatus` reinforces the point: it posts + // `setAvailability('On_Pickup')`, which is a fact about the *rider*, + // not about the booking. There is no rider endpoint that sets a + // booking to Active — `Pickup_Scheduled` is system-set — so `active` + // here was never a server state at all. It was app-generated, and it + // outranked the real rung on every screen that read `orderstatus`. + // + // The honest rung at this moment is ARRIVED: he is at the door and + // has just completed verification. `pickup-complete` moves it to + // PICKED, and the completed store is what carries that. + d['orderstatus'] = 'arrived'; await parentState._startPickupPosting(d); parentState.setState(() {}); } @@ -1789,6 +2175,8 @@ class _PickupMapScreenState extends State<_PickupMapScreen> riderLat: riderLatStr, riderLng: riderLngStr, orderId: orderId, + parcelPhotoPath: (verificationData['parcelImage'] ?? '') + .toString(), ), ), swipeToGoBack: false, @@ -1956,10 +2344,21 @@ class _LiveEtaHeader extends StatelessWidget { final double? speedMps; final bool live; + /// What the green dot beside the ETA says. + /// + /// It read **Active**, which named the *position stream* — a fact about this + /// app's plumbing, not about the job. On the delivery leg the rider is + /// carrying the parcel, and the useful word for the state he is in is the + /// one the hub uses for it: **Picked up**. The dot still only appears while a + /// fix is actually feeding the ETA; a stale reading that looks live is worse + /// than no reading. + final String liveLabel; + const _LiveEtaHeader({ required this.meters, required this.speedMps, required this.live, + this.liveLabel = 'Active', }); @override @@ -1975,80 +2374,136 @@ class _LiveEtaHeader extends StatelessWidget { ); final known = travel > Duration.zero; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // The headline: minutes away, at display size. - // - // Expanded, not a bare Text with a Spacer after it. At display size - // "Calculating…" is already 20pt, and the row overflowed from about - // 1.3x system text upward — striping the sheet's own headline, on - // the screen a rider reads at arm's length precisely because he has - // the font turned up. - Expanded( - child: Text( - known - ? RouteMetricsHelper.formatDuration(travel) - : 'Calculating…', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: known ? 30 : 20, - height: 1.05, - fontWeight: FontWeight.w700, - letterSpacing: -1.0, - color: ColorConstants.slateText, - fontFamily: FontConstants.fontFamily, - ), + // ── Two facts, and the clock time is not one of them ── + // + // This said `12 min` and under it `1.2 km away · Arrive by 10:42`. Three + // quantities and a preposition, for a rider glancing at a phone on a + // handlebar. The arrival time is the one to go: it is the only figure here + // he has to *convert* — read a clock, subtract now — to get back the number + // already printed above it at 30 points. Distance stays, because near and + // far is a different question from soon and late. + // + // Both survive in full for a screen reader, where nothing is competing for + // the glance. + return Semantics( + label: known + ? '${RouteMetricsHelper.formatDuration(travel)} away, ' + '$distance, arriving about $arrival' + '${live ? ', $liveLabel' : ''}' + : 'Working out how far away this stop is', + excludeSemantics: true, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // A live badge only when a position stream is actually feeding this. + // A stale ETA that looks live is worse than no ETA. + // + // ── The word came off it ── + // + // It was a tinted pill reading `Picked up` / `Active` beside the + // numeral. What it means is "this number is live", and a pulsing dot + // says that to anybody, in any language, without being read. The word + // moved into the semantics label above. + if (live) ...[_LiveDot(), SizedBox(width: 10.w)], + Expanded( + child: Text( + known + ? RouteMetricsHelper.formatDuration(travel) + : 'Calculating…', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: known ? 34.sp : 20.sp, + height: 1.0, + fontWeight: FontWeight.w800, + letterSpacing: -1.4, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, ), ), - const SizedBox(width: 10), - // A live badge only when a position stream is actually feeding - // this. A stale ETA that looks live is worse than no ETA. - if (live) - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 7, - height: 7, - decoration: BoxDecoration( - color: ColorConstants.acceptGreen, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 5), - Text( - 'Active', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w700, - letterSpacing: 0.8, - color: ColorConstants.acceptGreen, - fontFamily: FontConstants.fontFamily, - ), - ), - ], - ), - ], - ), - const SizedBox(height: 3), - Text( - known - ? '$distance away · Arrive by $arrival' - : 'Waiting for a GPS fix', - style: TextStyle( - fontSize: 13.5, - fontWeight: FontWeight.w600, - letterSpacing: -0.1, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, ), - ), - ], + SizedBox(width: 10.w), + // The distance, as a figure rather than a sentence. `1.2 km` is a + // quantity a rider reads the way he reads the minutes beside it. + if (known) + Text( + distance, + maxLines: 1, + style: TextStyle( + fontSize: 17.sp, + height: 1.0, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ); + } +} + +/// The mark that says a number on screen is live. +/// +/// A ring that breathes out from a solid centre — the shape every map app uses +/// for "you are here", which is what makes it readable without a caption. It +/// replaced a tinted pill carrying the word *Active*; see [_LiveEtaHeader]. +class _LiveDot extends StatefulWidget { + @override + State<_LiveDot> createState() => _LiveDotState(); +} + +class _LiveDotState extends State<_LiveDot> + with SingleTickerProviderStateMixin { + late final AnimationController _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1600), + )..repeat(); + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 18.w, + height: 18.w, + child: AnimatedBuilder( + animation: _c, + builder: (context, _) { + final t = _c.value; + return Stack( + alignment: Alignment.center, + children: [ + // The halo, expanding and fading — one cycle per period, so it + // reads as a beat rather than as a wobble. + Opacity( + opacity: (1 - t) * 0.35, + child: Container( + width: 18.w * (0.45 + t * 0.55), + height: 18.w * (0.45 + t * 0.55), + decoration: const BoxDecoration( + color: ColorConstants.acceptGreen, + shape: BoxShape.circle, + ), + ), + ), + Container( + width: 9.w, + height: 9.w, + decoration: const BoxDecoration( + color: ColorConstants.acceptGreen, + shape: BoxShape.circle, + ), + ), + ], + ); + }, + ), ); } } @@ -2123,340 +2578,105 @@ double routeFlowFadeAlpha( return ((front - x) / ramp).clamp(0.0, 1.0); } -/// ───────────────────────────────────────────────────────────────────────── -/// UPDATE STATUS — slide to open, then choose +/// ── The stop's life, as three beats ── /// -/// The delivery leg's one control, and the last thing the rider touches at a -/// door. Two states in one footprint: -/// -/// ``` -/// closed: [ Navigate ] [ ⟶ slide to update status ] -/// open: [ Delivered ] [ Skip ] [ Cancelled ] -/// ``` -/// -/// ── Why a slide and not three buttons ── -/// -/// The three outcomes are irreversible and one of them is *Cancelled*. A row of -/// tap targets on a phone in a jacket pocket, on a screen the rider is holding -/// while walking, is how a delivery gets cancelled by a knuckle. The slide is -/// the same gesture the app already reserves for consequential writes, and it -/// costs nothing at the one moment the rider is deliberately stopping to record -/// an outcome. -/// -/// It opens rather than commits, because *which* outcome is still a question — -/// a slide that committed would need three slides. -class _DeliveryStatusSlider extends StatefulWidget { - final VoidCallback onNavigate; - final void Function(DeliveryOutcome outcome, {String proofPath}) onChoose; +/// Filled behind the rider, hollow ahead of him, and the beat he is on wearing +/// the accent. Deliberately not a percentage or a spinner: three named states +/// is the whole vocabulary a stop has, and naming them is what lets a rider who +/// picked the phone up mid-shift know what he already did. +class _StageRail extends StatelessWidget { + /// What each beat is called. Kept for screen readers and **not drawn** — see + /// the note on [icons]. + final List labels; - /// The stop being closed — named on the proof page so the rider can see - /// which hand-over he is about to commit to. - final Map pickup; - - const _DeliveryStatusSlider({ - required this.onNavigate, - required this.onChoose, - required this.pickup, - }); - - @override - State<_DeliveryStatusSlider> createState() => _DeliveryStatusSliderState(); -} - -class _DeliveryStatusSliderState extends State<_DeliveryStatusSlider> { - /// How far along the track the thumb has been dragged, 0..1. - double _progress = 0; - - /// The fraction of the track that counts as "slid". - static const double _commitAt = 0.72; - - void _settle() { - if (_progress >= _commitAt) { - HapticFeedback.mediumImpact(); - _askOutcome(); - } else { - setState(() => _progress = 0); - } - } - - /// ── The question is a SHEET, not a panel in a sheet ── + /// ── The rail lost its captions ── /// - /// The outcomes used to render inline at the foot of the map's draggable - /// sheet — a white card inside frosted glass, with its own hand-rolled - /// close button, needing the host sheet animated taller just to be seen. - /// Three workarounds for one wrong container. Every other question this - /// app asks arrives on the kit's glass — handle, header, choice rows, one - /// entrance curve — and this, the most consequential question on the map, - /// was the one place still improvising. Dismissal is the handle and the - /// barrier, the same as everywhere; the slider resets either way. - Future _askOutcome() async { - setState(() => _progress = 1); - final outcome = await showMilerSheet( - context, - builder: (ctx) => MilerSheetScaffold( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const MilerSheetHeader( - title: 'How did this stop end?', - subtitle: 'This is recorded with the office straight away.', - ), - SizedBox(height: 16.h), - MilerButton( - label: DeliveryOutcome.delivered.label, - icon: DeliveryOutcome.delivered.icon, - color: ColorConstants.acceptGreen, - height: ButtonSizes.primary, - onPressed: () => Navigator.pop(ctx, DeliveryOutcome.delivered), - ), - Divider(height: 24.h, color: ColorConstants.borderSubtle), - _OutcomeRow( - outcome: DeliveryOutcome.skipped, - caption: 'Keep it on board and try again later', - onTap: () => Navigator.pop(ctx, DeliveryOutcome.skipped), - ), - _OutcomeRow( - outcome: DeliveryOutcome.cancelled, - caption: 'This order will not be delivered', - onTap: () => Navigator.pop(ctx, DeliveryOutcome.cancelled), - ), - SizedBox(height: 4.h), - ], - ), - ), - ); - if (!mounted) return; - setState(() => _progress = 0); - if (outcome == null) return; + /// Each beat carried a 10.5pt word under its dot: `Accepted · On the way · + /// Picked up`. Three words, at the smallest size on the sheet, on the one + /// screen a rider looks at while moving — and the rail already says the same + /// thing with its own shape, which is what a progress rail is *for*. A dot + /// that is filled is done, the ringed one is now, the hollow ones are next; + /// none of that needs reading, and at 10.5pt none of it was being read. + /// + /// So each beat is a glyph instead: what the rider is doing, drawn. The words + /// survive in [labels] for a screen reader, where they cost nothing. + final List icons; - // ── A delivery is proved before it is recorded ── - // - // Delivered is the one outcome somebody may dispute weeks later, so it - // goes through the proof page: photo, preview, then **Mark as - // delivered** on that same screen. The other two outcomes are the - // rider reporting he could *not* hand over, and there is nothing to - // photograph. - // - // Three answers come back and they are not the same: a path (proof - // taken), `''` (completed deliberately without one — the camera failed - // or the customer objected), and null (he backed out, so nothing is - // recorded and the stop stays his). - if (outcome == DeliveryOutcome.delivered) { - final proof = await openScreen( - context, - DeliveryProofPage( - customer: _customerName(), - orderId: (widget.pickup['orderid'] ?? '').toString(), - ), - ); - if (!mounted || proof == null) return; - widget.onChoose(outcome, proofPath: proof); - return; - } + /// Which beat is current, 0-based. Everything before it is done. + final int current; - widget.onChoose(outcome); - } - - /// The name the proof page shows, so the rider can see which hand-over he - /// is closing before he commits to it. - String _customerName() { - for (final k in const [ - 'dropcustomer', - 'pickupcustomer', - 'PickupCustomer', - 'name', - ]) { - final v = (widget.pickup[k] ?? '').toString().trim(); - if (v.isNotEmpty) return v; - } - return 'Customer'; - } - - @override - Widget build(BuildContext context) => _closed(context); - - Widget _closed(BuildContext context) { - return SizedBox( - height: ButtonSizes.primary, - width: double.infinity, - child: Row( - children: [ - Expanded( - flex: 4, - child: MilerButton( - label: 'Navigate', - icon: LucideIcons.navigation, - color: ColorConstants.primary, - onPressed: widget.onNavigate, - ), - ), - const SizedBox(width: 12), - Expanded(flex: 6, child: _track(context)), - ], - ), - ); - } - - Widget _track(BuildContext context) => LayoutBuilder( - builder: (context, constraints) { - final double travel = (constraints.maxWidth - _thumb).clamp( - 1.0, - double.infinity, - ); - // ── A drag-only control is a control some riders cannot use ── - // - // The slide exists to make finishing a stop deliberate, and that is - // right: it is the gesture that opens the outcome question. But a bare - // `GestureDetector` announces nothing and offers no path at all to - // anyone driving the screen with TalkBack, or to a rider whose gloves - // will not register a drag on a cold morning. - // - // The deliberateness is preserved exactly, because the slide never - // committed anything: it opens `How did this stop end?`, and the rider - // still has to choose there. So the accessible action opens the same - // question — one extra confirmed step either way, and nobody is locked - // out of finishing their own delivery. - return Semantics( - button: true, - label: 'Finish this stop', - hint: 'Slide, or double tap, to choose how the stop ended', - onTap: _askOutcome, - excludeSemantics: true, - child: GestureDetector( - onHorizontalDragUpdate: (d) => setState( - () => _progress = (_progress + d.delta.dx / travel).clamp(0.0, 1.0), - ), - onHorizontalDragEnd: (_) => _settle(), - onHorizontalDragCancel: _settle, - child: Container( - decoration: BoxDecoration( - color: ColorConstants.acceptGreen.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(ButtonSizes.radius), - ), - child: Stack( - alignment: Alignment.center, - children: [ - // The label fades as the thumb covers it, rather than sitting - // under the thumb being half-read. - Opacity( - opacity: (1 - _progress * 1.6).clamp(0.0, 1.0), - child: Padding( - padding: EdgeInsets.only(left: _thumb * 0.6), - child: Text( - 'Update status', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 15.sp, - fontWeight: FontWeight.w700, - letterSpacing: -0.2, - color: ColorConstants.acceptGreen, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - ), - Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: EdgeInsets.only(left: travel * _progress), - child: Container( - width: _thumb, - height: _thumb, - decoration: BoxDecoration( - color: ColorConstants.acceptGreen, - borderRadius: BorderRadius.circular(ButtonSizes.radius), - ), - child: Icon( - LucideIcons.chevronRight, - color: Colors.white, - size: 26.sp, - ), - ), - ), - ), - ], - ), - ), - ), - ); - }, - ); - - double get _thumb => ButtonSizes.primary - 8; -} - -/// One exception, as a row: glyph in a tinted disc, what it is, what it means. -class _OutcomeRow extends StatelessWidget { - final DeliveryOutcome outcome; - final String caption; - final VoidCallback onTap; - - const _OutcomeRow({ - required this.outcome, - required this.caption, - required this.onTap, + const _StageRail({ + required this.labels, + required this.icons, + required this.current, }); @override - Widget build(BuildContext context) => InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - child: Padding( - padding: EdgeInsets.symmetric(vertical: 9.h, horizontal: 4.w), + Widget build(BuildContext context) { + return Semantics( + label: 'Step ${current + 1} of ${labels.length}: ${labels[current]}', + excludeSemantics: true, child: Row( children: [ - Container( - width: 34.w, - height: 34.w, - decoration: BoxDecoration( - color: outcome.colour.withValues(alpha: 0.10), - shape: BoxShape.circle, - ), - child: Icon(outcome.icon, size: 18.sp, color: outcome.colour), - ), - SizedBox(width: 12.w), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - outcome.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 14.5.sp, - fontWeight: FontWeight.w700, - letterSpacing: -0.2, - color: ColorConstants.onSurface, - fontFamily: FontConstants.fontFamily, + for (var i = 0; i < icons.length; i++) ...[ + if (i > 0) + Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 6.w), + child: Container( + height: 3, + decoration: BoxDecoration( + color: i <= current + ? ColorConstants.acceptGreen + : ColorConstants.borderStrong, + borderRadius: BorderRadius.circular( + DesignConstants.radiusFull, + ), + ), ), ), - SizedBox(height: 1.h), - Text( - caption, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12.sp, - height: 1.3, - fontWeight: FontWeight.w500, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, - ), + ), + // Bigger than the 12pt dot it replaces, because it has to carry a + // glyph now — and because a rail read from a mount at arm's length + // was never going to be readable at 12. + AnimatedContainer( + duration: DesignConstants.motionState, + curve: Curves.easeOut, + width: 34.w, + height: 34.w, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: i < current + ? ColorConstants.acceptGreen + : i == current + ? ColorConstants.tint(ColorConstants.primary, 0.12) + : ColorConstants.pureSurface, + border: Border.all( + color: i < current + ? ColorConstants.acceptGreen + : i == current + ? ColorConstants.primary + : ColorConstants.borderStrong, + width: i == current ? 2 : 1.5, ), - ], + ), + child: Icon( + // A finished beat is a tick, not the thing it was — the rider + // is looking for what is left, and a done step that still wears + // its own glyph competes with the one that is not done. + i < current ? LucideIcons.check : icons[i], + size: 17.sp, + color: i < current + ? ColorConstants.onAccent + : i == current + ? ColorConstants.primary + : ColorConstants.secondaryText, + ), ), - ), - Icon( - LucideIcons.chevronRight, - size: 20.sp, - color: ColorConstants.borderStrong, - ), + ], ], ), - ), - ); + ); + } } diff --git a/lib/views/Dashboard/pickups/pickups.dart b/lib/views/Dashboard/pickups/pickups.dart index cacd994..203987b 100644 --- a/lib/views/Dashboard/pickups/pickups.dart +++ b/lib/views/Dashboard/pickups/pickups.dart @@ -14,10 +14,12 @@ library; // what is removed here is this file's claim on them. import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:flutter/foundation.dart' show kDebugMode, setEquals; import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; +import 'package:miler/views/helpers/constants/narrative.dart'; import 'package:miler/views/helpers/constants/miler_surface.dart'; import 'package:miler/views/helpers/constants/miler_type.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; @@ -30,6 +32,7 @@ import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/helpers/poll_policy.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; +import 'package:miler/views/helpers/widgets/slide_action.dart'; import 'package:miler/views/helpers/widgets/page_transitions.dart'; import 'package:miler/views/Dashboard/pickups/stop_type.dart'; import 'package:miler/views/Dashboard/pickups/route_metrics.dart'; @@ -1436,16 +1439,47 @@ class _MyPickupsState extends State /// has nothing to offer. **No feature flag is mirrored here**, because a /// mirrored flag is a second source of truth that can be wrong. /// - /// The local sets are the fallback for a row that carries no consignment - /// status yet — an older deployment, or a stop collected seconds ago that - /// the queue has not caught up with. + /// ── The rider's press decides this, not the server's rung ── + /// + /// This read the consignment state first and only fell back to the local + /// sets when the row carried none. That is the right instinct — a local flag + /// must never outrank a server fact — and it produced the wrong answer for + /// the case that is live on production today. + /// + /// Two backend behaviours ship at once. With `MILER_COLLECTED_STATE_ENABLED` + /// **on**, `pickup-complete` stops at `Collected_By_Miler` and this method's + /// old reading is exactly right. With it **off** — compatibility mode, which + /// is the default and what the app meets in production — `pickup-complete` + /// releases the consignment itself, so a stop arrives on this tab already + /// `Out_for_Delivery`, `needsRelease` answers false, and the round starts + /// itself. The rider never sees PICKED and **Start delivery** never appears: + /// he collects at the kitchen and the app has him on the road before he has + /// left the counter. + /// + /// So the two questions are separated, because they were never the same one: + /// + /// ``` + /// may this be delivered? → the consignment. ConsignmentGate, deliver + /// has he set off? → the rider. this method + /// ``` + /// + /// Nothing here contradicts the server. A stop the app holds at PICKED is + /// still `Out_for_Delivery` on the wire, `deliver` is still gated on the + /// consignment's own state, and pressing **Start delivery** on a consignment + /// that is already released costs no request at all — `releaseForDelivery` + /// returns true on sight of a deliverable state. What changes is only which + /// of the two facts the rider's card is showing him, and the answer is the + /// one he can act on. + /// + /// The consignment state stays the fallback for a stop this device has no + /// record of — collected on another handset, or after a reinstall. bool _needsRelease(Map stop) { - final state = consignmentStateFromRaw(stop['consignmentstatus']); - if (state != ConsignmentState.unknown) { - return state.needsRelease; - } final id = MilkRun.idOf(stop); - return _collectedIds.contains(id) && !_outForDeliveryIds.contains(id); + // He pressed it. Nothing outranks that. + if (_outForDeliveryIds.contains(id)) return false; + // He collected it here and has not pressed it. + if (_collectedIds.contains(id)) return true; + return consignmentStateFromRaw(stop['consignmentstatus']).needsRelease; } /// True while the round is being marked started, so the bar cannot be @@ -1536,10 +1570,19 @@ class _MyPickupsState extends State final held = _unreleasedFailures; if (n == 0) { + // One stop, one reason: say it. `releaseForDelivery` knows whether the + // parcel is at a hub, missing its reference, refused, or genuinely + // unreachable — see [lastReleaseFailure] — and "check your connection" + // is true for only the last of those. + // + // A whole round that failed keeps the general wording: with several + // stops there is no single reason to give, and the per-stop detail is in + // the log. AppFeedback.errorGlobal( held == 1 - ? 'Could not start that delivery. Check your connection and try ' - 'again.' + ? (lastReleaseFailure ?? + 'Could not start that delivery. Check your connection and ' + 'try again.') : 'Could not start the round. Check your connection and try again.', ); return; @@ -1751,23 +1794,46 @@ class _MyPickupsState extends State ); } - // ── Opening a stop starts its leg, if the bar has not already ── + // ── Opening a stop does NOT start its leg ── // - // START DELIVERY is the normal way in and releases the whole load at once. - // This is the other way in: a rider who taps a card without pressing the - // bar is just as much on his way, and the stop has to be released before he - // gets there — `deliver` refuses any consignment that is not - // `Out_for_Delivery`, so opening one unreleased would walk him to a door - // and fail him at it. + // This used to call `startRound([item])` here — the same server release the + // Start-ride slider makes — on the reasoning that a rider who taps a card + // without pressing the bar is just as much on his way, and that `deliver` + // refuses anything that is not `Out_for_Delivery`, so an unreleased stop + // would walk him to a door and fail him at it. // - // The same call the bar makes, for one order. [startRound] writes the local - // marker only after the server agrees, so a failure here leaves the row - // reading PICKED rather than lying about a round the hub has not started. - if (MilkRun.navigatesToCustomer(item, collectedIds: _collectedIds) && - !_outForDeliveryIds.contains((item['orderid'] ?? '').toString())) { - await startRound([item]); - if (!mounted) return; - } + // ── Why that is the production lifecycle bug, one tap later ── + // + // [MilkRun.navigatesToCustomer] is true the moment a stop is in + // `_collectedIds`, which is the moment **Picked** succeeds. So the sequence + // that matters is: + // + // ``` + // Home slide Picked → pickup-complete → Collected_By_Miler + // Work tap the card → start-delivery → Out_for_Delivery ← here + // Map slide Start ride (already released) + // ``` + // + // Tapping a card is not a statement that the rider has set off. It is how + // he reads an address, checks a phone number, or looks at the route — and + // it fired the one call that is supposed to be the slider's alone, so the + // console went **Active** while the load was still on the counter and the + // rider had not slid anything. + // + // It is invisible today only because `MILER_COLLECTED_STATE_ENABLED` is + // off: the pivot has already released the consignment, so + // `releaseForDelivery` sees a deliverable state and returns true without + // spending a request. **The flag flip is what arms this.** Flipping it + // without this change moves the early-Active bug rather than fixing it. + // + // The door is still covered, and by the branch written for it: the + // `DeliverGate.needsRelease` case in `_closeDelivery` releases a stop the + // rider never started, at the moment he actually hands over, then re-reads + // the state before posting. That is the safety net this pre-release was + // duplicating — one tap too early and on the wrong side of the boundary. + // + // See `MilerLifecycle`, and the regression tests in + // `test/picked_does_not_release_test.dart`. await openScreen( context, @@ -1962,10 +2028,25 @@ class _MyPickupsState extends State // `where` that used to sit here, re-admitting rows by raw status, is gone // with it: it was the hole an accepted-but-uncollected booking came // through, and a second expression of a fact that now has one. + // ── What the rider has already written off today ── + // + // Read from the two stores that record a mutation *after* it came back + // OK, so this is authoritative and not a display flag: `completed` holds + // delivered and cancelled, `skipped` holds a parked attempt. Both are + // day-stamped and scoped to this rider, tenant and line, and both survive + // a cold start — which is what stops `/miler/bookings` re-admitting a + // skipped stop on the next poll while the consignment is still + // `Out_for_Delivery`. See [WorkBoundary.isClosed]. + final closedIds = { + ...await getCompletedOrderIds(), + ...await getSkippedOrderIds(), + }; + var dedupedList = WorkBoundary.deliveryQueue( items.whereType>(), collectedIds: collectedIds, acceptedIds: acceptedIds, + closedIds: closedIds, ); // Drop stops the rider just finished. The backend may still return them @@ -1996,17 +2077,17 @@ class _MyPickupsState extends State '${sortedList.map((o) => "${o['orderid']}:${o['orderstatus']}").toList()}', ); - // Re-apply locally-skipped orders so a skip survives polling even when the - // backend hasn't recorded it yet (e.g. mock/offline data). Without this, - // the 3s poll would overwrite the skip with the raw API status. Applied - // before the filter below, because the filter now acts on it. - if (_skippedOrdersCache.isNotEmpty) { - for (final o in sortedList) { - if (_skippedOrdersCache.containsKey(_getOrderKey(o))) { - o['orderstatus'] = 'skipped'; - } - } - } + // ── The poll-time re-stamp is gone ── + // + // This used to walk the list writing `orderstatus = 'skipped'` onto every + // row in the in-memory skip cache, so that the 3s poll could not revert a + // skip the backend had not recorded. That was a display flag papering + // over dual ownership: the row was still in the queue, still owned by + // this tab, and merely wearing a different word. + // + // A skipped order no longer reaches this point — `closedIds` drops it at + // the boundary, above both screens — so there is nothing left to stamp + // and no second opinion to keep in sync. See [WorkBoundary.isClosed]. // Build the top list = the stops he is actually working. // - ACTIVE stops go to the banner, not the list. @@ -2895,14 +2976,20 @@ class _MyPickupsState extends State } }, child: Scaffold( - // The same white as Home, Activity and Account. This was `surface` - // (#FCF9F8, a warm off-white); the difference is invisible under a full - // list and obvious the moment the list is short, so the tab appeared to - // change colour with the rider's workload. - // The brand shows through the sheet's two top corners — see - // [MilerSheet]. + // ── The corner has to have something behind it ── + // + // The sheet's two top corners were clipping onto a ground painted in + // the same canvas the sheet itself uses, so the curve revealed the + // page's own colour and was invisible: this tab read as a hard maroon + // line above the queue while Home and Activity carried the shape. The + // brand goes behind the sheet, which is what the other tabs do and + // what makes the curve a curve. The page's own colour is the sheet's + // (`MilerSurface.canvas`, one line below), not this. backgroundColor: ColorConstants.primary, appBar: MilerAppBar( + // The bar's curve opens onto the sheet's canvas, not the app's + // paper. See [milerBrandSurface]. + pageColor: MilerSurface.canvas, // ── The tab and the page it opens must agree ── // // This was the literal 'My Bookings', while the nav tab underneath it @@ -2936,322 +3023,342 @@ class _MyPickupsState extends State // [MilerSurface]. body: MilerSheet( color: MilerSurface.canvas, - child: Stack( - children: [ - // Three states, cross-faded: still asking, nothing to do, and the - // queue itself. The middle one used to cover the first, and the - // hand-off between any two of them used to be a single frame. - SmoothSwap( - child: _picked.isEmpty && !_firstLoadDone - ? const SkeletonList(key: ValueKey('bookings-loading')) - : _picked.isEmpty && _fetchFailed - ? ErrorRetry( - key: const ValueKey('bookings-error'), - onRetry: () => _fetchPicked(force: true), - ) - : _picked.isEmpty - ? SingleChildScrollView( - key: const ValueKey('bookings-empty'), - physics: const AlwaysScrollableScrollPhysics(), - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.72, - // ── The artwork is the whole empty state ── - // - // This was a [MilerEmptyState] drawing a headline and a - // body line above the illustration — and the - // illustration has both baked into it, in the same - // words: "You're all caught up", then the line about - // new jobs appearing automatically. Rendered together - // the screen said each sentence twice, which reads as a - // rendering fault rather than as one message. The panel - // is the picture now, at a size worth looking at, and - // it keeps its own copy. - // - // [_fetchDiagLine] stays: it is `kDebugMode`-gated and - // folds away entirely in release, so nothing ships - // underneath the art. - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: EdgeInsets.symmetric( - horizontal: 16.w, - ), - child: Image.asset( - 'assets/images/caught_up.png', - width: 330.w, - fit: BoxFit.contain, - errorBuilder: (_, __, ___) => Icon( - LucideIcons.truck, - size: 64.sp, - color: ColorConstants.borderStrong, + // ── The sheet has to fill the body, not the queue ── + // + // A `Stack` hands its children loose constraints and then shrinks to + // the tallest of them, so on a short queue — or the empty state — + // the sheet ended where the content did and the rest of the body was + // whatever the Scaffold was painted with. That was invisible while + // the ground was the same canvas as the sheet; the moment the brand + // went behind it (for the corners) it became a red band across the + // bottom of the tab. + child: SizedBox.expand( + child: Stack( + children: [ + // Three states, cross-faded: still asking, nothing to do, and the + // queue itself. The middle one used to cover the first, and the + // hand-off between any two of them used to be a single frame. + SmoothSwap( + child: _picked.isEmpty && !_firstLoadDone + ? const SkeletonList(key: ValueKey('bookings-loading')) + : _picked.isEmpty && _fetchFailed + ? ErrorRetry( + key: const ValueKey('bookings-error'), + onRetry: () => _fetchPicked(force: true), + ) + : _picked.isEmpty + ? SingleChildScrollView( + key: const ValueKey('bookings-empty'), + physics: const AlwaysScrollableScrollPhysics(), + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.72, + // ── The artwork is the whole empty state ── + // + // This was a [MilerEmptyState] drawing a headline and a + // body line above the illustration — and the + // illustration has both baked into it, in the same + // words: "You're all caught up", then the line about + // new jobs appearing automatically. Rendered together + // the screen said each sentence twice, which reads as a + // rendering fault rather than as one message. The panel + // is the picture now, at a size worth looking at, and + // it keeps its own copy. + // + // [_fetchDiagLine] stays: it is `kDebugMode`-gated and + // folds away entirely in release, so nothing ships + // underneath the art. + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: EdgeInsets.symmetric( + horizontal: 16.w, + ), + child: Image.asset( + 'assets/images/caught_up.png', + width: 330.w, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => Icon( + LucideIcons.truck, + size: 64.sp, + color: ColorConstants.borderStrong, + ), ), ), - ), - _fetchDiagLine(), - ], + _fetchDiagLine(), + ], + ), ), ), - ), - ) - : Column( - key: const ValueKey('bookings-list'), - children: [ - // No trip tabs here. Everything on this screen is already - // accepted, so the only live question is which stop is - // next — and that has one answer across the whole day. - // See [_visibleStops]. - _buildTripRail(), - Expanded( - child: RefreshIndicator( - onRefresh: _fetchPicked, - color: ColorConstants.primary, - // ── An empty ROW list, not an empty stop list ── - // - // This asked `_visibleStops.isEmpty`, but the rows - // are built from a filtered subset of it. When the - // stops were all finished-but-not-yet-cleared, the - // page took the populated branch and rendered a - // `ListView` with zero children: a blank screen - // under the rail, with no explanation and nothing - // to pull on. Ask the list that is actually drawn. - child: _queueRows().isEmpty - ? _buildTripTabEmptyState() - : Builder( - builder: (context) { - final rows = _queueRows(); - return ListView.builder( - padding: EdgeInsets.only( - top: 2.h, - // Room for the live strip only when - // one is actually drawn — see - // [_liveOnThisScreen]. - // ── Room for whichever bar is - // drawn ── - // - // A flat 80 was the live strip's - // height, and the round bar is - // taller: a caption, a primary - // button and the nav-bar inset - // under it. The last card sat - // behind it with its controls - // unreachable. Measured from the - // pieces rather than guessed, so it - // cannot drift when either grows. - // ── One measured floor ── - // - // The unreleased branch was built - // from its bar's own pieces; the - // live branch was a flat `80.h` - // that forgot the device inset - // entirely, so on a gesture-nav - // phone the last row of the run sat - // behind the banner *and* behind - // the navigation. Both surfaces now - // state their own height and the - // floor reserves whichever one is - // actually showing, plus the inset. - bottom: _bottomReservation(context), - ), - itemCount: rows.length, - itemBuilder: (context, index) { - final row = rows[index]; - if (row.isHeading) { - return _QueueHeading( - label: row.heading!, - count: row.count, - // On the first heading only: - // it describes the whole - // queue, not the section. - order: index == 0 - ? _orderSource - : null, - ); - } - - final item = row.stop!; - final orderId = - (item['orderid'] ?? '') - .toString(); - // Live metres + speed drive the - // distance / ETA on every density; - // RouteMetricsHelper picks the unit - // and falls back to an urban average - // when the GPS reading is unusable. - final distanceMeters = - RouteMetricsHelper.metersToStop( - item, - riderLat: _currentLocation - ?.latitude, - riderLng: _currentLocation - ?.longitude, - // The leg decides the end: - // a delivery row measures - // to the DOOR. It measured - // to the kitchen, so the - // figure grew as the rider - // approached the customer. - toDrop: MilkRun.workingKind( - item, - collectedIds: _collectedIds, - ).isDelivery, + ) + : Column( + key: const ValueKey('bookings-list'), + children: [ + // No trip tabs here. Everything on this screen is already + // accepted, so the only live question is which stop is + // next — and that has one answer across the whole day. + // See [_visibleStops]. + _buildTripRail(), + Expanded( + child: RefreshIndicator( + onRefresh: _fetchPicked, + color: ColorConstants.primary, + // ── An empty ROW list, not an empty stop list ── + // + // This asked `_visibleStops.isEmpty`, but the rows + // are built from a filtered subset of it. When the + // stops were all finished-but-not-yet-cleared, the + // page took the populated branch and rendered a + // `ListView` with zero children: a blank screen + // under the rail, with no explanation and nothing + // to pull on. Ask the list that is actually drawn. + child: _queueRows().isEmpty + ? _buildTripTabEmptyState() + : Builder( + builder: (context) { + final rows = _queueRows(); + return ListView.builder( + padding: EdgeInsets.only( + top: 2.h, + // Room for the live strip only when + // one is actually drawn — see + // [_liveOnThisScreen]. + // ── Room for whichever bar is + // drawn ── + // + // A flat 80 was the live strip's + // height, and the round bar is + // taller: a caption, a primary + // button and the nav-bar inset + // under it. The last card sat + // behind it with its controls + // unreachable. Measured from the + // pieces rather than guessed, so it + // cannot drift when either grows. + // ── One measured floor ── + // + // The unreleased branch was built + // from its bar's own pieces; the + // live branch was a flat `80.h` + // that forgot the device inset + // entirely, so on a gesture-nav + // phone the last row of the run sat + // behind the banner *and* behind + // the navigation. Both surfaces now + // state their own height and the + // floor reserves whichever one is + // actually showing, plus the inset. + bottom: _bottomReservation( + context, + ), + ), + itemCount: rows.length, + itemBuilder: (context, index) { + final row = rows[index]; + if (row.isHeading) { + return _QueueHeading( + label: row.heading!, + count: row.count, + // On the first heading only: + // it describes the whole + // queue, not the section. + order: index == 0 + ? _orderSource + : null, ); + } - void open() => StopDetailSheet.show( - context, - stop: item, - stopNumber: row.displayStep, - // The pairing recorded at the - // counter — same store as the - // card, so the two agree. - bag: row.bag, - // The carried set — what turns - // a raw pickup row into the - // delivery leg the sheet must - // describe. - collectedIds: _collectedIds, - riderLat: - _currentLocation?.latitude, - riderLng: - _currentLocation?.longitude, - ); + final item = row.stop!; + final orderId = + (item['orderid'] ?? '') + .toString(); + // Live metres + speed drive the + // distance / ETA on every density; + // RouteMetricsHelper picks the unit + // and falls back to an urban average + // when the GPS reading is unusable. + final distanceMeters = + RouteMetricsHelper.metersToStop( + item, + riderLat: _currentLocation + ?.latitude, + riderLng: _currentLocation + ?.longitude, + // The leg decides the end: + // a delivery row measures + // to the DOOR. It measured + // to the kitchen, so the + // figure grew as the rider + // approached the customer. + toDrop: MilkRun.workingKind( + item, + collectedIds: + _collectedIds, + ).isDelivery, + ); - // ── One card shape per density ── - // - // Not one card dimmed four ways. See - // [QueueDensity] for why the live stop - // is physically bigger rather than - // merely brighter. - final Widget child = switch (row - .density) { - QueueDensity.now => PickupCard( - key: ValueKey( - 'pickup_$orderId', - ), - item: item, - displayStep: row.displayStep, - distanceMeters: distanceMeters, - riderSpeedMps: - _currentLocation?.speed, + void + open() => StopDetailSheet.show( + context, + stop: item, + stopNumber: row.displayStep, + // The pairing recorded at the + // counter — same store as the + // card, so the two agree. bag: row.bag, + // The carried set — what turns + // a raw pickup row into the + // delivery leg the sheet must + // describe. collectedIds: _collectedIds, - outForDeliveryIds: - _outForDeliveryIds, - ), - QueueDensity.next => _NextCard( - item: item, - bag: row.bag, - distanceMeters: distanceMeters, - riderSpeedMps: - _currentLocation?.speed, - onOpen: open, - ), - QueueDensity.later => _LaterRow( - item: item, - bag: row.bag, - distanceMeters: distanceMeters, - onOpen: open, - ), - QueueDensity.skipped => - _SkippedRow( + riderLat: + _currentLocation?.latitude, + riderLng: + _currentLocation?.longitude, + ); + + // ── One card shape per density ── + // + // Not one card dimmed four ways. See + // [QueueDensity] for why the live stop + // is physically bigger rather than + // merely brighter. + final Widget child = switch (row + .density) { + QueueDensity.now => PickupCard( + key: ValueKey( + 'pickup_$orderId', + ), + item: item, + displayStep: row.displayStep, + distanceMeters: + distanceMeters, + riderSpeedMps: + _currentLocation?.speed, + bag: row.bag, + collectedIds: _collectedIds, + outForDeliveryIds: + _outForDeliveryIds, + ), + QueueDensity.next => _NextCard( item: item, bag: row.bag, - reason: row.reason, + distanceMeters: + distanceMeters, + riderSpeedMps: + _currentLocation?.speed, onOpen: open, - onResume: () => - _resumeSkipped(item), ), - }; + QueueDensity.later => _LaterRow( + item: item, + bag: row.bag, + distanceMeters: + distanceMeters, + onOpen: open, + ), + QueueDensity.skipped => + _SkippedRow( + item: item, + bag: row.bag, + reason: row.reason, + onOpen: open, + onResume: () => + _resumeSkipped(item), + ), + }; - return Reveal( - key: ValueKey('reveal_$orderId'), - delay: staggerDelay(index), - child: child, - ); - }, - ); - }, - ), + return Reveal( + key: ValueKey( + 'reveal_$orderId', + ), + delay: staggerDelay(index), + child: child, + ); + }, + ); + }, + ), + ), ), - ), - ], - ), - ), - // The live strip, shared with Home. It places itself clear of the - // floating nav bar, so it is pinned flush here and left to it. - // - // Only for a stop this screen can actually work. On a kitchen line - // a collection is Home's, in bulk, so a strip here offering to - // "continue" one was an invitation into a flow the rider is not - // supposed to be in — and the way most riders found it. See - // [_liveOnThisScreen]. - // ── The hinge of the day, at the foot of the tab that holds the - // load ── - // - // It shares this slot with the live strip and yields to it: once a - // stop is running, "continue that stop" is the only thing the rider - // wants from the bottom of this screen, and a second bar under his - // thumb offering to start something else is how the wrong one gets - // pressed. See [startRound]. - // ── One contextual slot, and the workflow advances through it ── - // - // These were two `if`s over the same corner. Logically exclusive - // already — a live stop suppresses the start bar, because a - // second bar under the rider's thumb offering to start something - // else is how the wrong one gets pressed — but they *swapped* by - // one disappearing and the other appearing in the same frame, - // which reads as the screen glitching rather than as **Start - // delivery → Continue this stop**. - // - // One switcher, one child at a time, on the app's own state - // duration. Keyed by which surface it is rather than by its - // contents, so a poll that returns the same live stop does not - // replay the transition. - Positioned( - left: 0, - right: 0, - bottom: 0, - child: AnimatedSwitcher( - duration: DesignConstants.motionState, - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeOutCubic, - transitionBuilder: (child, anim) => FadeTransition( - opacity: anim, - child: SlideTransition( - position: Tween( - begin: const Offset(0, 0.12), - end: Offset.zero, - ).animate(anim), - child: child, - ), - ), - child: _liveOnThisScreen.isNotEmpty - ? ActivePickupBanner( - key: const ValueKey('live'), - activePickups: _liveOnThisScreen, - onTap: (pickup) async { - await startPickup(pickup); - // Refresh after returning from map screen - if (mounted) { - await Future.delayed( - const Duration(milliseconds: 500), - ); - _fetchPicked(); - } - }, - ) - : _unreleased.isNotEmpty - ? StartRoundBar( - key: const ValueKey('start-round'), - count: _unreleased.length, - busy: _starting, - onPressed: _startWholeRound, - ) - : const SizedBox.shrink(key: ValueKey('none')), + ], + ), ), - ), - ], + // The live strip, shared with Home. It places itself clear of the + // floating nav bar, so it is pinned flush here and left to it. + // + // Only for a stop this screen can actually work. On a kitchen line + // a collection is Home's, in bulk, so a strip here offering to + // "continue" one was an invitation into a flow the rider is not + // supposed to be in — and the way most riders found it. See + // [_liveOnThisScreen]. + // ── The hinge of the day, at the foot of the tab that holds the + // load ── + // + // It shares this slot with the live strip and yields to it: once a + // stop is running, "continue that stop" is the only thing the rider + // wants from the bottom of this screen, and a second bar under his + // thumb offering to start something else is how the wrong one gets + // pressed. See [startRound]. + // ── One contextual slot, and the workflow advances through it ── + // + // These were two `if`s over the same corner. Logically exclusive + // already — a live stop suppresses the start bar, because a + // second bar under the rider's thumb offering to start something + // else is how the wrong one gets pressed — but they *swapped* by + // one disappearing and the other appearing in the same frame, + // which reads as the screen glitching rather than as **Start + // delivery → Continue this stop**. + // + // One switcher, one child at a time, on the app's own state + // duration. Keyed by which surface it is rather than by its + // contents, so a poll that returns the same live stop does not + // replay the transition. + Positioned( + left: 0, + right: 0, + bottom: 0, + child: AnimatedSwitcher( + duration: DesignConstants.motionState, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + transitionBuilder: (child, anim) => FadeTransition( + opacity: anim, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.12), + end: Offset.zero, + ).animate(anim), + child: child, + ), + ), + child: _liveOnThisScreen.isNotEmpty + ? ActivePickupBanner( + key: const ValueKey('live'), + activePickups: _liveOnThisScreen, + onTap: (pickup) async { + await startPickup(pickup); + // Refresh after returning from map screen + if (mounted) { + await Future.delayed( + const Duration(milliseconds: 500), + ); + _fetchPicked(); + } + }, + ) + : _unreleased.isNotEmpty + ? StartRoundBar( + key: const ValueKey('start-round'), + count: _unreleased.length, + busy: _starting, + onPressed: _startWholeRound, + ) + : const SizedBox.shrink(key: ValueKey('none')), + ), + ), + ], + ), ), ), ), diff --git a/lib/views/Dashboard/pickups/sheet.dart b/lib/views/Dashboard/pickups/sheet.dart index 173396c..dbd321b 100644 --- a/lib/views/Dashboard/pickups/sheet.dart +++ b/lib/views/Dashboard/pickups/sheet.dart @@ -683,7 +683,7 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> if (payRes?['status'] != true && mounted) { AppFeedback.error( context, - "Payment recorded on your phone but not with the hub — tell them " + "Payment recorded on your phone but not with your office — tell them " "you collected ₹${_paidAmount.toStringAsFixed(0)}", ); } @@ -1285,19 +1285,27 @@ class _StopBrief extends StatelessWidget { children: [ Row( children: [ + // ── The avatar is a disc, and it is neutral ── + // + // A tinted rounded square in the leg's accent put a third coloured + // object on a sheet that already has a state pill and a coloured + // rail, and the colour said nothing the two of them had not + // already said. Initials are an identity, not a state: they take + // the app's quiet inset tone, and the round shape is what tells + // the eye it stands for a person. Container( - width: 40.w, - height: 40.w, + width: 42.w, + height: 42.w, alignment: Alignment.center, - decoration: BoxDecoration( - color: kind.accent.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + decoration: const BoxDecoration( + color: ColorConstants.neutralLight, + shape: BoxShape.circle, ), child: Text( initials, style: TextStyle( - color: kind.accent, - fontSize: 13.5.sp, + color: ColorConstants.secondaryText, + fontSize: 14.sp, fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, ), @@ -1355,8 +1363,12 @@ class _StopBrief extends StatelessWidget { // Aligned to the avatar's column so the two rows read as one block // rather than as two things that happen to be near each other. SizedBox( - width: 40.w, - child: Icon(LucideIcons.mapPin, size: 20.sp, color: kind.accent), + width: 42.w, + child: Icon( + LucideIcons.mapPin, + size: 20.sp, + color: ColorConstants.primary, + ), ), SizedBox(width: 12.w), Expanded( @@ -1364,12 +1376,20 @@ class _StopBrief extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( + // ── The destination label is brand ── + // + // It took the leg's accent — blue on a delivery — which is + // a *classification* colour, and this line is not + // classifying anything: the rider is already on the screen + // for this stop. What it is doing is pointing at where he + // has to go, and pointing is what the brand does on this + // sheet (the rail, the pin beside this label). kind.locationLabel, style: TextStyle( fontSize: 9.5.sp, fontWeight: FontWeight.w700, letterSpacing: 0.6, - color: kind.accent, + color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, ), ), diff --git a/lib/views/Dashboard/pickups/shipment_capture.dart b/lib/views/Dashboard/pickups/shipment_capture.dart index 2173ee1..fcffff7 100644 --- a/lib/views/Dashboard/pickups/shipment_capture.dart +++ b/lib/views/Dashboard/pickups/shipment_capture.dart @@ -209,7 +209,7 @@ class _ShipmentCapturePageState extends State { _quoteError = quote.found ? null : 'No price is configured for this weight and route. ' - 'Call the hub before collecting.'; + 'Call your office before collecting.'; }); } @@ -297,14 +297,22 @@ class _ShipmentCapturePageState extends State { Expanded( child: SingleChildScrollView( physics: const BouncingScrollPhysics(), - padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 24.h), + // ── The bands carry the gutter, the page does not ── + // + // Every child below is full-bleed and pads its own content by + // [_bandPad]. The page used to hold a 16pt margin and give each + // card a border and a radius on top of it, which spent 32 of a + // 390pt phone's width on two strips of grey either side of a + // form whose fields are addresses. The ground showing between + // two bands separates them just as well and costs nothing. + padding: EdgeInsets.only(bottom: 24.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _journeyCard(), - SizedBox(height: 14.h), + SizedBox(height: MilerBand.gap), _parcelCard(), - SizedBox(height: 14.h), + SizedBox(height: MilerBand.gap), _quotePanel(), ], ), @@ -360,13 +368,7 @@ class _ShipmentCapturePageState extends State { /// and it is the shape because it is read in one glance. /// ───────────────────────────────────────────────────────────────────── Widget _journeyCard() { - return Container( - padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 16.h), - decoration: BoxDecoration( - color: ColorConstants.pureSurface, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - border: Border.all(color: ColorConstants.borderSubtle, width: 1.2), - ), + return MilerBand( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -508,14 +510,14 @@ class _ShipmentCapturePageState extends State { /// One surface rather than a heading and three loose blocks, for the same /// reason as the journey above: these three answers are one description of /// one parcel, and the price is computed from all three together. + /// The inset a full-bleed band keeps around its content. The app's own, so + /// this page and the verification screen that hands off to it line up at the + /// same left margin. See [MilerBand]. + double get _bandPad => MilerBand.pad; + Widget _parcelCard() { - return Container( - padding: EdgeInsets.fromLTRB(14.w, 15.h, 14.w, 16.h), - decoration: BoxDecoration( - color: ColorConstants.pureSurface, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - border: Border.all(color: ColorConstants.borderSubtle, width: 1.2), - ), + return MilerBand( + padding: EdgeInsets.fromLTRB(_bandPad, 15.h, _bandPad, 16.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -588,21 +590,14 @@ class _ShipmentCapturePageState extends State { final quote = _quote; final priced = quote != null && !_quoting && _quoteError == null; - return Container( - width: double.infinity, - padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 16.h), - decoration: BoxDecoration( - color: priced - ? ColorConstants.acceptGreen.withValues(alpha: 0.06) - : ColorConstants.pureSurface, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - border: Border.all( - color: priced - ? ColorConstants.acceptGreen.withValues(alpha: 0.28) - : ColorConstants.borderSubtle, - width: 1.2, - ), - ), + // A priced band goes pale green and takes the rail; an unpriced one stays + // white. Both fills are opaque colours rather than the accent laid over the + // page — see [ColorConstants.tint]. + return MilerBand( + color: priced + ? ColorConstants.tint(ColorConstants.acceptGreen, 0.06) + : ColorConstants.pureSurface, + rail: priced ? ColorConstants.acceptGreen : null, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/views/Dashboard/pickups/shipment_review.dart b/lib/views/Dashboard/pickups/shipment_review.dart index 1f60eac..579e0cf 100644 --- a/lib/views/Dashboard/pickups/shipment_review.dart +++ b/lib/views/Dashboard/pickups/shipment_review.dart @@ -207,25 +207,29 @@ class _ShipmentReviewPageState extends State Expanded( child: SingleChildScrollView( physics: const BouncingScrollPhysics(), - padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 24.h), + // Full-bleed bands, like the verification screen this page is the + // end of. The section headers moved inside the band they head — a + // heading on the page and a card under it puts two left margins on + // one screen, which is the thing bands exist to stop. See + // [MilerBand]. + padding: EdgeInsets.only(bottom: 24.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _routeCard(), - SizedBox(height: 14.h), - const SectionHeader(title: 'Shipment'), - SizedBox(height: 8.h), + SizedBox(height: MilerBand.gap), _shipmentCard(), - SizedBox(height: 14.h), - const SectionHeader(title: 'Payment'), - SizedBox(height: 8.h), + SizedBox(height: MilerBand.gap), _paymentCard(), if (_error != null) ...[ - SizedBox(height: 14.h), - InfoBanner( - icon: LucideIcons.circleAlert, - text: _error!, - color: ColorConstants.errorRed, + SizedBox(height: MilerBand.gap), + Padding( + padding: EdgeInsets.symmetric(horizontal: MilerBand.pad), + child: InfoBanner( + icon: LucideIcons.circleAlert, + text: _error!, + color: ColorConstants.errorRed, + ), ), ], ], @@ -239,7 +243,7 @@ class _ShipmentReviewPageState extends State /// From → to, drawn as a journey rather than two labelled fields. Widget _routeCard() { - return AppCard( + return MilerBand( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -345,9 +349,12 @@ class _ShipmentReviewPageState extends State final weight = widget.shipment['weight']; final km = widget.shipment['approxkm']; - return AppCard( + return MilerBand( child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + const SectionHeader(title: 'Shipment'), + SizedBox(height: 4.h), _row('Weight', weight == null ? '—' : '$weight kg'), _divider(), _row('Category', _s(widget.shipment['category'])), @@ -374,9 +381,12 @@ class _ShipmentReviewPageState extends State } Widget _paymentCard() { - return AppCard( + return MilerBand( child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + const SectionHeader(title: 'Payment'), + SizedBox(height: 10.h), Row( children: [ for (final m in ShipmentPayMethod.values) ...[ @@ -425,7 +435,7 @@ class _ShipmentReviewPageState extends State padding: EdgeInsets.symmetric(vertical: 12.h), decoration: BoxDecoration( color: selected - ? ColorConstants.primary.withValues(alpha: 0.08) + ? ColorConstants.tint(ColorConstants.primary, 0.08) : ColorConstants.daylightSurface, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), border: Border.all( diff --git a/lib/views/Dashboard/pickups/skip_sheet.dart b/lib/views/Dashboard/pickups/skip_sheet.dart index 3e09194..6023d3f 100644 --- a/lib/views/Dashboard/pickups/skip_sheet.dart +++ b/lib/views/Dashboard/pickups/skip_sheet.dart @@ -299,7 +299,7 @@ Future _showMyOptionsSheet( // rather than a variant. const MilerSheetHeader( title: 'Skip this pickup', - subtitle: 'Tell us why, so the hub can reassign it.', + subtitle: 'Tell us why, so your office can reassign it.', ), SizedBox(height: 16.h), // ── Which pickup ── diff --git a/lib/views/Dashboard/pickups/stop_verify.dart b/lib/views/Dashboard/pickups/stop_verify.dart index e6cdcba..96af406 100644 --- a/lib/views/Dashboard/pickups/stop_verify.dart +++ b/lib/views/Dashboard/pickups/stop_verify.dart @@ -427,18 +427,25 @@ class _StopVerificationPageState extends State { onPopInvokedWithResult: (didPop, _) { if (!didPop) _back(); }, - // ── White page, tinted panels ── + // ── Grey page, white bands ── // - // Dropping the borders left the sections floating: a white card on a - // near-white page has no edge at all, so five panels read as one long - // undifferentiated form. Inverting it is what the Home stop card already - // does — the page is white and the card carries the fill — and it is the - // only way to separate surfaces without drawing a line around each one. - // See [ColorConstants.cardSurface] for why the fill sits 9.4% off white - // rather than the ~3% a "subtle" grey would give: this screen is filled - // in outdoors. + // It was the other way round — a white page carrying tinted panels — and + // that inversion was itself a fix for panels that would not separate. + // Both readings work; this one is taken because of what it does to the + // *width*. A tinted panel has to be inset from the page or its fill runs + // into the screen edge, so every section sat inside a 20pt gutter and the + // page spent 40 of its 390 points on two strips of white that said + // nothing. A white band on a grey ground has no such problem: it can run + // the full width, because the ground showing above and below it is what + // separates one section from the next. + // + // So the sections are full-bleed now and the gutter lives *inside* them, + // where it is padding around content rather than margin around a box. + // Same air next to the words, ~40pt more of it for the words themselves — + // which on this screen is the difference between an address on two lines + // and an address on three. child: Scaffold( - backgroundColor: ColorConstants.pureSurface, + backgroundColor: ColorConstants.daylightSurface, // ── The bar breathes, and its title is a title ── // // 17sp jammed against the status bar is a system default, not a @@ -462,15 +469,17 @@ class _StopVerificationPageState extends State { Expanded( child: SingleChildScrollView( physics: const BouncingScrollPhysics(), - padding: EdgeInsets.fromLTRB(20.w, 16.h, 20.w, 24.h), + // No horizontal padding: every child of this column is a + // full-bleed band that carries its own. See the note on the + // scaffold's ground. + padding: EdgeInsets.only(bottom: 24.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _stopSummary(), // No rule under the summary any more: the steps below - // are panels with their own edges, and a divider between a - // card and a card is a line drawn between two boundaries - // that already exist. + // are bands with the page's ground between them, and a + // divider between two boundaries is a third boundary. if (_hasReview && _step >= _legs.length) ...[ SizedBox(height: 6.h), _reviewStep(), @@ -639,136 +648,155 @@ class _StopVerificationPageState extends State { // first thing on the page, so every leg opened by drawing the eye to the one // block that needs nothing done to it. Unboxed it reads as a heading, which // is what it is. + /// The stop, as the band the form is being filled in *about*. + /// + /// Full-bleed white, like every section under it, so the page reads as one + /// stack of bands rather than a heading floating over a stack of boxes. Widget _stopSummary() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ── Both halves give ground, neither is cut off ── - // - // A fixed badge, a `Spacer` and a fixed amount: the row was wider - // than a 360pt phone from 1.3x system text upward, and the rider who - // has turned his font up is the one who most needs to read "₹2450 to - // collect". Flexible on both sides costs nothing at normal size — - // each still takes its natural width — and lets the longer one - // ellipsise instead of striping the card black and yellow. - Row( - children: [ - // The stop type as a dotted label rather than a filled badge. It - // is an attribute of the name below it, and a saturated chip made - // it the loudest thing on a screen whose subject is the form. - Container( - width: 6.w, - height: 6.w, - margin: EdgeInsets.only(right: 8.w), - decoration: BoxDecoration( - color: _kind.accent, - shape: BoxShape.circle, - ), - ), - Flexible( - child: Text( - _kind.titleLabel, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12.sp, - fontWeight: FontWeight.w700, + return MilerBand( + padding: EdgeInsets.fromLTRB(_bandPad, 16.h, _bandPad, 18.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Both halves give ground, neither is cut off ── + // + // A fixed badge, a `Spacer` and a fixed amount: the row was wider + // than a 360pt phone from 1.3x system text upward, and the rider who + // has turned his font up is the one who most needs to read "₹2450 to + // collect". Flexible on both sides costs nothing at normal size — + // each still takes its natural width — and lets the longer one + // ellipsise instead of striping the card black and yellow. + Row( + children: [ + // The stop type as a dotted label rather than a filled badge. It + // is an attribute of the name below it, and a saturated chip made + // it the loudest thing on a screen whose subject is the form. + Container( + width: 6.w, + height: 6.w, + margin: EdgeInsets.only(right: 8.w), + decoration: BoxDecoration( color: _kind.accent, - fontFamily: FontConstants.fontFamily, + shape: BoxShape.circle, ), ), - ), - const Spacer(), - if (_cash > 0) Flexible( child: Text( - '₹${_cash.toStringAsFixed(0)} to collect', + _kind.titleLabel, maxLines: 1, overflow: TextOverflow.ellipsis, - textAlign: TextAlign.right, style: TextStyle( fontSize: 12.sp, fontWeight: FontWeight.w700, - color: ColorConstants.acceptGreen, + color: _kind.accent, fontFamily: FontConstants.fontFamily, ), ), ), - ], - ), - SizedBox(height: 8.h), - Text( - _customerName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 19.sp, - fontWeight: FontWeight.w700, - letterSpacing: -0.5, - color: ColorConstants.slateText, - fontFamily: FontConstants.fontFamily, + const Spacer(), + if (_cash > 0) + Flexible( + child: Text( + '₹${_cash.toStringAsFixed(0)} to collect', + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.acceptGreen, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], ), - ), - SizedBox(height: 5.h), - // The pin is doing work, not decorating: this line and the customer - // name above it are both grey-on-white running text at a glance, and - // the glyph is what says which of the two is the place. - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.only(top: 2.h, right: 6.w), - child: Icon( - LucideIcons.mapPin, - size: 14.sp, - color: ColorConstants.secondaryText, - ), + SizedBox(height: 8.h), + Text( + _customerName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 19.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, ), - Expanded( - child: Text( - _address, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: MilerType.micro.copyWith( - fontSize: 13.sp, - height: 1.4, - color: ColorConstants.onSurfaceVariant, + ), + SizedBox(height: 5.h), + // The pin is doing work, not decorating: this line and the customer + // name above it are both grey-on-white running text at a glance, and + // the glyph is what says which of the two is the place. + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(top: 2.h, right: 6.w), + child: Icon( + LucideIcons.mapPin, + size: 14.sp, + color: ColorConstants.secondaryText, ), ), - ), - ], - ), - SizedBox(height: 12.h), - // Wrap rather than Row: on a combined stop the two pills together are - // wider than the page once the system font is up, and the second one - // belongs on its own line rather than off the edge of the screen. - Wrap( - spacing: 7.w, - runSpacing: 7.h, - children: [ - if (_expectedDelivery > 0) - _loadPill( - LucideIcons.truck, - 'Hand over $_expectedDelivery', - ColorConstants.deliveryAccent, + Expanded( + child: Text( + _address, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: MilerType.micro.copyWith( + fontSize: 13.sp, + height: 1.4, + color: ColorConstants.onSurfaceVariant, + ), + ), ), - if (_expectedPickup > 0) - _loadPill( - LucideIcons.package, - 'Collect $_expectedPickup', - ColorConstants.pickupAccent, - ), - ], - ), - ], + ], + ), + SizedBox(height: 12.h), + // Wrap rather than Row: on a combined stop the two pills together are + // wider than the page once the system font is up, and the second one + // belongs on its own line rather than off the edge of the screen. + Wrap( + spacing: 7.w, + runSpacing: 7.h, + children: [ + if (_expectedDelivery > 0) + _loadPill( + LucideIcons.truck, + 'Hand over $_expectedDelivery', + ColorConstants.deliveryAccent, + ), + if (_expectedPickup > 0) + _loadPill( + LucideIcons.package, + 'Collect $_expectedPickup', + ColorConstants.pickupAccent, + ), + ], + ), + ], + ), ); } + /// A finished band's ground: the accept green at 5.5%, resolved onto white + /// so it is an ordinary opaque colour rather than a wash that changes with + /// what is behind it. + static final Color _doneFill = ColorConstants.tint( + ColorConstants.acceptGreen, + 0.055, + ); + + /// The inset a band keeps around its content — the app's own, so this page + /// and the shipment desk it hands off to line up at the same left margin. + double get _bandPad => MilerBand.pad; + Widget _loadPill(IconData icon, String label, Color accent) { return Container( padding: EdgeInsets.symmetric(horizontal: 9.w, vertical: 6.h), decoration: BoxDecoration( - color: accent.withValues(alpha: 0.10), + color: ColorConstants.tint(accent, 0.10), borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), child: Row( @@ -897,7 +925,7 @@ class _StopVerificationPageState extends State { padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 14.h), decoration: BoxDecoration( color: ticked - ? ColorConstants.acceptGreen.withValues(alpha: 0.10) + ? ColorConstants.tint(ColorConstants.acceptGreen, 0.10) : ColorConstants.pureSurface, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), // The tick is the whole point of this control, so the untouched state @@ -1167,10 +1195,12 @@ class _StopVerificationPageState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // The heading lives on the page, not in a card — this is the screen - // talking, not a section of it. - Padding( - padding: EdgeInsets.fromLTRB(4.w, 4.h, 4.w, 0), + // The heading is the screen talking rather than a section of it, but it + // is still a band: with full-bleed sections under it, a heading inset + // by its own margin is the one thing on the page whose left edge does + // not line up with everything else. + MilerBand( + padding: EdgeInsets.fromLTRB(_bandPad, 16.h, _bandPad, 18.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -1219,7 +1249,7 @@ class _StopVerificationPageState extends State { ], ), ), - SizedBox(height: 14.h), + SizedBox(height: MilerBand.gap), if (_kind.hasDelivery) _reviewLeg( @@ -1251,7 +1281,8 @@ class _StopVerificationPageState extends State { ], ), - if (_kind.hasDelivery && _kind.hasPickup) SizedBox(height: 12.h), + if (_kind.hasDelivery && _kind.hasPickup) + SizedBox(height: MilerBand.gap), if (_kind.hasPickup) _reviewLeg( @@ -1296,7 +1327,7 @@ class _StopVerificationPageState extends State { ), if (_cash > 0) ...[ - SizedBox(height: 12.h), + SizedBox(height: MilerBand.gap), _notice( 'Collect ₹${_cash.toStringAsFixed(0)} from the customer on the ' 'next screen', @@ -1326,13 +1357,10 @@ class _StopVerificationPageState extends State { required bool done, File? photo, }) { - return Container( - width: double.infinity, - padding: EdgeInsets.all(16.r), - decoration: BoxDecoration( - color: ColorConstants.cardSurface, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - ), + // White, and full-bleed like every other band. It was a tinted rounded card, + // which on a grey page is a box on a box: the ground between two bands + // already says these are two legs of the stop. + return MilerBand( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -1375,8 +1403,8 @@ class _StopVerificationPageState extends State { return Container( padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h), decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + color: ColorConstants.tint(color, 0.12), + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), ), child: Row( mainAxisSize: MainAxisSize.min, @@ -1425,7 +1453,7 @@ class _StopVerificationPageState extends State { width: double.infinity, padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 14.h), decoration: BoxDecoration( - color: ColorConstants.warning.withValues(alpha: 0.10), + color: ColorConstants.tint(ColorConstants.warning, 0.10), borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), child: Row( @@ -1904,26 +1932,19 @@ class _StopVerificationPageState extends State { final accent = done ? ColorConstants.acceptGreen : _legAccent; return Padding( - padding: EdgeInsets.only(top: 14.h), - child: AnimatedContainer( - duration: DesignConstants.motionState, - curve: Curves.easeOut, - width: double.infinity, - padding: EdgeInsets.fromLTRB(16.w, 15.h, 16.w, 16.h), - decoration: BoxDecoration( - // Done recedes; outstanding keeps the page. Neither is a border - // colour — a filled state survives sunlight and a 1px edge does not. - color: done - ? ColorConstants.acceptGreen.withValues(alpha: 0.055) - : ColorConstants.daylightSurface, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - border: Border.all( - color: done - ? ColorConstants.acceptGreen.withValues(alpha: 0.22) - : ColorConstants.borderSubtle, - width: 1.2, - ), - ), + padding: EdgeInsets.only(top: MilerBand.gap), + // ── State in the fill, and the fill is a colour rather than a wash ── + // + // Done recedes and outstanding keeps the page; that is the right property + // to carry it, because a fill is the one thing readable at arm's length + // in sun. Both states are opaque colours rather than the accent laid over + // whatever is behind (see [ColorConstants.tint]), and the finished band + // adds the rail, which is what replaced the tinted outline these sections + // used to close with. See [MilerBand]. + child: MilerBand( + color: done ? _doneFill : ColorConstants.pureSurface, + rail: done ? ColorConstants.acceptGreen : null, + padding: EdgeInsets.fromLTRB(_bandPad, 15.h, _bandPad, 17.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -1935,7 +1956,11 @@ class _StopVerificationPageState extends State { height: 30.w, alignment: Alignment.center, decoration: BoxDecoration( - color: accent.withValues(alpha: 0.11), + color: ColorConstants.tint( + accent, + 0.11, + on: done ? _doneFill : ColorConstants.pureSurface, + ), borderRadius: BorderRadius.circular( DesignConstants.radiusLg, ), @@ -2020,7 +2045,7 @@ class _StopVerificationPageState extends State { return Container( padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h), decoration: BoxDecoration( - color: ColorConstants.warning.withValues(alpha: 0.10), + color: ColorConstants.tint(ColorConstants.warning, 0.10), borderRadius: BorderRadius.circular(DesignConstants.radiusFull), ), child: Text( @@ -2274,7 +2299,7 @@ class _StopVerificationPageState extends State { padding: EdgeInsets.symmetric(vertical: 30.h), width: double.infinity, decoration: BoxDecoration( - color: accent.withValues(alpha: 0.06), + color: ColorConstants.tint(accent, 0.06), borderRadius: BorderRadius.circular(DesignConstants.radiusXl), border: Border.all(color: accent.withValues(alpha: 0.30), width: 1.4), ), @@ -2302,9 +2327,14 @@ class _StopVerificationPageState extends State { Widget _notice(String text, Color accent) { return Container( width: double.infinity, + margin: EdgeInsets.symmetric(horizontal: _bandPad), padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 11.h), decoration: BoxDecoration( - color: accent.withValues(alpha: 0.10), + color: ColorConstants.tint( + accent, + 0.10, + on: ColorConstants.daylightSurface, + ), borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), child: Row( diff --git a/lib/views/Dashboard/profile/Profilepage.dart b/lib/views/Dashboard/profile/Profilepage.dart index fdece6b..bea2099 100644 --- a/lib/views/Dashboard/profile/Profilepage.dart +++ b/lib/views/Dashboard/profile/Profilepage.dart @@ -23,6 +23,7 @@ import 'package:miler/views/Dashboard/profile/informations/order_alert_sound.dar import 'package:miler/views/Dashboard/summary/summary.dart'; import 'package:miler/views/helpers/constants/miler_type.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/narrative.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; import 'package:miler/views/helpers/widgets/miler_app_bar.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; @@ -163,13 +164,26 @@ class _ProfilePageState extends State { // rows on the bare background, so the whole tab read a shade dirtier than // the three either side of it. Four tabs of one app should not disagree // about what colour the paper is. - // The brand shows through the sheet's two top corners — see [MilerSheet]. + // ── The corner has to have something behind it ── + // + // Account had the sheet but not the shape: its two top corners clipped + // onto a white ground, so the curve revealed white and read as a + // rounding artefact rather than as the brand coming through. The brand + // goes behind the sheet, the same as the other three tabs; the page's + // own colour is the sheet's [MilerSurface.canvas]. backgroundColor: ColorConstants.primary, // Account was the odd tab out: no top bar at all, just a 28sp inline // heading scrolling away with the content, while the other three sat // under a fixed branded bar. Four tabs of one app should not disagree // about where the page title lives. - appBar: MilerAppBar(title: 'Account', trailing: _bell()), + appBar: MilerAppBar( + title: 'Account', + trailing: _bell(), + // The bar's curve opens onto whatever is directly under it, and under + // this bar is the sheet's canvas rather than the app's paper. See + // [milerBrandSurface]. + pageColor: Narrative.canvas, + ), // ── Pull to refresh ── // // A pull here used to drag the page off the bottom of the screen and @@ -187,7 +201,7 @@ class _ProfilePageState extends State { // layered pages and one flat one. Same recipe here: canvas ground, one // panel per section, the labels on the canvas between them. body: MilerSheet( - color: MilerSurface.canvas, + color: Narrative.canvas, child: SettingsBody( onRefresh: _refresh, // The page's single left edge — the same gutter Activity uses, so a @@ -233,8 +247,8 @@ class _ProfilePageState extends State { // he has finished today, which is the glance he actually makes // between stops. Padding( - padding: EdgeInsetsDirectional.only( - start: MilerSurface.panelGutter + MilerSurface.panelPad, + padding: const EdgeInsetsDirectional.only( + start: MilerSurface.panelGutter, ), child: const SettingsLabel('Money'), ), @@ -257,8 +271,8 @@ class _ProfilePageState extends State { SizedBox(height: 20.h), Padding( - padding: EdgeInsetsDirectional.only( - start: MilerSurface.panelGutter + MilerSurface.panelPad, + padding: const EdgeInsetsDirectional.only( + start: MilerSurface.panelGutter, ), child: const SettingsLabel('Account'), ), @@ -305,8 +319,8 @@ class _ProfilePageState extends State { SizedBox(height: 20.h), Padding( - padding: EdgeInsetsDirectional.only( - start: MilerSurface.panelGutter + MilerSurface.panelPad, + padding: const EdgeInsetsDirectional.only( + start: MilerSurface.panelGutter, ), child: const SettingsLabel('Support'), ), diff --git a/lib/views/Dashboard/profile/informations/faq.dart b/lib/views/Dashboard/profile/informations/faq.dart index 857e2de..9dec6f1 100644 --- a/lib/views/Dashboard/profile/informations/faq.dart +++ b/lib/views/Dashboard/profile/informations/faq.dart @@ -52,20 +52,21 @@ class _FaqPageState extends State { static const List<_Faq> _faqs = [ _Faq( 'What is a trip, and why can I only work one stop at a time?', - 'Your hub assigns a whole slot as one trip: pick up from every customer ' - 'on it, then return to the hub. The stops are ordered for you, so the ' + 'Your office assigns a whole slot as one trip: pick up from every ' + 'customer on it, then return to base. The stops are ordered for you, ' + 'so the ' 'app opens them one at a time — finish the active stop before the ' 'next one unlocks.', ), _Faq( 'A customer is not answering. Can I move them to the end?', 'No — a trip keeps its order. Skip the stop with a reason instead. It ' - 'comes out of your queue, your hub sees why, and the rest of the trip ' + 'comes out of your queue, your office sees why, and the rest of the trip ' 'carries on unchanged.', ), _Faq( 'I am running late because of traffic. What should I do?', - 'Keep the app open so your location keeps reporting — the hub can see ' + 'Keep the app open so your location keeps reporting — your office can see ' 'the delay without you calling. If a customer needs to know, use the ' 'call button on the stop.', ), @@ -79,12 +80,12 @@ class _FaqPageState extends State { _Faq( 'I collected cash. Whose money is it?', 'It is the customer\'s payment to the business you are picking up from, ' - 'held by you until you hand it in at the hub. Record it in the app at ' + 'held by you until you hand it in at the end of your shift. Record it in the app at ' 'the stop so the amount you carry and the amount expected match.', ), _Faq( 'When do I get paid, and what are points?', - 'Earnings are settled by your hub on its regular cycle — Earnings shows ' + 'Earnings are settled by your office on its regular cycle — Earnings shows ' 'what has been counted so far. Points are separate: they are earned ' 'on completed trips and shown under Rewards.', ), @@ -98,7 +99,7 @@ class _FaqPageState extends State { 'How do I change my name, photo or address?', 'Account > Edit Profile for your name, photo and email; Account > Saved ' 'Address for your address. Your mobile number is fixed to the one ' - 'your hub registered — contact support to change it.', + 'your office registered — contact support to change it.', ), ]; diff --git a/lib/views/Dashboard/profile/informations/help_center.dart b/lib/views/Dashboard/profile/informations/help_center.dart index 3898a9b..ea6cf28 100644 --- a/lib/views/Dashboard/profile/informations/help_center.dart +++ b/lib/views/Dashboard/profile/informations/help_center.dart @@ -54,7 +54,7 @@ class HelpCenter extends StatelessWidget { ), SettingsRow( icon: LucideIcons.phone, - title: 'Call the hub', + title: 'Call the office', subtitle: 'For anything that cannot wait', onTap: () => _dial(context, _supportPhone), ), diff --git a/lib/views/Dashboard/profile/informations/rider_rewards_page.dart b/lib/views/Dashboard/profile/informations/rider_rewards_page.dart index ca54712..1ffb3ef 100644 --- a/lib/views/Dashboard/profile/informations/rider_rewards_page.dart +++ b/lib/views/Dashboard/profile/informations/rider_rewards_page.dart @@ -489,7 +489,7 @@ class _RiderRewardsPageState extends State { _ruleDivider(), _rule( icon: LucideIcons.route, - title: 'Ride the route your hub assigned', + title: 'Ride the route your office assigned', body: 'Wandering well past the planned distance breaks the ' 'route check for that stop.', @@ -534,8 +534,8 @@ class _RiderRewardsPageState extends State { SizedBox(width: 7.w), Expanded( child: Text( - 'Your hub sets what each status is worth and can change the ' - 'thresholds. Ask your hub manager what your level earns you.', + 'Your office sets what each status is worth and can change the ' + 'thresholds. Ask your manager what your level earns you.', style: TextStyle( fontSize: 11.5.sp, height: 1.45, diff --git a/lib/views/Dashboard/profile/informations/saved_address.dart b/lib/views/Dashboard/profile/informations/saved_address.dart index fa0ef17..98b6371 100644 --- a/lib/views/Dashboard/profile/informations/saved_address.dart +++ b/lib/views/Dashboard/profile/informations/saved_address.dart @@ -132,7 +132,7 @@ class _SavedAddressPageState extends State { children: [ _editing ? _editor() : _addressCard(), const SettingsHint( - 'Your hub uses this to work out which slots start near you, ' + 'Your office uses this to work out which slots start near you, ' 'and it is where your shift is assumed to end.', ), SizedBox(height: 24.h), diff --git a/lib/views/Dashboard/summary/summary.dart b/lib/views/Dashboard/summary/summary.dart index 851dcf7..affd2bc 100644 --- a/lib/views/Dashboard/summary/summary.dart +++ b/lib/views/Dashboard/summary/summary.dart @@ -8,6 +8,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:miler/Models/summary/riderweeklykms.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/narrative.dart'; import 'package:miler/views/helpers/widgets/miler_app_bar.dart'; import 'package:miler/controllers/summary_controller.dart'; import 'package:miler/controllers/rewards_controller.dart'; @@ -92,59 +93,89 @@ class _SummaryState extends State { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: ColorConstants.surface, + // ── The same ground and the same curve as the four tabs ── + // + // It was `surface`, the warm off-white, on a page with no sheet at all — + // so the one screen a rider opens to look at his money was the one screen + // that did not belong to the app around it. The brand behind the sheet's + // two top corners, the canvas under the cards, the gutter and the radii + // are all the shared ones now. + backgroundColor: ColorConstants.primary, // Earnings is no longer a tab — it opens from Account — so it needs a way // back that the four tabs never did. - appBar: const MilerAppBar(title: 'Earnings', showBack: true), - body: RefreshIndicator( - onRefresh: _refreshData, - color: ColorConstants.primary, - child: CustomScrollView( - physics: const AlwaysScrollableScrollPhysics( - parent: BouncingScrollPhysics(), - ), - slivers: [ - SliverToBoxAdapter( - child: Column( - children: [ - SizedBox(height: 16.h), - _buildPeriodTabs(), - SizedBox(height: 16.h), - Padding( - padding: EdgeInsets.symmetric(horizontal: 16.w), - child: _buildPickupsHero(), - ), - SizedBox(height: 16.h), - Padding( - padding: EdgeInsets.symmetric(horizontal: 16.w), - child: _buildStatsGrid(), - ), - SizedBox(height: 16.h), - Padding( - padding: EdgeInsets.symmetric(horizontal: 16.w), - child: TotalDistanceCard( - userId: _userId, - refreshTick: _refreshTick, - ), - ), - SizedBox(height: 32.h), - ], - ), + // `pageColor`: this Scaffold is brand-red, so the curve names the sheet + // under it instead — see [milerBrandSurface]. + appBar: const MilerAppBar( + title: 'Earnings', + showBack: true, + pageColor: ColorConstants.pureSurface, + ), + body: MilerSheet( + // White, like Home, Activity and the record page. It was + // [Narrative.canvas] — a grey ground for panels that carry a hairline + // and a lift of their own, so the grey was a second separator under + // things that already had one. Four screens, one colour. + color: ColorConstants.pureSurface, + child: RefreshIndicator( + onRefresh: _refreshData, + color: ColorConstants.primary, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), ), - ], + slivers: [ + SliverToBoxAdapter( + child: Column( + children: [ + SizedBox(height: 14.h), + _buildPeriodTabs(), + SizedBox(height: Narrative.gapBlock.h), + Padding( + padding: EdgeInsets.symmetric( + horizontal: Narrative.gutter.w, + ), + child: _buildPickupsHero(), + ), + SizedBox(height: Narrative.gapBlock.h), + Padding( + padding: EdgeInsets.symmetric( + horizontal: Narrative.gutter.w, + ), + child: _buildStatsGrid(), + ), + SizedBox(height: Narrative.gapBlock.h), + Padding( + padding: EdgeInsets.symmetric( + horizontal: Narrative.gutter.w, + ), + child: TotalDistanceCard( + userId: _userId, + refreshTick: _refreshTick, + ), + ), + SizedBox(height: 32.h), + ], + ), + ), + ], + ), ), ), ); } Widget _buildPeriodTabs() { + // The app's segmented control, the same object as Activity's filter and + // Home's trip tabs: a tonal track, one white pill, the selected label in + // the brand. It was a bordered white bar with a *filled maroon* segment — + // a third idiom for the one interaction, and the only place in the app + // where a selected tab was a solid brand slab. return Container( - margin: EdgeInsets.symmetric(horizontal: 16.w), - height: 44.h, + margin: EdgeInsets.symmetric(horizontal: Narrative.gutter.w), + height: 46.h, decoration: BoxDecoration( - color: ColorConstants.pureSurface, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - border: Border.all(color: ColorConstants.outlineVariant), + color: Narrative.insetDeep, + borderRadius: BorderRadius.circular(Narrative.radiusPill), ), child: Row( children: List.generate(_periods.length, (index) { @@ -158,23 +189,23 @@ class _SummaryState extends State { }); } }, - child: Container( + child: AnimatedContainer( + duration: DesignConstants.motionState, + curve: Curves.easeOutCubic, margin: EdgeInsets.all(4.r), decoration: BoxDecoration( - color: isSelected - ? ColorConstants.primary - : Colors.transparent, - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + color: isSelected ? Narrative.card : Colors.transparent, + borderRadius: BorderRadius.circular(Narrative.radiusPill), + boxShadow: isSelected ? Narrative.cardShadow : null, ), alignment: Alignment.center, child: Text( _periods[index], style: TextStyle( fontSize: 13.sp, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, - color: isSelected - ? Colors.white - : ColorConstants.onSurfaceVariant, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + letterSpacing: -0.1, + color: isSelected ? Narrative.burgundy : Narrative.inkMuted, fontFamily: FontConstants.fontFamily, ), ), @@ -203,11 +234,14 @@ class _SummaryState extends State { // not an effect. The two faint circles behind it survive: they are what // stops a solid maroon slab reading as a error state. decoration: BoxDecoration( - color: ColorConstants.primary, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + color: Narrative.burgundy, + borderRadius: BorderRadius.circular(Narrative.radiusCard), + // The one celebratory surface in the app, and the only place the + // brand's own glow is spent. + boxShadow: Narrative.burgundyGlow, ), child: ClipRRect( - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + borderRadius: BorderRadius.circular(Narrative.radiusCard), child: Stack( children: [ Positioned( @@ -317,14 +351,30 @@ class _SummaryState extends State { icon: LucideIcons.circleMinus, ), ), - SizedBox(width: 12.w), - Expanded( - child: _buildHeroMetric( - '$points', - 'Reward pts', - icon: LucideIcons.star, + // ── The reward tile is withheld, not zeroed ── + // + // `bonuspoints` is never written — confirmed by the + // backend on 24 Aug, along with the fact that + // `ridercharges` holds the *client's* order price rather + // than any rider payout, and that a real rate-card is a + // separate build. A tile reading **0 Reward pts** on + // every screen of every shift does not say "not built + // yet"; it says "you have earned nothing", which is a + // worse thing to tell a rider than saying nothing. + // + // It comes back the moment the figure is real: the tile + // is drawn whenever the source is non-zero, so no code + // has to change when the backend starts writing it. + if (points > 0) ...[ + SizedBox(width: 12.w), + Expanded( + child: _buildHeroMetric( + '$points', + 'Reward pts', + icon: LucideIcons.star, + ), ), - ), + ], ], ), ], @@ -437,8 +487,15 @@ class _SummaryState extends State { // competes with the hero above it for the reader's first look. Container( decoration: BoxDecoration( - color: ColorConstants.cardSurface, + // A hairline and a soft lift on white, rather than a tonal fill. + // The fill was carrying the separation while the page had a grey + // ground to sit on; on white it was a fourth surface tone doing + // what an edge does more quietly. Same recipe as every other card + // in the app — see [Narrative.cardBox]. + color: Narrative.card, borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + border: Border.all(color: Narrative.cardBorder), + boxShadow: Narrative.cardShadow, ), padding: EdgeInsets.symmetric(vertical: 16.h), child: IntrinsicHeight( @@ -575,6 +632,15 @@ class _TotalDistanceCardState extends State { String _shortDay(String day) => day.length <= 3 ? day : day.substring(0, 3); + /// `Mon` … `Sun`, for matching a chart row against the calendar. + /// + /// Written out rather than taken from `intl`: the series' own day names are + /// whatever the backend sends, and the only thing being compared is the first + /// three letters — so a locale-aware formatter would introduce a mismatch + /// this comparison cannot have. + String _weekdayName(DateTime d) => + const ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][d.weekday - 1]; + @override Widget build(BuildContext context) { return Container( @@ -713,16 +779,74 @@ class _TotalDistanceCardState extends State { Widget _buildChart(List details, double totalKms) { final values = details.map((e) => e.kms).toList(); - final maxVal = values.reduce((a, b) => a > b ? a : b); - final safeMax = maxVal <= 0 ? 1.0 : maxVal; + final maxVal = values.isEmpty + ? 0.0 + : values.reduce((a, b) => a > b ? a : b); - // Best day + averages for the summary row. - int bestIdx = 0; - for (int i = 1; i < values.length; i++) { - if (values[i] > values[bestIdx]) bestIdx = i; + // ── A week with nothing in it says so ── + // + // Seven bars of zero under a chart frame is not an empty state, it is a + // chart that looks broken — and it looked exactly like the bug this page + // had for months, where the series was never being read at all. An axis + // with nothing on it should be replaced, not drawn. + if (maxVal <= 0) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 34.h), + child: Column( + children: [ + Icon( + LucideIcons.chartNoAxesColumn, + size: 28.sp, + color: ColorConstants.secondaryText, + ), + SizedBox(height: 10.h), + Text( + 'No distance recorded this week', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 4.h), + Text( + 'Kilometres appear here once a stop is completed.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12.5.sp, + height: 1.4, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ); } + + final safeMax = maxVal; + final avgPerDay = details.isEmpty ? 0.0 : totalKms / details.length; + // ── Today, from the same series the chart is drawn from ── + // + // Matched on the day name rather than taken from a second endpoint: the + // bars and this figure have to agree, and the only way to guarantee that is + // for them to be the same number. A week whose rows carry no day names — + // which happens, and is why `Best day` used to render blank — simply has no + // today to point at, and the row prints the em dash. + final today = _shortDay(_weekdayName(DateTime.now())).toLowerCase(); + RiderWeeklyKms? todayRow; + for (final d in details) { + if (_shortDay(d.day).toLowerCase() == today) { + todayRow = d; + break; + } + } + return Column( children: [ SizedBox( @@ -843,17 +967,28 @@ class _TotalDistanceCardState extends State { // // These were amber / brand-red / blue — three hues for three ledger // facts, on the one page family where colour is reserved for the hero - // and the chart's own bars. And "Best Day" rendered blank when the - // week's data carries no day names, leaving a caption with nothing - // above it; an absent fact prints the em dash like everywhere else. + // and the chart's own bars. + // + // ── `Best day` gave way to `Today` ── + // + // Best day named a weekday — `Wed` — which is a fact about the past + // that a rider can do nothing with, and it rendered blank whenever the + // week's rows carried no day names. What he opens this page to find out + // is how far he has ridden *today*, and until now the app said that + // nowhere: the distance was per-stop on Activity and per-week here, + // with nothing in between. + // + // It also puts the same figure on two screens — this and the Activity + // hero — which is deliberate: they are read at different moments, and + // both take it from a measurement rather than from each other. Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ _buildChartStat( - 'Best day', - _shortDay(details[bestIdx].day).trim().isEmpty + 'Today', + todayRow == null ? '\u2014' - : _shortDay(details[bestIdx].day), + : '${todayRow.kms.toStringAsFixed(1)} km', ColorConstants.slateText, ), _buildChartStat( diff --git a/lib/views/helpers/constants/Colorconstants.dart b/lib/views/helpers/constants/Colorconstants.dart index 072f372..99873ee 100644 --- a/lib/views/helpers/constants/Colorconstants.dart +++ b/lib/views/helpers/constants/Colorconstants.dart @@ -232,57 +232,59 @@ class ColorConstants { /// snackbar. static const Color onAccent = Color(0xFFFFFFFF); - /// ── The frosted brand wash on every app bar ─────────────────────────── + /// ── A tint, resolved rather than laid over ── /// - /// A translucent maroon laid over the page rather than a solid slab of it. - /// Painted behind a blur (see `milerGlassSurface`), so what shows through is - /// the page's own colour softened and tinted. + /// `accent.withValues(alpha: 0.10)` is how nearly every tinted chip, pill and + /// panel in this app was drawn, and it is a *translucent* surface: what the + /// rider actually sees is the accent composited against whatever happens to + /// be behind it. That is fine on a white page and wrong the moment the same + /// chip appears on a tinted panel, a photo or a map — the same "10% green" + /// comes out four different colours across four screens, which is the drift + /// the surface system exists to stop. /// - /// Deliberately weak. At 7% the surface stays light enough for near-black - /// type, which is what keeps a title readable through a windscreen mount in - /// sun. - static const Color glassRed = Color(0x12960019); + /// This resolves the composite once, against the surface the caller names, so + /// what comes back is an ordinary opaque colour. Same appearance where the + /// old code was already over white; predictable everywhere else. + static Color tint(Color accent, double amount, {Color on = pureSurface}) => + Color.alphaBlend(accent.withValues(alpha: amount), on); - /// ── The fill on a frosted sheet ── + /// ── The fill on a bottom sheet ── /// - /// A sheet over a map has something worth seeing underneath it, and a solid - /// slab throws all of it away. Frosted, the map stays legible as context while - /// the sheet stays legible as content. - /// - /// Deliberately high at 88%. This is not decoration: the text over it is an - /// address and an ETA, read one-handed at a doorstep in daylight, and every - /// point of transparency spent on the effect is contrast taken off the words. - static const Color glassSheet = Color(0xE0FDFDFE); + /// It was 88% white — a frosted pane that let the map read through it. It is + /// opaque now, because every point of transparency spent on the effect was + /// contrast taken off the words, and the words here are an address and an ETA + /// read one-handed at a doorstep in daylight. The map is not lost: the sheet + /// covers 45% of the screen and the other 55% is still the map. + static const Color glassSheet = Color(0xFFFFFFFF); - /// The specular rim along a glass edge — what makes frost read as a pane - /// rather than as a translucent rectangle. - static const Color glassRim = Color(0x8CFFFFFF); + /// The hairline along a sheet's or a card's edge. It was a specular white + /// highlight that only read as an edge because the surface under it was + /// translucent; on an opaque surface the same job needs a real line. + static const Color glassRim = Color(0xFFE5E7EB); - /// ── The pane a list card is made of ── + /// ── The surface a list card is made of ── /// /// Every card the rider scrolls — a stop on Home, a job on Deliveries — is - /// this fill with a soft, wide shadow under it ([DesignConstants.shadowGlass]) - /// and **no border at all**. + /// this fill, a hairline and a soft, wide shadow. /// - /// Not a solid white: at 92% the page's slate tint comes through just enough - /// that the card reads as a pane laid over the screen rather than a white - /// rectangle cut into it, which is the whole difference between "glass" and - /// "box". It stays high enough for near-black type at any brightness — a - /// rider reads addresses off this surface in direct sun. + /// ── 0xEB → 0xFF ── /// - /// No `BackdropFilter`. A real blur re-samples everything behind it every - /// frame, and a scrolling list of twenty of them is the single most reliable - /// way to drop a mid-range Android phone to 30fps. The blur is reserved for - /// the two surfaces that genuinely sit over live content — the app bar and - /// the bottom sheets ([milerGlassSheet]). - static const Color glassCard = Color(0xEBFFFFFF); + /// It was 92% white, so the page's slate tint came through and the card read + /// as a pane laid *over* the screen. That is a nice effect on a design file + /// and a cost on a handlebar: a translucent card takes its contrast from + /// whatever happens to be behind it, so the same address is crisp over the + /// canvas and grey over a map. Opaque, it reads the same everywhere, which is + /// the only property that matters on a surface a rider takes an address off + /// in direct sun. Depth comes from the hairline and the lift instead. + static const Color glassCard = Color(0xFFFFFFFF); - /// The same pane, warmed by the brand, for the one stop that is under way. + /// The same surface, warmed by the brand, for the one stop that is under way. /// - /// A whisper — 5% — because a card the rider has to *read* is the wrong place - /// for a saturated fill: what marks the live stop is the LIVE chip and the - /// button on it, not a red panel behind its address. - static const Color glassCardLive = Color(0x0D960019); + /// A whisper — the brand at 5%, flattened onto white rather than laid over + /// the card as a translucent wash — because a card the rider has to *read* is + /// the wrong place for a saturated fill: what marks the live stop is the LIVE + /// chip and the button on it, not a red panel behind its address. + static const Color glassCardLive = Color(0xFFFAF2F3); // ── Daylight surfaces ──────────────────────────────────────────────── // Tuned for a phone clamped to a handlebar in direct sun. Slate-tinted grey diff --git a/lib/views/helpers/constants/design_constants.dart b/lib/views/helpers/constants/design_constants.dart index 1829b56..62c64ac 100644 --- a/lib/views/helpers/constants/design_constants.dart +++ b/lib/views/helpers/constants/design_constants.dart @@ -71,12 +71,27 @@ class DesignConstants { // `radius2xl` (20) is retired: it was the third-most-used value and had no // job the container radius could not do. + // ── 12/16 → 14/20 ── + // + // Both steps went up one notch, together, after four screens were rebuilt + // against reference designs and every one of them came back reading harder + // than the picture it was drawn from. The shapes were right and the corners + // were not: at 16 a full-width card on a phone is a rectangle with the edges + // taken off, and the difference between that and something that reads as a + // *soft object* is about four points. + // + // Moved as a pair so the ladder holds — an inner tile has to stay visibly + // tighter than the surface it sits on, or the two corners fight — and moved + // in the tokens rather than at call sites, because a radius that is softer + // on the screens somebody redesigned last is exactly how an app ends up with + // 26 of them again. + /// **Inner** — inputs, chips, small tiles, panels *inside* a container. - static const double radiusLg = 12.0; + static const double radiusLg = 16.0; /// **Container** — cards, sheets, and anything that is a surface in its own /// right. Buttons use this too, so a button and the card it sits on agree. - static const double radiusXl = 16.0; + static const double radiusXl = 24.0; /// **Pill** — fully rounded: pills, circular tracks, avatars. static const double radiusFull = 999.0; diff --git a/lib/views/helpers/constants/miler_surface.dart b/lib/views/helpers/constants/miler_surface.dart index 9b2857b..914b485 100644 --- a/lib/views/helpers/constants/miler_surface.dart +++ b/lib/views/helpers/constants/miler_surface.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/narrative.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; /// ───────────────────────────────────────────────────────────────────────── @@ -46,7 +47,15 @@ abstract final class MilerSurface { /// /// Deliberately not white: a canvas that is white leaves nothing for a white /// surface to be distinguishable *from*, which is the state this app was in. - static const Color canvas = ColorConstants.daylightSurface; + /// + /// ── #DEE3EA → #F8F9FA ── + /// + /// It was a heavy slate, chosen when a card was a bare white fill and the + /// tonal step was the *only* thing separating the two. Cards carry a hairline + /// and a lift now (see [MilerPanel]), so the ground no longer has to do that + /// work alone — and a page this dark under bordered cards reads as heavy + /// rather than as layered. The brief's canvas is the value. + static const Color canvas = Narrative.canvas; /// **Layer 1.** A region the rider reads or works in — a sheet's body, a /// details page, a grouped operational block. @@ -88,10 +97,18 @@ abstract final class MilerSurface { /// The gutter a layer-1 panel leaves around itself, so the canvas reads as /// ground rather than as a hairline. One number, because two panels a few /// points apart is how a page stops looking composed. + /// + /// 12 → 20. The brief keeps a generous margin off the screen's edge so + /// nothing interactive touches it and the panels read as floating; its own + /// figure is 24, and 20 is where that lands once the panel's internal padding + /// (below, also raised) is added to it — 40 of inset before a word on a + /// 390pt phone is as far as this can go and still hold a full address. static const double panelGutter = 12; /// The breathing room *inside* a layer-1 panel. - static const double panelPad = 14; + /// + /// 14 → 20, the brief's figure for the padding inside a card. + static const double panelPad = 16; } /// ── Layer 1, as a widget ── @@ -140,10 +157,17 @@ class MilerPanel extends StatelessWidget { vertical: MilerSurface.panelPad, ) : EdgeInsets.zero, - decoration: BoxDecoration( - color: MilerSurface.working, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - ), + // ── Level 1, as the Narrative brief draws it ── + // + // It was a bare white fill on a dark canvas: separation bought with a + // 1.29:1 tonal step, no border and no shadow, on the rule that an + // outline over a step that already works is a line to read past. + // + // The brief inverts that trade — a *light* canvas, and cards that carry + // a hairline and a soft lift. Both work; what does not is half of each, + // so the panel takes the brief's whole recipe and the ground goes light + // with it. See [Narrative]. + decoration: Narrative.cardBox(), child: child, ); } diff --git a/lib/views/helpers/constants/narrative.dart b/lib/views/helpers/constants/narrative.dart new file mode 100644 index 0000000..a5bf768 --- /dev/null +++ b/lib/views/helpers/constants/narrative.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// PREMIUM LOGISTICS NARRATIVE — the design system the rebuilt screens follow +/// +/// A supplied spec, transcribed rather than interpreted: the values below are +/// the ones in the brief. It is a different recipe from [MilerSurface]'s, and +/// the difference is the whole point, so it is worth stating plainly: +/// +/// ``` +/// MilerSurface dark canvas, white cards, NO border and NO shadow +/// — separation bought with a 1.29:1 tonal step +/// Narrative light canvas, white cards, 1px border AND a soft shadow +/// — separation bought with an edge and a lift +/// ``` +/// +/// Both work; what does not work is half of each. So a screen belongs to one +/// system or the other, and the ones rebuilt against this brief take this one +/// whole — canvas, card, border, shadow, radius and rhythm together. +/// +/// ── No glass ── +/// +/// The brief's own Level 2 is a frosted, 70%-white overlay. It is deliberately +/// **not** transcribed here: translucency was cut from these screens on the +/// instruction that came with the spec. A blurred surface is also the single +/// most reliable way to drop a mid-range Android below 60fps, which is the +/// phone this app ships to — so nothing here is see-through, and depth comes +/// from tone, edge and shadow instead. +/// ───────────────────────────────────────────────────────────────────────── +class Narrative { + Narrative._(); + + // ── Surfaces ────────────────────────────────────────────────────────── + + /// **Level 0.** The page. A soft, cool grey — light enough that a white card + /// on it needs its edge and its shadow to read, which is exactly what this + /// system gives them. + static const Color canvas = Color(0xFFF8F9FA); + + /// **Level 1.** A card: pure white, over [cardBorder] and [cardShadow]. + static const Color card = Color(0xFFFFFFFF); + + /// The hairline around a card. 1.13:1 against white on its own — it is not + /// asked to separate anything by itself, it is asked to *finish* an edge the + /// shadow has already softened. + static const Color cardBorder = Color(0xFFE5E7EB); + + /// A tonal block *inside* a card — an address, a fact tile, a task row. + static const Color inset = Color(0xFFF3F4F5); + + /// One step down from [inset], for a control's resting state. + static const Color insetDeep = Color(0xFFEDEEEF); + + // ── Ink ─────────────────────────────────────────────────────────────── + + /// Headings and any figure the screen is about. 15.9:1 on [card]. + static const Color ink = Color(0xFF191C1D); + + /// Supporting copy, captions, the quiet half of a pair. 8.0:1 on [card]. + static const Color inkSoft = Color(0xFF584141); + + /// Labels in data-dense places — timestamps, counts, eyebrows. 4.9:1. + static const Color inkMuted = Color(0xFF6B6B6B); + + // ── Accents ─────────────────────────────────────────────────────────── + + /// **Deep burgundy.** The brand, spent sparingly: primary actions, the + /// selected state, and the one figure a screen is answering with. + /// + /// The brief names #800020 in prose and #570013 in its token table. The + /// prose value is the one used, because it is the one the brief calls "the + /// palette is centred around" — and it is the value the mock-ups were drawn + /// with. + static const Color burgundy = Color(0xFF800020); + + /// The burgundy at the weight a tint reads on white without becoming a + /// surface of its own — the brief's "10% opacity burgundy" ghost fill. + static const Color burgundyWash = Color(0x1A800020); + + /// **Refined emerald.** Completion, and only completion. + static const Color emerald = Color(0xFF00472C); + + /// The emerald as a chip fill, with [emerald] as its text — the brief's + /// "light tint of the status colour, darker text of the same hue". + static const Color emeraldWash = Color(0x1A00472C); + + /// Amber, for the one row that still owes somebody something. + static const Color amber = Color(0xFF8E5A00); + static const Color amberWash = Color(0x1A8E5A00); + + // ── Shape ───────────────────────────────────────────────────────────── + // + // "Architectural curvature": large, intentional radii, nested so a child is + // always visibly tighter than the surface holding it. + + /// A card, a sheet, a panel — anything that is a surface in its own right. + static const double radiusCard = 28; + + /// A block inside a card: a tonal well, a task row, a fact tile. + static const double radiusInner = 18; + + /// A chip, an icon container, a small tile. + static const double radiusChip = 14; + + /// Fully rounded — pills, segmented tracks, avatars. + static const double radiusPill = 999; + + // ── Elevation ───────────────────────────────────────────────────────── + + /// A card's lift: wide, diffuse and almost invisible. Offset 0,4 · blur 20 · + /// 4% black. It is not there to be seen; it is there so the edge above it + /// does not have to carry the separation alone. + static const List cardShadow = [ + BoxShadow(color: Color(0x0A000000), blurRadius: 20, offset: Offset(0, 4)), + ]; + + /// A surface that floats over the page rather than sitting in it. + static const List floatShadow = [ + BoxShadow(color: Color(0x14000000), blurRadius: 28, offset: Offset(0, 8)), + ]; + + /// The glow under a primary action — the brand's own colour, so the button + /// reads as lit rather than as merely raised. + static const List burgundyGlow = [ + BoxShadow(color: Color(0x33800020), blurRadius: 20, offset: Offset(0, 8)), + ]; + + // ── Rhythm ──────────────────────────────────────────────────────────── + // + // Base-8. Two sections are 32 apart, two blocks 16, two lines 8. + + /// The margin the page keeps off the screen's edge, so nothing interactive + /// touches it and the cards read as floating. + /// + /// ── 24 → 14 ── + /// + /// The brief's figure is 24, and it is written for a page of prose. These + /// pages are lists of stops read at arm's length: at 24 of margin plus 20 of + /// card padding, a customer's name started 44pt in on a 390pt phone and the + /// screen held two fewer rows for it. The reference mock-ups the brief came + /// with are drawn at about half its own number, which is the answer — the + /// margin exists so the cards read as floating, and 14 does that. + /// + /// ── 14 → 12 ── + /// + /// One more point off each side, measured on device rather than reasoned: + /// with a 1px border and a 20-blur shadow the card's *visual* edge already + /// sits a point or two inside its box, so 14 of margin reads as 16 and the + /// page looked like it had a frame. 12 is where the gap stops being noticed + /// and starts just being air. + static const double gutter = 12; + + /// The padding inside a card. The brief says 20; 16 is what leaves the + /// content room once [gutter] is added to it on a phone. + static const double cardPad = 16; + + /// Between two logical sections. + static const double gapSection = 32; + + /// Between two blocks in a section. + static const double gapBlock = 16; + + /// Between two lines of one block. + static const double gapLine = 8; + + /// Decoration for a Level-1 card, so no screen has to re-declare the three + /// values that make one. + static BoxDecoration cardBox({double? radius}) => BoxDecoration( + color: card, + borderRadius: BorderRadius.circular(radius ?? radiusCard), + border: Border.all(color: cardBorder, width: 1), + boxShadow: cardShadow, + ); +} diff --git a/lib/views/helpers/widgets/app_widgets.dart b/lib/views/helpers/widgets/app_widgets.dart index fbe4f05..4032eb6 100644 --- a/lib/views/helpers/widgets/app_widgets.dart +++ b/lib/views/helpers/widgets/app_widgets.dart @@ -4,12 +4,14 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'package:shimmer/shimmer.dart'; -import 'miler_app_bar.dart' show milerGlassSurface; import 'package:miler/Models/stop_status.dart'; import 'package:miler/views/helpers/constants/miler_type.dart'; +import 'package:miler/views/helpers/widgets/miler_app_bar.dart' + show milerBrandSurface; import '../constants/Colorconstants.dart'; import '../constants/design_constants.dart'; @@ -24,6 +26,96 @@ import '../constants/Font_constant.dart'; /// every screen stays visually consistent. /// ─────────────────────────────────────────────────────────────────────────── +/// ───────────────────────────────────────────────────────────────────────── +/// A BAND — a section that runs the full width of the phone +/// +/// The alternative, and what most of this app still does, is a card: a +/// surface inset from both edges by a page margin, closed with a radius and +/// usually an outline. That shape is right when a screen shows a *list of +/// objects* — a stop, a job, a booking — because the margin is what says +/// "these are separate things". +/// +/// It is wrong for a screen that is one long thing: a form, a review, a +/// receipt. There the margin buys nothing and costs twice. It costs width — +/// 32 to 40 points off a 390pt phone, spent on two strips of empty ground +/// beside text that is mostly addresses — and it costs a second boundary, +/// because the gap between two cards already separates them and the outline +/// says it again. +/// +/// A band takes the other trade: it runs edge to edge, the page's ground +/// showing between one band and the next is the only separator, and the +/// margin moves *inside* as padding, where it is air around words rather than +/// air around a box. Same breathing room at the left of a line, ~40pt more +/// line to breathe in. +/// +/// ── The rail ── +/// +/// A band cannot carry state in an outline it does not have, so state that has +/// to read at arm's length goes in the fill, and a finished band adds [rail] — +/// a short accent stripe down its leading edge. It is the one mark that stays +/// legible when a phone is at arm's length in sun, and it is cheap: three +/// points of colour rather than a whole tinted border. +/// ───────────────────────────────────────────────────────────────────────── +class MilerBand extends StatelessWidget { + final Widget child; + + /// The band's ground. White unless the band's state says otherwise, and an + /// **opaque** colour either way — see [ColorConstants.tint] for why a + /// translucent wash is the wrong tool for a surface. + final Color? color; + + /// An accent stripe down the leading edge, for a band whose state has to + /// read without being examined. Null draws no stripe and no inset. + final Color? rail; + + /// Overrides the standard inset. Pass only where the child owns its own + /// horizontal padding — a row that has to bleed to the band's edge. + final EdgeInsets? padding; + + const MilerBand({ + super.key, + required this.child, + this.color, + this.rail, + this.padding, + }); + + /// The inset a band keeps around its content. + /// + /// One number for the whole app. With the bands running edge to edge, their + /// padding is the only thing left aligning the page's left margin — two + /// bands a couple of points apart is exactly how a column of text stops + /// looking like a column. + static double get pad => 20.w; + + /// The gap of page ground between one band and the next. This is the only + /// separator a band has, so it is a real gap rather than a hairline. + static double get gap => 10.h; + + @override + Widget build(BuildContext context) { + // Animated because [color] and [rail] carry state: a section going from + // outstanding to finished should be seen to change, and a hard cut on a + // full-width fill reads as the screen having been replaced. Bands whose + // state never moves pay nothing for this. + return AnimatedContainer( + duration: DesignConstants.motionState, + curve: Curves.easeOut, + width: double.infinity, + padding: padding ?? EdgeInsets.symmetric(horizontal: pad, vertical: 16.h), + decoration: BoxDecoration( + color: color ?? ColorConstants.pureSurface, + border: rail == null + ? null + : Border( + left: BorderSide(color: rail!, width: 3.w), + ), + ), + child: child, + ); + } +} + /// White rounded card with the design-system shadow. class AppCard extends StatelessWidget { final Widget child; @@ -792,6 +884,12 @@ PreferredSizeWidget milerPageBar( /// True for a screen that genuinely *dismisses* rather than pops — a sheet /// presented modally. Only this gets the cross. bool isModal = false, + + /// The colour of the surface directly under the bar, so the curve at its + /// bottom corners opens onto the page rather than onto more brand. + /// Transparent reveals the Scaffold's own background, which is right for + /// every pushed page here. See [milerBrandSurface]. + Color pageColor = Colors.transparent, }) { final IconData backIcon = isModal ? LucideIcons.x : LucideIcons.arrowLeft; final bool hasControlRow = onBack != null || (actions?.isNotEmpty ?? false); @@ -805,10 +903,15 @@ PreferredSizeWidget milerPageBar( // the phone AND the colour of the status-bar glyphs, which reads as leaving // the app rather than opening a page inside it. // - // Solid [ColorConstants.primary], and everything on it inverts. The wash - // survives as `milerGlassSurface()` for the sheets, which sit over content - // and still want it. - backgroundColor: ColorConstants.primary, + // One ground, and everything on it inverts. The wash and the blur it was + // painted behind are both gone; what the bar wears now is + // [kBrandGradient], with its bottom corners taken off. + // + // **Transparent**, and the surface does all the painting: an opaque fill + // behind a rounded surface fills in the two corners the curve exists to + // open. See [milerBrandSurface]. + backgroundColor: Colors.transparent, + flexibleSpace: milerBrandSurface(pageColor: pageColor), surfaceTintColor: Colors.transparent, foregroundColor: Colors.white, iconTheme: const IconThemeData(color: Colors.white), @@ -937,17 +1040,28 @@ Duration staggerDelay(int index, {int stepMs = 45, int maxMs = 320}) => /// ───────────────────────────────────────────────────────────────────────── /// GLASS CARD — the one surface every list card in the app is drawn on. /// -/// A translucent pane, a soft wide shadow, and **no border**. +/// An opaque surface, a hairline, and a soft wide shadow. /// -/// ── Why no border ── +/// ── The name is older than the material ── /// -/// A border, a fill and a shadow are three devices doing one job, and the -/// border is the one that competes with the content: six bordered rectangles -/// down a phone screen read as a form to be filled in, not a route to be -/// ridden. Every large logistics app the rider already uses — Uber, Swiggy, -/// Amazon Shopper — separates list cards with elevation alone, and it survives -/// what a hairline does not: a scratched screen in direct sun, where a 1px -/// #E2E8F0 line is simply not there. +/// It was a translucent pane with no border at all, and both halves of that +/// have been undone. The pane went opaque because a translucent card takes its +/// contrast from whatever happens to be behind it — the same address is crisp +/// over the canvas and grey over a map — and the hairline came back because on +/// the budget LCD this app ships to, a shadow crushes to nothing and a +/// borderless card reads as printed onto the page. +/// +/// The name is kept because seven call sites use it and a rename buys nothing. +/// +/// ── What the border is and is not ── +/// +/// The risk a border carries is that it competes with the content: six heavily +/// bordered rectangles down a phone screen read as a form to be filled in, not +/// a route to be ridden. So it is a *hairline* and it is nearly the card's own +/// colour — it is not asked to separate the card from the page, which is the +/// shadow's job and the canvas step's. It is asked to finish an edge that the +/// shadow has already softened, and to be the thing still standing when the +/// shadow is crushed away by sunlight. /// /// ── Why one widget rather than a decoration constant ── /// @@ -969,8 +1083,8 @@ class GlassCard extends StatelessWidget { final String? semanticLabel; final String? semanticHint; - /// A wash over the pane, for the one card on a screen that is *live*. - /// Deliberately a whisper — see [ColorConstants.glassCardLive]. + /// The card's fill, for the one card on a screen that is *live*. Deliberately + /// a whisper — see [ColorConstants.glassCardLive]. final Color? tint; /// Dims the whole card without changing a single colour inside it, for a @@ -998,31 +1112,26 @@ class GlassCard extends StatelessWidget { margin: margin, padding: padding, decoration: BoxDecoration( - color: ColorConstants.glassCard, - borderRadius: BorderRadius.circular(radius), - // ── The rim is what a budget LCD can actually render ── + // ── The tint replaces the fill, it does not sit over it ── // - // On a calibrated panel the slate shadow separates the pane from the + // It used to be a translucent wash laid *inside* the card, on the + // reasoning that a live card should be the same glass as everything + // else, warmed. With opaque surfaces that overlay would cover the + // content rather than warm it, so the live card is simply a card with a + // different fill — which is what it always looked like anyway. + color: tint ?? ColorConstants.glassCard, + borderRadius: BorderRadius.circular(radius), + // ── The hairline is what a budget LCD can actually render ── + // + // On a calibrated panel the shadow alone separates the card from the // ground; on the LCD this app ships to, shadows crush to nothing and // the cards read as printed on the page — reported twice from the - // device. The one-pixel rim is the same trick the frosted sheets use - // ([milerGlassSheet]'s glassRim): a line of light on the curve costs - // nothing at any brightness and says "edge" where blur cannot. + // device. A one-pixel edge costs nothing at any brightness and says + // "edge" where a blur cannot. border: Border.all(color: ColorConstants.borderSubtle), boxShadow: DesignConstants.shadowGlass, ), - // The tint rides *inside* the pane rather than replacing it, so a live - // card is the same glass everything else is, warmed — not a different - // material. - child: tint == null - ? child - : DecoratedBox( - decoration: BoxDecoration( - color: tint, - borderRadius: BorderRadius.circular(radius), - ), - child: child, - ), + child: child, ); final tappable = onTap == null diff --git a/lib/views/helpers/widgets/load_state_view.dart b/lib/views/helpers/widgets/load_state_view.dart index ea1ec5e..244b6a2 100644 --- a/lib/views/helpers/widgets/load_state_view.dart +++ b/lib/views/helpers/widgets/load_state_view.dart @@ -97,7 +97,7 @@ class LoadStateView extends StatelessWidget { // 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. LoadFailureKind.server => - message.isEmpty ? 'The hub could not be reached.' : message, + message.isEmpty ? 'Your office could not be reached.' : message, }, onRetry: kind.isRetryable ? onRetry : null, ), diff --git a/lib/views/helpers/widgets/miler_app_bar.dart b/lib/views/helpers/widgets/miler_app_bar.dart index f3c16e4..de86cc3 100644 --- a/lib/views/helpers/widgets/miler_app_bar.dart +++ b/lib/views/helpers/widgets/miler_app_bar.dart @@ -1,6 +1,4 @@ import 'dart:io'; -import 'dart:ui' show ImageFilter; - import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter/services.dart'; @@ -8,65 +6,131 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'package:miler/controllers/profile_controller.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/narrative.dart'; import 'package:miler/views/helpers/constants/miler_surface.dart'; import 'package:miler/views/helpers/constants/miler_type.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; -/// The frosted brand surface every app bar in the app sits on. +/// The brand, as a surface with light falling across it. /// -/// ── Why a filter and not just a tinted box ── +/// Two stops about eight per cent apart, top-left to bottom-right. Enough that +/// a header reads as an object rather than as a rectangle filled with one hex +/// value; not enough to be seen *as* a gradient, which on a brand ground is the +/// difference between premium and 2014. The app's own red stays the darker +/// stop, so nothing about the colour the app is known by changes. /// -/// A flat 7% red over white is a colour; glass is a *relationship* — it takes -/// whatever is underneath and softens it. Content scrolls under these bars, so -/// the blur has something real to work on: a card edge passing beneath the bar -/// blurs out rather than sliding under a solid lid, which is the whole visual -/// difference between "the page continues up there" and "the page stops here". +/// Declared once here because three screens wear it — Home's own header builds +/// it inline for the same values — and a gradient copied per screen is how two +/// pages end up a shade apart. +const LinearGradient kBrandGradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFA51C1C), Color(0xFF8B1A1A)], +); + +/// ── The bar curves UP, and draws the curve itself ── /// -/// Pass as `flexibleSpace` with a transparent `backgroundColor`, so the wash -/// covers the status-bar inset too — a bar tinted only below the notch reads as -/// a stripe rather than a surface. +/// Every page in this app used to do the opposite: the body was a [MilerSheet] +/// with rounded *top* corners over a brand-red Scaffold, so the maroon filled +/// in behind the curve and reached furthest down at the two outside edges. The +/// header's bottom edge therefore read as sagging at the corners — a page +/// hanging off a bar rather than a bar sitting over a page. /// -/// ── And no line under it ── +/// Home was inverted first and it is the right way round. This is the same +/// shape, made available to every other bar. +/// ── Logical points, not `.r` ── /// -/// The bar closed with a hairline for as long as it was white, because a white -/// bar over a near-white page has no edge of its own and would otherwise bleed -/// into the content. The wash *is* an edge — a tinted surface against an -/// untinted one — so the rule underneath became a second boundary drawn on top -/// of the first, which is exactly the doubled-up border this app has been -/// pulling out of its cards. Glass separates by being a different surface, not -/// by being fenced. -Widget milerGlassSurface() { - return ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18), - child: Container(color: ColorConstants.glassRed), +/// Everything else in this app sizes with ScreenUtil, and this deliberately +/// does not. `milerBrandSurface()` is called while the `AppBar` is being +/// *constructed*, which is outside any `ScreenUtilInit` — so reading `.r` there +/// throws `LateInitializationError` for every caller that builds a bar without +/// one, which includes several tests and any future widget that composes a bar +/// eagerly. +/// +/// A corner radius is also the one measurement that gains nothing from scaling: +/// it is device-independent already, and 22 logical points is the same shape on +/// every phone. +const double kBarBottomRadius = 22; + +/// The brand surface a bar sits on, with its bottom corners taken off. +/// +/// ── Why it paints [pageColor] itself ── +/// +/// A rounded bar only reads as rounded if something *else* shows through the +/// corners, and the obvious way to arrange that — round the bar and let the +/// Scaffold behind it show — does not work here: every page that carries this +/// bar paints its Scaffold **brand red**, left over from the old arrangement. +/// The corners would open onto more maroon and the curve would be invisible. +/// +/// The alternative was to flip five pages' backgrounds, which is five chances +/// to get a page's own ground wrong for a change that is about the bar. So the +/// bar carries its own backdrop instead: the page's colour behind, the gradient +/// in front with the corners taken off. Self-contained, and correct whatever a +/// Scaffold happens to be painted. +/// +/// [pageColor] must match the surface directly under the bar — that is the +/// whole illusion. +/// +/// It defaults to **transparent**, which lets the Scaffold's own background +/// show through: on the dozen pushed pages whose body sits straight on the +/// Scaffold that is exactly right and costs no wiring at all. Only a page that +/// paints its Scaffold brand-red — a hangover from the old top-rounded-sheet +/// arrangement — has to name its ground, because there the corners would open +/// onto more maroon. +/// +/// Pass as `flexibleSpace` with a **transparent** `backgroundColor`, so the +/// AppBar's own fill does not paint a square maroon rectangle behind all of +/// this — and so the surface covers the status-bar inset, since a bar tinted +/// only below the notch reads as a stripe rather than a surface. +Widget milerBrandSurface({Color pageColor = Colors.transparent}) { + return ColoredBox( + color: pageColor, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: kBrandGradient, + borderRadius: BorderRadius.vertical( + bottom: Radius.circular(kBarBottomRadius), + ), + ), + // ── The `SizedBox.expand` is load-bearing ── + // + // A `DecoratedBox` with no child shrink-wraps to nothing. On its own as a + // `flexibleSpace` that did not matter — the AppBar's stack stretched it — + // but inside the [ColoredBox] the outer box takes the space and the inner + // one collapses to zero, painting no gradient at all. The bar then showed + // the Scaffold's own maroon through a fully transparent AppBar, which + // looks exactly like a flat brand bar and is why this read as "the curve + // did not apply". + child: const SizedBox.expand(), ), ); } -/// ── A bottom sheet as frosted glass ── +/// ── The sheet is a solid surface again ── /// -/// The two sheets a rider spends the most time in — the stop's map sheet and -/// Update Status — were opaque white slabs. On the map screen that is the -/// whole problem: the sheet opens over the one thing the screen exists to -/// show, and takes 45% of it away permanently. +/// It was frosted: an 88%-white fill behind an 18-sigma blur, so a map stayed +/// legible as context under the sheet covering it. The name has been kept +/// because forty call sites use it; the material has not. /// -/// Frosted instead. The map stays visible as context underneath, softened -/// enough that an address printed on top of it is still the sharpest thing in -/// the frame. It is the same material the app bar already uses -/// ([milerGlassSurface]) — the sheets now belong to the same app as the bars -/// above them, which is the thing that made them look borrowed from somewhere -/// else. +/// Two reasons, and the second is the one that decided it. /// -/// ── What it costs ── +/// **It was asked for.** The design brief these screens were rebuilt against +/// carries a glass layer, and the instruction that came with it was to drop +/// the translucency. A sheet a rider reads an address off at a doorstep, in +/// sun, is the wrong surface to spend contrast on: every point of transparency +/// is taken off the words. /// -/// A `BackdropFilter` re-samples everything behind it every frame, and this one -/// is full-width over a map that repaints as it pans. Sigma is held at 18 — -/// the same as the app bar, tuned on a mid-range Android — rather than the 30+ -/// that reads as heavier frost, because on this phone the difference between -/// them is frames, and a sheet that stutters when dragged is worse-looking than -/// one that is merely less blurry. +/// **It cost frames.** A `BackdropFilter` re-samples everything behind it every +/// frame, and this one is full-width over a map that repaints as it pans, on a +/// mid-range Android. The blur was already held down to sigma 18 for exactly +/// that reason — which is the tell that the effect was being rationed rather +/// than enjoyed. +/// +/// What replaces it is the brief's Level 1: white, a hairline along the top +/// edge, and a soft lift. The map underneath is no longer visible *through* the +/// sheet, and it does not need to be — the sheet is 45% of the screen and the +/// other 55% is the map. Widget milerGlassSheet({required Widget child, double radius = 28}) { final shape = BorderRadius.vertical(top: Radius.circular(radius)); @@ -75,28 +139,19 @@ Widget milerGlassSheet({required Widget child, double radius = 28}) { // that has been clipped away. decoration: BoxDecoration( borderRadius: shape, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.13), - blurRadius: 26, - offset: const Offset(0, -6), - ), - ], + boxShadow: Narrative.floatShadow, ), child: ClipRRect( borderRadius: shape, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18), - child: DecoratedBox( - decoration: BoxDecoration( - color: ColorConstants.glassSheet, - borderRadius: shape, - // The rim, not a border: it is one pixel of light on the curve, and - // it is what separates "frosted glass" from "white box at 88%". - border: Border.all(color: ColorConstants.glassRim, width: 1), + child: DecoratedBox( + decoration: BoxDecoration( + color: Narrative.card, + borderRadius: shape, + border: Border( + top: BorderSide(color: Narrative.cardBorder, width: 1), ), - child: child, ), + child: child, ), ), ); @@ -310,6 +365,14 @@ class MilerAvatar extends StatelessWidget { /// icon). class MilerAppBar extends StatelessWidget implements PreferredSizeWidget { final String title; + + /// The colour of the surface directly under the bar, so the curve at its + /// bottom corners opens onto the page rather than onto more brand. + /// + /// Transparent by default, which reveals the Scaffold's own background — the + /// right answer everywhere except a page that paints its Scaffold brand-red. + /// See [milerBrandSurface]. + final Color pageColor; final Widget? trailing; /// Draws a back arrow before the title. @@ -326,6 +389,7 @@ class MilerAppBar extends StatelessWidget implements PreferredSizeWidget { required this.title, this.trailing, this.showBack = false, + this.pageColor = Colors.transparent, }); /// Fixed logical bar height. 64 → 76 to absorb [_titleTopInset] without @@ -350,10 +414,16 @@ class MilerAppBar extends StatelessWidget implements PreferredSizeWidget { // and the colour of the status-bar glyphs with it, which reads as // arriving in a different application rather than a different page. // - // Solid [ColorConstants.primary] everywhere, and everything on it - // inverts — title, back arrow, trailing controls, status-bar glyphs. - // White on #960019 clears AA at every size used here. - backgroundColor: ColorConstants.primary, + // One ground everywhere, and everything on it inverts — title, back + // arrow, trailing controls, status-bar glyphs. White clears AA at every + // size used here against both stops of [kBrandGradient]. + // + // **Transparent**, and the surface does all the painting. The fill used + // to be the brand as a safety net, and it cannot be one now: an opaque + // rectangle behind a rounded surface fills in the two corners the curve + // exists to open. See [milerBrandSurface]. + backgroundColor: Colors.transparent, + flexibleSpace: milerBrandSurface(pageColor: pageColor), // The status bar belongs to the bar under it. Dark glyphs on maroon is // the one contrast failure a rider cannot work around by tilting the // phone. Matches the value Home's own header sets. @@ -482,7 +552,17 @@ class MilerSheet extends StatelessWidget { @override Widget build(BuildContext context) { return ClipRRect( - borderRadius: BorderRadius.vertical(top: Radius.circular(radius.r)), + // ── Flat, because the curve moved onto the bar ── + // + // This clipped its own **top** corners so the brand-red Scaffold behind + // could show through them — the page hanging off the bar. The bar rounds + // its own bottom now and paints the page colour into those corners + // itself (see [milerBrandSurface]), so rounding here as well would draw + // the same curve twice, in opposite directions, one inside the other. + // + // Kept as a zero-radius clip rather than deleted: every page names this + // widget as *the page's working surface*, and that is still what it is. + borderRadius: BorderRadius.zero, child: ColoredBox( // ── Layer 1: this IS the working surface ── // diff --git a/lib/views/helpers/widgets/settings_ui.dart b/lib/views/helpers/widgets/settings_ui.dart index ade5116..6b1957c 100644 --- a/lib/views/helpers/widgets/settings_ui.dart +++ b/lib/views/helpers/widgets/settings_ui.dart @@ -332,20 +332,20 @@ class SettingsLabel extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - // ── Aligned to the titles, not to the icons ── + // ── Aligned to the card, not to the titles inside it ── // - // The label used to start on the page gutter, level with the row icons - // below it. Text-to-glyph alignment is optical, never exact: an `Icon` - // does not paint flush to the left of its box, so an 11sp heading and a - // 21sp glyph starting at the same x read as eight points out of true — - // and a column of three headings all eight points out is what makes a - // settings page look assembled rather than laid out. + // It was inset by the row's icon box plus its gap, so that the heading + // sat exactly over the *words* it heads and the glyphs formed a margin + // rail to its left — text-to-text alignment, which is exact where + // text-to-glyph is only optical. // - // Text-to-text is exact. The heading sits over the words it heads, and - // the icons become a clean margin rail to its left — the arrangement iOS - // Settings has used since it had groups. The inset is the row's own icon - // box plus its gap, read from the same numbers [SettingsRow] uses. - padding: EdgeInsetsDirectional.only(start: 21.sp + 14.w, bottom: 6.h), + // The argument is sound and the result was wrong on the page: the label + // ended up ~65 points in from the screen edge, floating in the middle of + // nothing with the card's own left edge 35 points to its left. A section + // heading names the block under it, and the block's edge is the line the + // eye actually reads down. It sits on that now, and the caller supplies + // the page gutter. + padding: EdgeInsetsDirectional.only(start: 2.w, bottom: 6.h), child: Text(text.toUpperCase(), style: MilerType.eyebrow), ); } diff --git a/lib/views/helpers/widgets/slide_action.dart b/lib/views/helpers/widgets/slide_action.dart new file mode 100644 index 0000000..c46ebb8 --- /dev/null +++ b/lib/views/helpers/widgets/slide_action.dart @@ -0,0 +1,404 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:lucide_icons_flutter/lucide_icons.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'; + +/// ───────────────────────────────────────────────────────────────────────── +/// SLIDE TO COMMIT +/// +/// The delivery leg's one control, at both of its moments: setting off, and +/// handing over. Both are writes the rider cannot take back — one puts the load +/// on the road under his name, the other closes the order — and this screen is +/// held while walking, so a tap target at the foot of it is how either gets +/// recorded by a knuckle. +/// +/// ``` +/// ┌──────────────────────────────────────────────┐ +/// │ (▸) Slide to start ride │ solid accent +/// └──────────────────────────────────────────────┘ +/// ``` +/// +/// ── A drag-only control is a control some riders cannot use ── +/// +/// A bare `GestureDetector` announces nothing to TalkBack, and a glove on a +/// cold morning will not register a drag. So the same act is reachable by +/// double tap through [Semantics] — the deliberateness is what the gesture is +/// for, and a screen reader's double tap is every bit as deliberate as a slide. +/// ───────────────────────────────────────────────────────────────────────── +class MilerSlideAction extends StatefulWidget { + final String label; + final IconData icon; + final Color color; + final Future Function() onCommit; + + const MilerSlideAction({ + super.key, + required this.label, + required this.icon, + required this.color, + required this.onCommit, + }); + + /// The control's height. + /// + /// ── 56 → 72 ── + /// + /// It was [ButtonSizes.primary], the app's ordinary button height, and this + /// is not an ordinary button. It is the only control on a screen held against + /// a handlebar, operated with a thumb that is often gloved and always moving + /// between glances at traffic — and it is a *drag*, which needs a target deep + /// enough that a thumb travelling horizontally does not slide off the top of + /// it. 72 gives a 64pt thumb, which is a third larger than the 48pt + /// accessibility floor for a tap and about right for a drag. + /// + /// It costs 16pt of a sheet whose other content just got shorter — the ETA + /// lost a line and the rail lost its captions — so the sheet is no taller + /// than it was. + static double get height => 72.h; + + @override + State createState() => _MilerSlideActionState(); +} + +class _MilerSlideActionState extends State + with TickerProviderStateMixin { + /// How far along the track the thumb has been dragged, 0..1. + double _progress = 0; + + /// True while the thumb is under a finger. The spring back is animated; the + /// drag itself must not be, or the thumb lags the thumb-print. + bool _dragging = false; + + /// True once [onCommit] has fired, until it returns. The track holds at the + /// far end rather than springing back, so the rider can see that the gesture + /// took before anything else happens. + bool _committed = false; + + /// ── The hint ── + /// + /// A drag-only control has one problem a button does not: nothing about a + /// still image says it slides. This is the answer every app that ships one + /// uses — a shimmer travelling left to right along the track, and three + /// chevrons brightening in turn ahead of the thumb. It runs only while the + /// control is idle: once a finger is on it the rider has worked out what it + /// is, and an animation under his own thumb is noise. + late final AnimationController _hint = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1800), + ); + + /// How many laps the hint runs before it stops. + /// + /// ── Why it is not `repeat()` ── + /// + /// It was, and an endless one is wrong twice. It is a permanent repaint on a + /// sheet the rider is reading — the whole track re-shaded every frame, for as + /// long as it is on screen — and its job is done long before that: three + /// passes is enough for anyone to see that the thing moves. After that it is + /// motion in the corner of the eye of someone about to make a decision. + /// + /// It also makes the control **settle**. An animation that never ends is an + /// animation `pumpAndSettle` waits for forever, so every widget test that + /// opened a sheet carrying one hung — which is the framework saying out loud + /// that nothing on screen is ever at rest. + static const int _hintLaps = 3; + int _lapsRun = 0; + + void _runHint() { + _lapsRun = 0; + _hint + ..reset() + ..forward(); + } + + /// The fraction of the track that counts as slid. Short of the end on + /// purpose: a track that has to be driven into its own wall reads as stuck. + static const double _commitAt = 0.72; + + double get _thumb => MilerSlideAction.height - 8.h; + + @override + void initState() { + super.initState(); + _hint.addStatusListener((status) { + if (status != AnimationStatus.completed) return; + if (++_lapsRun >= _hintLaps || !mounted) return; + _hint + ..reset() + ..forward(); + }); + _runHint(); + } + + Future _commit() async { + HapticFeedback.mediumImpact(); + setState(() { + _progress = 1; + _committed = true; + }); + _hint.stop(); + await widget.onCommit(); + if (mounted) { + setState(() { + _progress = 0; + _committed = false; + }); + // The control is back at the start, so the hint has something to say + // again — a rider who slid once and came back to a second stop meets the + // same affordance he did the first time. + _runHint(); + } + } + + void _settle() { + setState(() => _dragging = false); + if (_progress >= _commitAt) { + _commit(); + } else { + setState(() => _progress = 0); + if (!_hint.isAnimating) _runHint(); + } + } + + @override + void dispose() { + _hint.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: widget.label.replaceFirst('Slide to ', ''), + hint: 'Slide, or double tap, to confirm', + excludeSemantics: true, + onTap: _commit, + child: LayoutBuilder( + builder: (context, box) { + final travel = (box.maxWidth - _thumb - 8.w).clamp( + 1.0, + double.infinity, + ); + return GestureDetector( + onHorizontalDragStart: (_) { + HapticFeedback.selectionClick(); + _hint.stop(); + }, + onHorizontalDragUpdate: (d) => setState(() { + _dragging = true; + _progress = (_progress + d.delta.dx / travel).clamp(0.0, 1.0); + }), + onHorizontalDragEnd: (_) => _settle(), + onHorizontalDragCancel: _settle, + child: AnimatedBuilder( + animation: _hint, + builder: (context, _) => Container( + height: MilerSlideAction.height, + padding: EdgeInsets.all(4.w), + decoration: BoxDecoration( + // ── The track is the action, not a slot the action sits in ── + // + // It was the accent at 14% — a pale wash carrying a bright + // thumb. That reads as a *disabled* button on the one screen + // where the control has to be found without being looked for: + // this sheet is glanced at over a handlebar, and the shape the + // rider is scanning for is the same solid slab of colour that + // every other primary action in the app is. + // + // So the track carries the accent solid and the thumb inverts + // to white — the shape and the weight of a primary button, + // with a disc on it that says the button travels. + color: widget.color, + // Fully round, and the thumb with it: a rounded-rectangle + // thumb inside a rounded-rectangle track reads as a button + // that has been pushed to one side. A disc in a capsule reads + // as something that travels, which is the one thing this + // control has to say before it is touched. + borderRadius: BorderRadius.circular( + DesignConstants.radiusFull, + ), + // A hairline of the accent's own light along the rim. It is + // what makes the track read as a *groove* the thumb sits in + // rather than as a flat slab with a disc drawn on it — the + // one cue that says the two parts are different objects. + border: Border.all( + color: Colors.white.withValues(alpha: 0.16), + ), + boxShadow: [ + BoxShadow( + color: widget.color.withValues(alpha: 0.30), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular( + DesignConstants.radiusFull, + ), + child: Stack( + alignment: Alignment.center, + children: [ + // ── The shimmer ── + // + // A band of lighter colour sweeping the length of the + // track. It is the only thing on the sheet that moves + // horizontally, which is precisely the gesture being + // asked for. + if (!_dragging && !_committed) + Positioned.fill(child: _Shimmer(t: _hint.value)), + // ── The chevrons and the label are one object ── + // + // They were laid out independently — chevrons pinned + // after the thumb, label centred on what was left — and + // on a long label the two overlapped: `› Slide to confirm + // pickup` with two of the three arrows underneath the + // word. + // + // One row, sized to itself and centred in the room to the + // right of the thumb. The arrows lead into the word + // instead of colliding with it, the pair stays balanced + // whatever the label says, and both fade together as the + // thumb arrives — rather than one of them hanging on + // while the other has gone. + Padding( + padding: EdgeInsets.only(left: _thumb, right: 12.w), + child: Center( + child: Opacity( + opacity: (1 - _progress * 2.0).clamp(0.0, 1.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Nobody has to be told what an arrow pointing + // right means, and three of them brightening in + // turn is one arrow travelling. + for (var i = 0; i < 3; i++) + Icon( + LucideIcons.chevronRight, + size: 19.sp, + color: ColorConstants.onAccent.withValues( + alpha: _dragging || _committed + ? 0.30 + : _chevron(i), + ), + ), + SizedBox(width: 8.w), + Flexible( + child: Text( + widget.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.3, + color: ColorConstants.onAccent, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ), + ), + ), + Align( + alignment: Alignment.centerLeft, + child: AnimatedContainer( + // Instant under the finger, eased on the way back — + // and a spring on the way home, which is what makes a + // committed slide feel like it landed rather than + // like it was reset. + duration: _dragging + ? Duration.zero + : DesignConstants.motionState, + curve: Curves.easeOutCubic, + margin: EdgeInsets.only(left: travel * _progress), + width: _thumb, + height: _thumb, + decoration: BoxDecoration( + color: ColorConstants.onAccent, + shape: BoxShape.circle, + boxShadow: const [ + BoxShadow( + color: Color(0x2E000000), + blurRadius: 10, + offset: Offset(0, 3), + ), + ], + ), + child: AnimatedSwitcher( + duration: DesignConstants.motionPress, + child: Icon( + // The tick is the receipt. A thumb that arrives + // still wearing the "go" glyph looks like it has + // not finished. + _committed ? LucideIcons.check : widget.icon, + key: ValueKey(_committed), + color: widget.color, + size: 26.sp, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + }, + ), + ); + } + + /// How lit chevron [i] is on this frame — a wave running left to right, so + /// the three read as one arrow travelling rather than three blinking. + double _chevron(int i) { + final phase = (_hint.value * 3 - i) % 3; + final lit = phase < 1 ? 1 - phase : 0.0; + return 0.28 + 0.62 * lit; + } +} + +/// A band of light sweeping the length of the track. +/// +/// Painted rather than composed from widgets: it is a gradient whose stops +/// move, and a `LinearGradient` rebuilt per frame is one object where a stack +/// of animated boxes would be several. +class _Shimmer extends StatelessWidget { + /// 0 → 1, the sweep's position over one cycle. + final double t; + + const _Shimmer({required this.t}); + + @override + Widget build(BuildContext context) { + // Runs from off the left edge to off the right, so the band is never + // parked at either end waiting for the cycle to restart. + final centre = -0.4 + t * 1.8; + return DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + stops: [ + (centre - 0.22).clamp(0.0, 1.0), + centre.clamp(0.0, 1.0), + (centre + 0.22).clamp(0.0, 1.0), + ], + colors: [ + Colors.white.withValues(alpha: 0), + Colors.white.withValues(alpha: 0.16), + Colors.white.withValues(alpha: 0), + ], + ), + ), + ); + } +} diff --git a/lib/views/introscreens/introscreen.dart b/lib/views/introscreens/introscreen.dart index 8ba0c21..fbb63a0 100644 --- a/lib/views/introscreens/introscreen.dart +++ b/lib/views/introscreens/introscreen.dart @@ -1,55 +1,36 @@ import 'package:flutter/material.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/views/onboardscreens/Sign_in.dart'; -import 'package:miler/views/onboardscreens/auth_scaffold.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; -import 'package:miler/views/helpers/widgets/miler_app_bar.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; /// ═════════════════════════════════════════════════════════════════════════ -/// FIRST RUN — three slides, then sign in. +/// FIRST RUN — three full-bleed brand slides, then sign in. /// -/// ── The copy described a different app ── +/// ── The artwork is the slide ── /// -/// This is the most important change here, and it is not a visual one. The old -/// slides said "Find pickups near you — see nearby pickup requests the moment -/// they arrive, accept in a single tap" and "Navigate the smart way". That is a -/// gig-marketplace pitch, and Miler is not a marketplace: the **hub assigns a -/// slot**, the route comes with its stops and its order already set, it is a -/// **round trip** that starts and ends at the hub, and a stop that is not ready -/// gets **skipped, never reordered**. A rider onboarded on the old copy would -/// open the app looking for a feed of nearby jobs to claim and not find one. +/// Each slide is a finished poster: headline, sub-copy, product shot and the +/// MILER by DOORMILE lockup are all baked into the image. So this screen draws +/// no title or body text of its own — a Flutter caption under the picture would +/// simply say the same thing twice, in a second typeface, at a second size. +/// The screen's whole job is to present the image edge to edge and put the +/// controls somewhere they stay readable. /// -/// The third slide also carried a "Powered by AI" chip next to a claim about -/// real-time bonuses. Neither was backed by anything in the app, and a promise -/// made during onboarding is the one a rider remembers when it does not hold. +/// ── Controls that survive three different backdrops ── /// -/// ── The illustrations show the actual product ── -/// -/// Each slide used to draw a 320×320 rounded panel, containing a 156px tinted -/// circle, containing an 84px Material icon — three nested shapes to present one -/// glyph, on a `repeat(reverse: true)` pulse that never stopped. A location pin -/// does not teach anyone what a slot route is. -/// -/// So the panels now hold small, honest mock-ups built from the app's own -/// vocabulary — the same slot chip, stop rows and hub markers the rider will see -/// on Home an hour later. Onboarding that previews the real interface is doing -/// two jobs at once: it explains the model *and* it makes the real screen -/// familiar on arrival. +/// Slides 1 and 3 end in the brand's red wave; slide 2 ends on dark tarmac. +/// [ColorConstants.primary] would vanish into the first and get lost in the +/// second, so the CTA inverts: a white pill carrying the red label, which holds +/// its contrast on red, on grey and on black. A short scrim under the controls +/// does the rest without dulling the wave. `Skip` sits top-right, where all +/// three images are near-white, so it stays dark type on a light plate. /// ═════════════════════════════════════════════════════════════════════════ -class _Slide { - final String title; - final String body; - final Widget art; - const _Slide({required this.title, required this.body, required this.art}); -} - class Introscreen extends StatefulWidget { const Introscreen({super.key}); @@ -60,32 +41,27 @@ class Introscreen extends StatefulWidget { class _IntroscreenState extends State { final PageController _controller = PageController(); int _index = 0; + bool _precached = false; - static final _slides = <_Slide>[ - _Slide( - title: 'Your route arrives ready', - body: - 'Your hub assigns each shift as a time slot, with the stops and ' - 'their order already set. Take the whole trip, or accept it stop by ' - 'stop.', - art: _SlotArt(), - ), - _Slide( - title: 'Work the stops in order', - body: - 'Follow the list as it stands. If a stop is not ready, skip it and ' - 'keep moving — the order is never rearranged underneath you.', - art: _StopsArt(), - ), - _Slide( - title: 'Collect, then close the loop', - body: - 'Take the parcels and any cash due, capture the proof, and finish ' - 'back at the hub. Every stop is logged as you go.', - art: _HubArt(), - ), + /// Order is the order the rider sees: trust → the work → the pay. + static const _slides = [ + 'assets/images/onboard_1.png', + 'assets/images/onboard_2.png', + 'assets/images/onboard_3.png', ]; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Decode all three up front. Paging onto an undecoded 940×1672 PNG shows a + // blank frame first, which on the very first run reads as a broken app. + if (_precached) return; + _precached = true; + for (final path in _slides) { + precacheImage(AssetImage(path), context); + } + } + Future _finish() async { try { final prefs = await SharedPreferences.getInstance(); @@ -120,520 +96,194 @@ class _IntroscreenState extends State { value: SystemUiOverlayStyle.dark.copyWith( statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.dark, + systemNavigationBarColor: Colors.black, ), child: Scaffold( - backgroundColor: ColorConstants.surface, - body: SafeArea( - child: Column( - children: [ - // ── Top bar: mark, and a way out ── - Padding( - padding: EdgeInsets.fromLTRB(20.w, 8.h, 12.w, 0), - child: Row( - children: [ - const MilerBrandMark(), - const Spacer(), - // Skip stays mounted on the last slide instead of fading to - // `opacity: 0` — an invisible-but-present control is a trap - // for screen readers, and on the last slide the primary - // button already does what Skip would. - if (!isLast) - AuthTextAction(label: 'Skip', onPressed: _finish), - ], - ), + backgroundColor: ColorConstants.pureSurface, + // The image runs under the status bar; only the controls are inset. + extendBodyBehindAppBar: true, + body: Stack( + fit: StackFit.expand, + children: [ + // ── The slide itself ── + PageView.builder( + controller: _controller, + onPageChanged: (i) => setState(() => _index = i), + itemCount: _slides.length, + itemBuilder: (_, i) => Image.asset( + _slides[i], + fit: BoxFit.cover, + alignment: Alignment.topCenter, + filterQuality: FilterQuality.medium, ), + ), - Expanded( - child: PageView.builder( - controller: _controller, - onPageChanged: (i) => setState(() => _index = i), - itemCount: _slides.length, - itemBuilder: (_, i) => _SlideView( - slide: _slides[i], - controller: _controller, - index: i, + // ── Scrim: just enough to seat the dots and the CTA ── + Positioned( + left: 0, + right: 0, + bottom: 0, + child: IgnorePointer( + child: Container( + height: 210.h, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x00000000), Color(0x73000000)], + stops: [0.0, 0.85], + ), ), ), ), + ), - // ── Position ── - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate(_slides.length, (i) { - final active = i == _index; - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - margin: EdgeInsets.symmetric(horizontal: 3.w), - height: 6.h, - width: active ? 26.w : 6.h, - decoration: BoxDecoration( - color: active - ? ColorConstants.primary - : ColorConstants.borderStrong, - borderRadius: BorderRadius.circular( - DesignConstants.radiusFull, - ), - ), - ); - }), - ), - - Padding( - padding: EdgeInsets.fromLTRB(24.w, 22.h, 24.w, 14.h), - child: AuthPrimaryButton( - label: isLast ? 'Get started' : 'Next', - onPressed: _next, - ), - ), - ], - ), - ), - ), - ); - } -} - -/// One slide. Parallax is driven by an [AnimatedBuilder] on the page controller -/// rather than by a listener calling `setState` on the whole screen, which is -/// what the old version did on every pixel of every drag. -class _SlideView extends StatelessWidget { - final _Slide slide; - final PageController controller; - final int index; - - const _SlideView({ - required this.slide, - required this.controller, - required this.index, - }); - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: controller, - builder: (context, _) { - // `page` is null until the viewport has been laid out once. - final page = controller.hasClients && controller.position.haveDimensions - ? (controller.page ?? controller.initialPage.toDouble()) - : controller.initialPage.toDouble(); - final delta = (page - index).abs().clamp(0.0, 1.0); - - return Opacity( - opacity: 1 - (delta * 0.55), - child: Transform.scale( - scale: 1 - (delta * 0.08), - child: SingleChildScrollView( - physics: const ClampingScrollPhysics(), - padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 8.h), + SafeArea( child: Column( - mainAxisAlignment: MainAxisAlignment.center, children: [ - slide.art, - SizedBox(height: 34.h), - Text( - slide.title, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 25.sp, - fontWeight: FontWeight.w700, - letterSpacing: -0.6, - height: 1.2, - color: ColorConstants.slateText, - fontFamily: FontConstants.fontFamily, + // ── A way out ── + Padding( + padding: EdgeInsets.fromLTRB(20.w, 8.h, 16.w, 0), + child: Row( + children: [ + const Spacer(), + // Skip stays unmounted on the last slide rather than + // fading to `opacity: 0` — an invisible-but-present + // control is a trap for screen readers, and by then the + // CTA already does what Skip would. + if (!isLast) _SkipAction(onPressed: _finish), + ], ), ), - SizedBox(height: 10.h), - Text( - slide.body, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14.5.sp, - fontWeight: FontWeight.w500, - height: 1.5, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, + + const Spacer(), + + // ── Position ── + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(_slides.length, (i) { + final active = i == _index; + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + margin: EdgeInsets.symmetric(horizontal: 3.w), + height: 6.h, + width: active ? 26.w : 6.h, + decoration: BoxDecoration( + color: active + ? Colors.white + : Colors.white.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular( + DesignConstants.radiusFull, + ), + ), + ); + }), + ), + + Padding( + padding: EdgeInsets.fromLTRB(24.w, 20.h, 24.w, 16.h), + child: _OnboardButton( + label: isLast ? 'Get started' : 'Next', + onPressed: _next, ), ), ], ), ), - ), - ); - }, - ); - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// ILLUSTRATIONS -// -// Built from the app's real vocabulary — white surfaces, 1.5px `borderStrong` -// edges, slate type, the hub teal — so the first thing the rider sees is the -// thing he will be using. Static: no controllers, nothing to dispose, nothing -// left repainting behind the keyboard. -// ═══════════════════════════════════════════════════════════════════════════ - -/// Shared frame: the "screen" each mock sits on. -class _ArtFrame extends StatelessWidget { - final Widget child; - const _ArtFrame({required this.child}); - - @override - Widget build(BuildContext context) { - return Center( - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: 300.w), - child: Container( - padding: EdgeInsets.all(16.w), - decoration: BoxDecoration( - color: ColorConstants.pureSurface, - borderRadius: BorderRadius.circular(DesignConstants.radiusXl), - border: Border.all(color: ColorConstants.borderStrong, width: 1.5), - boxShadow: const [ - BoxShadow( - color: Color(0x0F000000), - blurRadius: 20, - offset: Offset(0, 8), - ), - ], - ), - child: child, + ], ), ), ); } } -const Color _hubTeal = Color(0xFF0E7C7B); +/// The CTA, inverted. White plate, red label — the one pairing that holds on +/// the red wave of slides 1 and 3 *and* on the tarmac of slide 2. +class _OnboardButton extends StatelessWidget { + final String label; + final VoidCallback onPressed; -TextStyle _artTitle(double size) => TextStyle( - fontSize: size.sp, - fontWeight: FontWeight.w700, - letterSpacing: -0.3, - color: ColorConstants.slateText, - fontFamily: FontConstants.fontFamily, -); + const _OnboardButton({required this.label, required this.onPressed}); -TextStyle _artMeta(double size) => TextStyle( - fontSize: size.sp, - fontWeight: FontWeight.w600, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, -); - -/// Slide 1 — a slot, its load, and the shape of the trip. -class _SlotArt extends StatelessWidget { @override Widget build(BuildContext context) { - return _ArtFrame( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('12:00 PM – 3:00 PM', style: _artTitle(16)), - SizedBox(height: 3.h), - Text('6 stops', style: _artMeta(11.5)), - ], - ), - ), - Container( - padding: EdgeInsets.symmetric(horizontal: 9.w, vertical: 5.h), - decoration: BoxDecoration( - color: ColorConstants.neutralLight, - borderRadius: BorderRadius.circular( - DesignConstants.radiusFull, - ), - border: Border.all(color: ColorConstants.borderStrong), - ), - child: Row( - children: [ - Icon( - LucideIcons.package, - size: 11.sp, - color: ColorConstants.slateText, - ), - SizedBox(width: 4.w), - Text('15', style: _artTitle(12)), - ], - ), - ), - ], + return SizedBox( + width: double.infinity, + height: ButtonSizes.primary, + child: ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: ColorConstants.pureSurface, + foregroundColor: ColorConstants.primary, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(ButtonSizes.radius.r), ), - SizedBox(height: 14.h), - // The metric strip, in miniature. - Container( - padding: EdgeInsets.symmetric(vertical: 8.h), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - border: Border.all( - color: ColorConstants.borderStrong, - width: 1.5, - ), - ), - child: Row( - children: [ - _cell('≈3h', 'Duration', ColorConstants.primary), - _divider(), - _cell('50 km', 'Round trip', ColorConstants.slateText), - _divider(), - _cell('₹1,030', 'To collect', ColorConstants.acceptGreen), - ], - ), - ), - SizedBox(height: 14.h), - Row( - children: [ - Icon(LucideIcons.warehouse, size: 13.sp, color: _hubTeal), - SizedBox(width: 6.w), - Text( - 'HUB', + padding: EdgeInsets.symmetric(horizontal: 16.w), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: TextStyle( - fontSize: 10.sp, + color: ColorConstants.primary, + fontSize: 16.sp, fontWeight: FontWeight.w700, - letterSpacing: 0.6, - color: _hubTeal, + letterSpacing: -0.2, fontFamily: FontConstants.fontFamily, ), ), - SizedBox(width: 7.w), - for (var i = 0; i < 4; i++) ...[ - Container( - width: 14.w, - height: 2, - color: ColorConstants.borderStrong, - ), - Container( - width: 7.w, - height: 7.w, - decoration: BoxDecoration( - color: ColorConstants.primary, - shape: BoxShape.circle, - ), - ), - ], - Container( - width: 14.w, - height: 2, - color: ColorConstants.borderStrong, - ), - Icon(LucideIcons.warehouse, size: 13.sp, color: _hubTeal), - ], - ), - ], - ), - ); - } - - Widget _cell(String value, String label, Color accent) => Expanded( - child: Column( - children: [ - Text( - value, - style: TextStyle( - fontSize: 12.5.sp, - fontWeight: FontWeight.w700, - letterSpacing: -0.3, - color: accent, - fontFamily: FontConstants.fontFamily, - ), - ), - SizedBox(height: 1.h), - Text(label, style: _artMeta(9)), - ], - ), - ); - - Widget _divider() => - Container(width: 1, height: 24.h, color: ColorConstants.borderSubtle); -} - -/// Slide 2 — the list, in order, with one done and one skipped. -class _StopsArt extends StatelessWidget { - @override - Widget build(BuildContext context) { - return _ArtFrame( - child: Column( - children: [ - _row( - 1, - 'Sri Balaji Stores', - 'Done', - ColorConstants.acceptGreen, - LucideIcons.check, - ), - SizedBox(height: 9.h), - _row( - 2, - 'Anand Traders', - 'Skipped', - ColorConstants.warning, - LucideIcons.redo2, - ), - SizedBox(height: 9.h), - _row(3, 'Kumar Textiles', 'Next', ColorConstants.primary, null), - SizedBox(height: 9.h), - _row(4, 'MS Enterprises', null, ColorConstants.borderStrong, null), - ], - ), - ); - } - - Widget _row( - int n, - String name, - String? status, - Color accent, - IconData? badge, - ) { - final pending = status == null; - return Row( - children: [ - Container( - width: 20.w, - height: 20.w, - alignment: Alignment.center, - decoration: BoxDecoration( - color: pending ? ColorConstants.pureSurface : accent, - shape: BoxShape.circle, - border: Border.all(color: accent, width: 1.8), - ), - child: badge != null - ? Icon(badge, size: 11.sp, color: Colors.white) - : Text( - '$n', - style: TextStyle( - fontSize: 10.sp, - fontWeight: FontWeight.w700, - color: pending - ? ColorConstants.secondaryText - : Colors.white, - fontFamily: FontConstants.fontFamily, - ), - ), - ), - SizedBox(width: 10.w), - Expanded( - child: Text( - name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13.sp, - fontWeight: FontWeight.w700, - letterSpacing: -0.2, - color: pending - ? ColorConstants.secondaryText - : ColorConstants.slateText, - fontFamily: FontConstants.fontFamily, ), - ), - ), - if (status != null) - Text( - status, - style: TextStyle( - fontSize: 10.5.sp, - fontWeight: FontWeight.w700, - color: accent, - fontFamily: FontConstants.fontFamily, + SizedBox(width: 8.w), + Icon( + LucideIcons.arrowRight, + size: 18.sp, + color: ColorConstants.primary, ), - ), - ], + ], + ), + ), ); } } -/// Slide 3 — what leaves the stop with you, and where it ends up. -class _HubArt extends StatelessWidget { +/// Skip, on a translucent plate. The top of every slide is near-white, so the +/// label is dark type; the plate keeps it legible if artwork is ever swapped +/// for something busier. +class _SkipAction extends StatelessWidget { + final VoidCallback onPressed; + + const _SkipAction({required this.onPressed}); + @override Widget build(BuildContext context) { - return _ArtFrame( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'COLLECT AT THIS STOP', - style: TextStyle( - fontSize: 9.5.sp, - fontWeight: FontWeight.w700, - letterSpacing: 0.9, - color: ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, - ), - ), - SizedBox(height: 11.h), - _line(LucideIcons.package, '3 parcels', ColorConstants.pickupAccent), - SizedBox(height: 9.h), - _line(LucideIcons.banknote, '₹340 cash', ColorConstants.acceptGreen), - SizedBox(height: 9.h), - _line(LucideIcons.camera, 'Photo proof', ColorConstants.slateText), - SizedBox(height: 14.h), - Container(height: 1, color: ColorConstants.borderSubtle), - SizedBox(height: 13.h), - Row( - children: [ - Container( - width: 22.w, - height: 22.w, - alignment: Alignment.center, - decoration: BoxDecoration( - color: _hubTeal.withValues(alpha: 0.14), - shape: BoxShape.circle, - border: Border.all(color: _hubTeal, width: 1.5), - ), - child: Icon( - LucideIcons.warehouse, - size: 12.sp, - color: _hubTeal, - ), - ), - SizedBox(width: 9.w), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'RETURN · HUB', - style: TextStyle( - fontSize: 10.sp, - fontWeight: FontWeight.w700, - letterSpacing: 0.7, - color: _hubTeal, - fontFamily: FontConstants.fontFamily, - ), - ), - SizedBox(height: 2.h), - Text('Hand everything over', style: _artMeta(10.5)), - ], - ), - ), - ], - ), - ], + return TextButton( + onPressed: onPressed, + style: TextButton.styleFrom( + minimumSize: Size(0, ButtonSizes.minTapTarget), + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + backgroundColor: Colors.white.withValues(alpha: 0.72), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + ), ), - ); - } - - Widget _line(IconData icon, String text, Color accent) => Row( - children: [ - Icon(icon, size: 15.sp, color: accent), - SizedBox(width: 9.w), - Text( - text, + child: Text( + 'Skip', style: TextStyle( - fontSize: 13.sp, + fontSize: 13.5.sp, fontWeight: FontWeight.w700, - letterSpacing: -0.2, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), - ], - ); + ); + } } diff --git a/lib/views/onboardscreens/Sign_in.dart b/lib/views/onboardscreens/Sign_in.dart index 0945623..3f3b031 100644 --- a/lib/views/onboardscreens/Sign_in.dart +++ b/lib/views/onboardscreens/Sign_in.dart @@ -151,7 +151,7 @@ class _SignInState extends State { _showError = true; _errorText = 'We could not sign you in with that number. Check it and try ' - 'again, or contact your hub manager.'; + 'again, or contact your manager.'; }); } } diff --git a/lib/widget/Bottom_page.dart b/lib/widget/Bottom_page.dart index ff89b95..503a8b9 100644 --- a/lib/widget/Bottom_page.dart +++ b/lib/widget/Bottom_page.dart @@ -10,6 +10,7 @@ import 'package:miler/views/Dashboard/activity/activity_page.dart'; import 'package:miler/views/Dashboard/profile/Profilepage.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/narrative.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; import 'package:miler/data/service_profile.dart'; @@ -39,9 +40,14 @@ class BottomPage extends StatefulWidget { /// scroll position and state, which is why the IndexedStack is there. static final ValueNotifier currentTab = ValueNotifier(0); - /// Height of the floating bar at rest, and the gap to the screen edge. + /// Height of the bar at rest. static const double navBarHeight = 66; - static const double navBarGap = 10; + + /// The gap the bar used to float above the screen's edge. Zero now: it is + /// docked, so there is nothing between it and the bottom of the phone. Kept + /// as a name because [bottomInset] and several pages read it, and a bar that + /// changes where it sits should not also change how many numbers describe it. + static const double navBarGap = 0; /// Height of the bar once the rider has scrolled into a list. /// @@ -304,49 +310,67 @@ class _BottomPageState extends State with TickerProviderStateMixin { ), ), - // ── Floating frosted bar, compacting rather than leaving ── + // ── Docked, opaque, and full width ── // - // Every dimension is lerped off one animation value, so the - // height, the radius, the inset and the contents shrink together - // instead of each running its own clock. + // It was a frosted pill floating 10 points off the bottom with a + // 16-point margin down each side. Two costs, and the second is + // the one that decided it. + // + // **It was not where a nav bar is.** Every other app on the + // rider's phone puts the tabs on the bottom edge; a pill hovering + // above it reads as a widget over the page rather than as the + // frame of the app, and the strip of page visible under it is a + // gap nothing lives in. + // + // **It was glass.** A 20-sigma backdrop blur, permanently on + // screen, over a scrolling list — the most expensive thing this + // app drew, on the one surface that is never not drawn. + // + // It still compacts rather than leaving: labels go, the icons + // draw in, the bar shortens. A control that vanishes is not a + // smaller control, it is a missing one. AnimatedBuilder( animation: Listenable.merge([_compact, _pillAnim]), builder: (context, _) { final k = _compact.value; - final side = lerpDouble(16.w, 30.w, k)!; + final side = 6.w; final height = lerpDouble( BottomPage.navBarHeight.h, BottomPage.navBarHeightCompact.h, k, )!; - final radius = lerpDouble(32.r, 23.r, k)!; + final radius = lerpDouble(26.r, 20.r, k)!; final pos = _pillPos; return Positioned( - left: side, - right: side, - bottom: - MediaQuery.of(context).padding.bottom + - BottomPage.navBarGap.h, - child: ClipRRect( - borderRadius: BorderRadius.circular(radius), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20.0, sigmaY: 20.0), + left: 0, + right: 0, + bottom: 0, + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom, + ), + child: ClipRRect( + borderRadius: BorderRadius.vertical( + top: Radius.circular(radius), + ), child: DecoratedBox( decoration: BoxDecoration( - color: ColorConstants.pureSurface.withValues( - alpha: 0.82, + color: Narrative.card, + borderRadius: BorderRadius.vertical( + top: Radius.circular(radius), ), - borderRadius: BorderRadius.circular(radius), - border: Border.all( - color: Colors.black.withValues(alpha: 0.06), - width: 1.0, + border: Border( + top: BorderSide( + color: Narrative.cardBorder, + width: 1.0, + ), ), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.07), - blurRadius: 28, - offset: const Offset(0, 8), + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 20, + offset: const Offset(0, -4), ), ], ), diff --git a/lib/xpress/background/foreground_service.dart b/lib/xpress/background/foreground_service.dart index fa0ae9a..b64c82c 100644 --- a/lib/xpress/background/foreground_service.dart +++ b/lib/xpress/background/foreground_service.dart @@ -1,500 +1,500 @@ -import 'dart:async'; -import 'dart:isolate'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_foreground_task/flutter_foreground_task.dart'; -import 'dart:math' as math; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:miler/xpress/views/helpers/constants/apiconstants.dart'; -import 'package:miler/xpress/providers/Riderlog/riderlog_provider.dart'; -import 'package:miler/xpress/background/backgroundservice.dart'; -import 'package:geolocator/geolocator.dart'; -import 'package:miler/xpress/utils/kalman_filter.dart'; -import 'package:miler/xpress/utils/mqtt_service.dart'; -import 'package:miler/xpress/views/helpers/constants/mqtt_constants.dart'; -import 'package:battery_plus/battery_plus.dart'; -import 'package:connectivity_plus/connectivity_plus.dart'; -import 'dart:io'; -import 'package:miler/helpers/http_overrides.dart'; - -class _BackgroundRiderLog { - static DoormileKalmanFilter? _kf; - static DateTime? _lastUpdateTime; - - static Future> _ensureLatLng() async { - Map result = { - 'lat': '0', - 'lng': '0', - 'raw_lat': '0', - 'raw_lng': '0', - 'speed': '0', - 'heading': '0', - 'velocity_lat': '0', - 'velocity_lng': '0', - 'status': 'unknown', - 'accuracy': '0', - }; - try { - // 1. Check if location services are enabled - final serviceEnabled = await Geolocator.isLocationServiceEnabled(); - if (!serviceEnabled) { - debugPrint('[BG_RIDER_LOG] Location services are disabled.'); - result['status'] = 'disabled'; - return result; - } - - // 2. Check permissions - LocationPermission permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.denied) { - debugPrint('[BG_RIDER_LOG] Location permission denied.'); - result['status'] = 'denied'; - return result; - } - if (permission == LocationPermission.deniedForever) { - debugPrint('[BG_RIDER_LOG] Location permission denied forever.'); - result['status'] = 'denied_forever'; - return result; - } - - result['status'] = 'enabled'; - - // 3. Get position (using non-deprecated LocationSettings + explicit timeout) - final pos = await Geolocator.getCurrentPosition( - locationSettings: const LocationSettings( - accuracy: LocationAccuracy.high, - ), - ); - - // Reject mocked positions (anti-cheat) - if (pos.isMocked) { - debugPrint('[BG_RIDER_LOG] Mocked position detected — using cached'); - return result; - } - - final now = DateTime.now(); - double outLat = pos.latitude; - double outLng = pos.longitude; - double speed = pos.speed; - double heading = pos.heading; - - // Decompose velocity for Kalman - final double headingRadians = heading * (math.pi / 180.0); - final double velocityLng = speed * math.sin(headingRadians); - final double velocityLat = speed * math.cos(headingRadians); - - if (_kf == null) { - _kf = DoormileKalmanFilter(lat: outLat, lng: outLng); - } else { - final double dt = _lastUpdateTime != null - ? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0 - : 30.0; // Default background interval - _kf!.predict(dt); - _kf!.update(outLat, outLng); - outLat = _kf!.x[0]; - outLng = _kf!.x[1]; - } - _lastUpdateTime = now; - - return { - 'lat': outLat.toStringAsFixed(6), - 'lng': outLng.toStringAsFixed(6), - 'raw_lat': pos.latitude.toStringAsFixed(6), - 'raw_lng': pos.longitude.toStringAsFixed(6), - 'speed': speed.toStringAsFixed(2), - 'heading': heading.toStringAsFixed(2), - 'velocity_lat': velocityLat.toStringAsFixed(4), - 'velocity_lng': velocityLng.toStringAsFixed(4), - 'status': 'enabled', - 'accuracy': pos.accuracy.toStringAsFixed(1), - }; - } catch (e) { - debugPrint('[BG_RIDER_LOG] Error getting location: $e'); - return result; - } - } - - static String _two(int n) => n.toString().padLeft(2, '0'); - static String _formatDateTimeFull(DateTime dt) { - final y = dt.year.toString(); - final m = _two(dt.month); - final d = _two(dt.day); - final hh = _two(dt.hour); - final mm = _two(dt.minute); - final ss = _two(dt.second); - return "$y-$m-$d $hh:$mm:$ss"; - } - - static String _formatTime(DateTime dt) { - final hh = _two(dt.hour); - final mm = _two(dt.minute); - final ss = _two(dt.second); - return "$hh:$mm:$ss"; - } - - /// Accumulates cumulative KMs for active deliveries using the foreground service GPS position. - /// Only runs when LiveTrackingService (main isolate) hasn't updated in the last 10 seconds, - /// which means the app is backgrounded/screen-off/power-saver and the main isolate is dormant. - static Future _accumulateBackgroundKms( - SharedPreferences prefs, - Map loc, - ) async { - try { - // Check if the main isolate's LiveTrackingService is still actively updating - final lastLiveUpdateMs = - prefs.getInt('live_tracking_last_update_ms') ?? 0; - final nowMs = DateTime.now().millisecondsSinceEpoch; - final secondsSinceLiveUpdate = (nowMs - lastLiveUpdateMs) / 1000.0; - - if (secondsSinceLiveUpdate < 10.0) { - // Main isolate is active — let it handle KMs to avoid race conditions - debugPrint( - '[BG_KM] LiveTrackingService active (${secondsSinceLiveUpdate.toStringAsFixed(1)}s ago) — skipping background accumulation', - ); - return; - } - - // Skip if GPS accuracy is too poor for reliable KM tracking - final double accuracy = - double.tryParse(loc['accuracy'] ?? '9999') ?? 9999.0; - if (accuracy > 50.0) { - debugPrint( - '[BG_KM] Low-accuracy position (${accuracy.toStringAsFixed(0)}m) — skipping KM accumulation', - ); - return; - } - - final double currentLat = double.tryParse(loc['lat'] ?? '0') ?? 0.0; - final double currentLng = double.tryParse(loc['lng'] ?? '0') ?? 0.0; - if (currentLat == 0.0 || currentLng == 0.0) return; - - final activeDeliveryIds = - prefs.getStringList('active_tracking_delivery_ids') ?? []; - - for (final dId in activeDeliveryIds) { - try { - final lastLatStr = - prefs.getString('delivery_tracking_${dId}_lastLat') ?? ''; - final lastLngStr = - prefs.getString('delivery_tracking_${dId}_lastLng') ?? ''; - final currentCumKm = - double.tryParse( - prefs.getString('delivery_tracking_${dId}_cumulativeKm') ?? '0', - ) ?? - 0.0; - - if (lastLatStr.isNotEmpty && lastLngStr.isNotEmpty) { - final lastLat = double.tryParse(lastLatStr) ?? 0.0; - final lastLng = double.tryParse(lastLngStr) ?? 0.0; - - if (lastLat != 0.0 && lastLng != 0.0) { - final distanceMeters = Geolocator.distanceBetween( - lastLat, - lastLng, - currentLat, - currentLng, - ); - - // Speed-based jump guard: reject if implied speed > 120 km/h (33.3 m/s). - // Uses elapsed time since last recorded position so the threshold scales - // correctly whether the background interval is 30s, 60s, or longer. - final lastUpdateMs = - prefs.getInt('delivery_tracking_${dId}_lastUpdateMs') ?? 0; - final nowMs = DateTime.now().millisecondsSinceEpoch; - final elapsedSeconds = lastUpdateMs > 0 - ? (nowMs - lastUpdateMs) / 1000.0 - : 60.0; // conservative default - final maxRealisticMeters = - elapsedSeconds * 33.3; // 120 km/h ceiling - - if (distanceMeters > maxRealisticMeters && - distanceMeters > 50.0) { - // GPS jumped — update anchor without counting phantom distance - debugPrint( - '[BG_KM] GPS jump for $dId: ${distanceMeters.toStringAsFixed(0)}m ' - 'in ${elapsedSeconds.toStringAsFixed(1)}s (max: ${maxRealisticMeters.toStringAsFixed(0)}m) — resetting anchor', - ); - await prefs.setString( - 'delivery_tracking_${dId}_lastLat', - currentLat.toString(), - ); - await prefs.setString( - 'delivery_tracking_${dId}_lastLng', - currentLng.toString(), - ); - await prefs.setInt( - 'delivery_tracking_${dId}_lastUpdateMs', - nowMs, - ); - } else if (distanceMeters >= 5.0) { - final newCumKm = currentCumKm + (distanceMeters / 1000.0); - await prefs.setString( - 'delivery_tracking_${dId}_cumulativeKm', - newCumKm.toStringAsFixed(4), - ); - await prefs.setString( - 'delivery_tracking_${dId}_lastLat', - currentLat.toString(), - ); - await prefs.setString( - 'delivery_tracking_${dId}_lastLng', - currentLng.toString(), - ); - await prefs.setInt( - 'delivery_tracking_${dId}_lastUpdateMs', - nowMs, - ); - debugPrint( - '[BG_KM] +${(distanceMeters / 1000.0).toStringAsFixed(4)} km for $dId ' - 'in ${elapsedSeconds.toStringAsFixed(1)}s (total: ${newCumKm.toStringAsFixed(4)} km)', - ); - } - } - } else { - // No anchor yet — set initial position - await prefs.setString( - 'delivery_tracking_${dId}_lastLat', - currentLat.toString(), - ); - await prefs.setString( - 'delivery_tracking_${dId}_lastLng', - currentLng.toString(), - ); - await prefs.setInt( - 'delivery_tracking_${dId}_lastUpdateMs', - DateTime.now().millisecondsSinceEpoch, - ); - } - } catch (_) {} - } - } catch (e) { - debugPrint('[BG_KM] Error in background KM accumulation: $e'); - } - } - - static Future createLoginNow() async { - try { - final prefs = await SharedPreferences.getInstance(); - // Reload from disk so we see the latest values written by the main isolate - await prefs.reload(); - - final int onduty = prefs.getInt('onduty') ?? 0; - if (onduty != 1) { - return; - } - final int? userid = prefs.getInt('userId') ?? prefs.getInt('userid'); - final int? partnerid = - prefs.getInt('partnerId') ?? prefs.getInt('partnerid'); - final int? shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid'); - if ((userid ?? 0) == 0) return; - - // Prefer explicit username, then fallback to stored full name or first/last - String? username = prefs.getString('username'); - username ??= prefs.getString('user_name'); - if (username == null || username.trim().isEmpty) { - final first = prefs.getString('firstname') ?? ''; - final last = prefs.getString('lastname') ?? ''; - final combined = ('$first $last').trim(); - if (combined.isNotEmpty) { - username = combined; - } - } - - // ✅ Check if there are active deliveries to set status - final bool hasActiveDeliveries = - prefs.getBool('has_live_deliveries') ?? false; - final String riderStatus = hasActiveDeliveries ? 'active' : 'idle'; - - final now = DateTime.now(); - final iso = _formatDateTimeFull(now); - final loginTime = _formatTime(now); - final loc = await _ensureLatLng(); - - // Accumulate KMs in background when LiveTrackingService (main isolate) is not active - await _accumulateBackgroundKms(prefs, loc); - - final int? tenantid = prefs.getInt('tenantid'); - final int? locationid = prefs.getInt('locationid'); - final int? applocationid = prefs.getInt('applocationid'); - final String? userfcmtoken = prefs.getString('userfcmtoken'); - - final int? logid = prefs.getInt('logId') ?? prefs.getInt('logid'); - final String orderId = prefs.getString('current_riding_order_id') ?? ''; - - final payload = { - "logid": logid ?? 0, - "userid": userid, - "partnerid": partnerid, - "shiftid": shiftid, - "logdate": iso, - "login": loginTime, - "latitude": loc['lat'] ?? '0', - "longitude": loc['lng'] ?? '0', - "raw_latitude": loc['raw_lat'] ?? '0', - "raw_longitude": loc['raw_lng'] ?? '0', - "velocity_lat": loc['velocity_lat'] ?? '0', - "velocity_lng": loc['velocity_lng'] ?? '0', - "speed": loc['speed'] ?? '0', - "heading": loc['heading'] ?? '0', - "onduty": 1, - "status": riderStatus, - "contactno": prefs.getString('contactno') ?? '', - "tenantid": tenantid ?? 0, - "locationid": locationid ?? 0, - "applocationid": applocationid ?? 0, - "userfcmtoken": userfcmtoken ?? '', - "username": (username ?? '').trim(), - "orderid": orderId, - }; - - final firstName = prefs.getString('firstname') ?? ''; - final lastName = prefs.getString('lastname') ?? ''; - if (firstName.trim().isNotEmpty) { - payload['firstname'] = firstName.trim(); - } - if (lastName.trim().isNotEmpty) { - payload['lastname'] = lastName.trim(); - } - - final base = ApiConstants.mainRoute == 'live' - ? ApiConstants.createRiderLogLive - : ApiConstants.createRiderLogDev; - - final provider = CreateRiderLogProvider(); - final resp = await provider.createRiderLog(base, payload); - - if (resp == null || resp.isEmpty) return; - final det = (resp['details'] is Map) - ? (resp['details'] as Map) - : resp; - final newLogId = - int.tryParse('${det['logid'] ?? 0}') ?? (det['logid'] as int? ?? 0); - await prefs.setInt('logid', newLogId); - await prefs.setInt('logId', newLogId); - - // ✅ MQTT BACKGROUND PUBLISH ( Lane Split ) - final mqttService = DoormileMqttService(); - if (!mqttService.isConnected) { - // Use a slightly different client ID for background to avoid kicking the main one off - await mqttService.connect(); - } - - if (mqttService.isConnected) { - // Gather Telemetry - final battery = Battery(); - final int batteryLevel = await battery.batteryLevel; - final BatteryState batteryState = await battery.batteryState; - final isCharging = - batteryState == BatteryState.charging || - batteryState == BatteryState.full; - - final connectivity = await Connectivity().checkConnectivity(); - final String connType = connectivity.isNotEmpty - ? connectivity.first.toString().split('.').last - : 'none'; - - // 1. Direct Telemetry (Feeding the /full API) - mqttService.publish('battery', '$batteryLevel%'); - mqttService.publish('charging', isCharging ? 'yes' : 'no'); - mqttService.publish('speed', loc['speed'] ?? '0'); - mqttService.publish('connection', connType); - mqttService.publish('accuracy', loc['accuracy'] ?? '0'); - - // 2. Alert if Location is Off - final String locStatus = loc['status'] ?? 'unknown'; - if (locStatus != 'enabled') { - mqttService.publish('alerts', { - 'userid': userid, - 'username': (username ?? '').trim(), - 'event': 'location_turned_off', - 'error_type': locStatus, - 'battery': '$batteryLevel%', - 'is_charging': isCharging, - 'connection': connType, - 'logdate': iso, - }); - } - - // 3. Low Battery Alert - if (batteryLevel < 15 && !isCharging) { - mqttService.publish('alerts', { - 'userid': userid, - 'username': (username ?? '').trim(), - 'event': 'low_battery_warning', - 'battery': '$batteryLevel%', - 'logdate': iso, - }); - } - - // 4. Poor GPS Accuracy Alert - final double accuracy = double.tryParse(loc['accuracy'] ?? '0') ?? 0; - if (accuracy > 30) { - mqttService.publish('alerts', { - 'userid': userid, - 'username': (username ?? '').trim(), - 'event': 'poor_gps_signal', - 'accuracy': '${accuracy.toStringAsFixed(1)}m', - 'logdate': iso, - }); - } - - // 5. Lane: Status - mqttService.updateStatus( - riderStatus == 'active' ? 'Active' : MqttConstants.statusOnline, - ); - - // 6. Lane: Periodic Log (Comprehensive Snapshot) - mqttService.publishLog('rider_periodic_log', { - 'userid': userid, - 'username': username, - 'logdate': iso, - 'latitude': loc['lat'] ?? '0', - 'longitude': loc['lng'] ?? '0', - 'speed': loc['speed'] ?? '0', - 'heading': loc['heading'] ?? '0', - 'accuracy': loc['accuracy'] ?? '0', - 'status': riderStatus, - 'orderid': orderId, - 'battery': '$batteryLevel%', - 'is_charging': isCharging, - 'connection': connType, - 'location_service': locStatus, - 'is_background': true, - }); - } - } catch (e) { - // ignore background errors - } - } -} - -class RiderLogTaskHandler extends TaskHandler { - Timer? _timer; // not used; plugin provides repeat callback, but keep safety - - @override - Future onStart(DateTime timestamp, SendPort? sendPort) async { - // No-op - } - - @override - Future onRepeatEvent(DateTime timestamp, SendPort? sendPort) async { - // 1. Rider Log (existing) - await _BackgroundRiderLog.createLoginNow(); - - // 2. Delivery Log (new) - await BackgroundDeliveryLog.processActiveDeliveries(); - - // 3. Auto Shift End (new) - await BackgroundDeliveryLog.checkShiftEnd(); - } - - @override - Future onDestroy(DateTime timestamp, SendPort? sendPort) async { - _timer?.cancel(); - _timer = null; - } -} - -@pragma('vm:entry-point') -void riderLogCallback() { - HttpOverrides.global = MyHttpOverrides(); - FlutterForegroundTask.setTaskHandler(RiderLogTaskHandler()); -} +import 'dart:async'; +import 'dart:io'; +import 'package:miler/helpers/http_overrides.dart'; +import 'dart:isolate'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; +import 'dart:math' as math; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:miler/xpress/views/helpers/constants/apiconstants.dart'; +import 'package:miler/xpress/providers/Riderlog/riderlog_provider.dart'; +import 'package:miler/xpress/background/backgroundservice.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:miler/xpress/utils/kalman_filter.dart'; +import 'package:miler/xpress/utils/mqtt_service.dart'; +import 'package:miler/xpress/views/helpers/constants/mqtt_constants.dart'; +import 'package:battery_plus/battery_plus.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; + +class _BackgroundRiderLog { + static DoormileKalmanFilter? _kf; + static DateTime? _lastUpdateTime; + + static Future> _ensureLatLng() async { + Map result = { + 'lat': '0', + 'lng': '0', + 'raw_lat': '0', + 'raw_lng': '0', + 'speed': '0', + 'heading': '0', + 'velocity_lat': '0', + 'velocity_lng': '0', + 'status': 'unknown', + 'accuracy': '0', + }; + try { + // 1. Check if location services are enabled + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + debugPrint('[BG_RIDER_LOG] Location services are disabled.'); + result['status'] = 'disabled'; + return result; + } + + // 2. Check permissions + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + debugPrint('[BG_RIDER_LOG] Location permission denied.'); + result['status'] = 'denied'; + return result; + } + if (permission == LocationPermission.deniedForever) { + debugPrint('[BG_RIDER_LOG] Location permission denied forever.'); + result['status'] = 'denied_forever'; + return result; + } + + result['status'] = 'enabled'; + + // 3. Get position (using non-deprecated LocationSettings + explicit timeout) + final pos = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ); + + // Reject mocked positions (anti-cheat) + if (pos.isMocked) { + debugPrint('[BG_RIDER_LOG] Mocked position detected — using cached'); + return result; + } + + final now = DateTime.now(); + double outLat = pos.latitude; + double outLng = pos.longitude; + double speed = pos.speed; + double heading = pos.heading; + + // Decompose velocity for Kalman + final double headingRadians = heading * (math.pi / 180.0); + final double velocityLng = speed * math.sin(headingRadians); + final double velocityLat = speed * math.cos(headingRadians); + + if (_kf == null) { + _kf = DoormileKalmanFilter(lat: outLat, lng: outLng); + } else { + final double dt = _lastUpdateTime != null + ? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0 + : 30.0; // Default background interval + _kf!.predict(dt); + _kf!.update(outLat, outLng); + outLat = _kf!.x[0]; + outLng = _kf!.x[1]; + } + _lastUpdateTime = now; + + return { + 'lat': outLat.toStringAsFixed(6), + 'lng': outLng.toStringAsFixed(6), + 'raw_lat': pos.latitude.toStringAsFixed(6), + 'raw_lng': pos.longitude.toStringAsFixed(6), + 'speed': speed.toStringAsFixed(2), + 'heading': heading.toStringAsFixed(2), + 'velocity_lat': velocityLat.toStringAsFixed(4), + 'velocity_lng': velocityLng.toStringAsFixed(4), + 'status': 'enabled', + 'accuracy': pos.accuracy.toStringAsFixed(1), + }; + } catch (e) { + debugPrint('[BG_RIDER_LOG] Error getting location: $e'); + return result; + } + } + + static String _two(int n) => n.toString().padLeft(2, '0'); + static String _formatDateTimeFull(DateTime dt) { + final y = dt.year.toString(); + final m = _two(dt.month); + final d = _two(dt.day); + final hh = _two(dt.hour); + final mm = _two(dt.minute); + final ss = _two(dt.second); + return "$y-$m-$d $hh:$mm:$ss"; + } + + static String _formatTime(DateTime dt) { + final hh = _two(dt.hour); + final mm = _two(dt.minute); + final ss = _two(dt.second); + return "$hh:$mm:$ss"; + } + + /// Accumulates cumulative KMs for active deliveries using the foreground service GPS position. + /// Only runs when LiveTrackingService (main isolate) hasn't updated in the last 10 seconds, + /// which means the app is backgrounded/screen-off/power-saver and the main isolate is dormant. + static Future _accumulateBackgroundKms( + SharedPreferences prefs, + Map loc, + ) async { + try { + // Check if the main isolate's LiveTrackingService is still actively updating + final lastLiveUpdateMs = + prefs.getInt('live_tracking_last_update_ms') ?? 0; + final nowMs = DateTime.now().millisecondsSinceEpoch; + final secondsSinceLiveUpdate = (nowMs - lastLiveUpdateMs) / 1000.0; + + if (secondsSinceLiveUpdate < 10.0) { + // Main isolate is active — let it handle KMs to avoid race conditions + debugPrint( + '[BG_KM] LiveTrackingService active (${secondsSinceLiveUpdate.toStringAsFixed(1)}s ago) — skipping background accumulation', + ); + return; + } + + // Skip if GPS accuracy is too poor for reliable KM tracking + final double accuracy = + double.tryParse(loc['accuracy'] ?? '9999') ?? 9999.0; + if (accuracy > 50.0) { + debugPrint( + '[BG_KM] Low-accuracy position (${accuracy.toStringAsFixed(0)}m) — skipping KM accumulation', + ); + return; + } + + final double currentLat = double.tryParse(loc['lat'] ?? '0') ?? 0.0; + final double currentLng = double.tryParse(loc['lng'] ?? '0') ?? 0.0; + if (currentLat == 0.0 || currentLng == 0.0) return; + + final activeDeliveryIds = + prefs.getStringList('active_tracking_delivery_ids') ?? []; + + for (final dId in activeDeliveryIds) { + try { + final lastLatStr = + prefs.getString('delivery_tracking_${dId}_lastLat') ?? ''; + final lastLngStr = + prefs.getString('delivery_tracking_${dId}_lastLng') ?? ''; + final currentCumKm = + double.tryParse( + prefs.getString('delivery_tracking_${dId}_cumulativeKm') ?? '0', + ) ?? + 0.0; + + if (lastLatStr.isNotEmpty && lastLngStr.isNotEmpty) { + final lastLat = double.tryParse(lastLatStr) ?? 0.0; + final lastLng = double.tryParse(lastLngStr) ?? 0.0; + + if (lastLat != 0.0 && lastLng != 0.0) { + final distanceMeters = Geolocator.distanceBetween( + lastLat, + lastLng, + currentLat, + currentLng, + ); + + // Speed-based jump guard: reject if implied speed > 120 km/h (33.3 m/s). + // Uses elapsed time since last recorded position so the threshold scales + // correctly whether the background interval is 30s, 60s, or longer. + final lastUpdateMs = + prefs.getInt('delivery_tracking_${dId}_lastUpdateMs') ?? 0; + final nowMs = DateTime.now().millisecondsSinceEpoch; + final elapsedSeconds = lastUpdateMs > 0 + ? (nowMs - lastUpdateMs) / 1000.0 + : 60.0; // conservative default + final maxRealisticMeters = + elapsedSeconds * 33.3; // 120 km/h ceiling + + if (distanceMeters > maxRealisticMeters && + distanceMeters > 50.0) { + // GPS jumped — update anchor without counting phantom distance + debugPrint( + '[BG_KM] GPS jump for $dId: ${distanceMeters.toStringAsFixed(0)}m ' + 'in ${elapsedSeconds.toStringAsFixed(1)}s (max: ${maxRealisticMeters.toStringAsFixed(0)}m) — resetting anchor', + ); + await prefs.setString( + 'delivery_tracking_${dId}_lastLat', + currentLat.toString(), + ); + await prefs.setString( + 'delivery_tracking_${dId}_lastLng', + currentLng.toString(), + ); + await prefs.setInt( + 'delivery_tracking_${dId}_lastUpdateMs', + nowMs, + ); + } else if (distanceMeters >= 5.0) { + final newCumKm = currentCumKm + (distanceMeters / 1000.0); + await prefs.setString( + 'delivery_tracking_${dId}_cumulativeKm', + newCumKm.toStringAsFixed(4), + ); + await prefs.setString( + 'delivery_tracking_${dId}_lastLat', + currentLat.toString(), + ); + await prefs.setString( + 'delivery_tracking_${dId}_lastLng', + currentLng.toString(), + ); + await prefs.setInt( + 'delivery_tracking_${dId}_lastUpdateMs', + nowMs, + ); + debugPrint( + '[BG_KM] +${(distanceMeters / 1000.0).toStringAsFixed(4)} km for $dId ' + 'in ${elapsedSeconds.toStringAsFixed(1)}s (total: ${newCumKm.toStringAsFixed(4)} km)', + ); + } + } + } else { + // No anchor yet — set initial position + await prefs.setString( + 'delivery_tracking_${dId}_lastLat', + currentLat.toString(), + ); + await prefs.setString( + 'delivery_tracking_${dId}_lastLng', + currentLng.toString(), + ); + await prefs.setInt( + 'delivery_tracking_${dId}_lastUpdateMs', + DateTime.now().millisecondsSinceEpoch, + ); + } + } catch (_) {} + } + } catch (e) { + debugPrint('[BG_KM] Error in background KM accumulation: $e'); + } + } + + static Future createLoginNow() async { + try { + final prefs = await SharedPreferences.getInstance(); + // Reload from disk so we see the latest values written by the main isolate + await prefs.reload(); + + final int onduty = prefs.getInt('onduty') ?? 0; + if (onduty != 1) { + return; + } + final int? userid = prefs.getInt('userId') ?? prefs.getInt('userid'); + final int? partnerid = + prefs.getInt('partnerId') ?? prefs.getInt('partnerid'); + final int? shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid'); + if ((userid ?? 0) == 0) return; + + // Prefer explicit username, then fallback to stored full name or first/last + String? username = prefs.getString('username'); + username ??= prefs.getString('user_name'); + if (username == null || username.trim().isEmpty) { + final first = prefs.getString('firstname') ?? ''; + final last = prefs.getString('lastname') ?? ''; + final combined = ('$first $last').trim(); + if (combined.isNotEmpty) { + username = combined; + } + } + + // ✅ Check if there are active deliveries to set status + final bool hasActiveDeliveries = + prefs.getBool('has_live_deliveries') ?? false; + final String riderStatus = hasActiveDeliveries ? 'active' : 'idle'; + + final now = DateTime.now(); + final iso = _formatDateTimeFull(now); + final loginTime = _formatTime(now); + final loc = await _ensureLatLng(); + + // Accumulate KMs in background when LiveTrackingService (main isolate) is not active + await _accumulateBackgroundKms(prefs, loc); + + final int? tenantid = prefs.getInt('tenantid'); + final int? locationid = prefs.getInt('locationid'); + final int? applocationid = prefs.getInt('applocationid'); + final String? userfcmtoken = prefs.getString('userfcmtoken'); + + final int? logid = prefs.getInt('logId') ?? prefs.getInt('logid'); + final String orderId = prefs.getString('current_riding_order_id') ?? ''; + + final payload = { + "logid": logid ?? 0, + "userid": userid, + "partnerid": partnerid, + "shiftid": shiftid, + "logdate": iso, + "login": loginTime, + "latitude": loc['lat'] ?? '0', + "longitude": loc['lng'] ?? '0', + "raw_latitude": loc['raw_lat'] ?? '0', + "raw_longitude": loc['raw_lng'] ?? '0', + "velocity_lat": loc['velocity_lat'] ?? '0', + "velocity_lng": loc['velocity_lng'] ?? '0', + "speed": loc['speed'] ?? '0', + "heading": loc['heading'] ?? '0', + "onduty": 1, + "status": riderStatus, + "contactno": prefs.getString('contactno') ?? '', + "tenantid": tenantid ?? 0, + "locationid": locationid ?? 0, + "applocationid": applocationid ?? 0, + "userfcmtoken": userfcmtoken ?? '', + "username": (username ?? '').trim(), + "orderid": orderId, + }; + + final firstName = prefs.getString('firstname') ?? ''; + final lastName = prefs.getString('lastname') ?? ''; + if (firstName.trim().isNotEmpty) { + payload['firstname'] = firstName.trim(); + } + if (lastName.trim().isNotEmpty) { + payload['lastname'] = lastName.trim(); + } + + final base = ApiConstants.mainRoute == 'live' + ? ApiConstants.createRiderLogLive + : ApiConstants.createRiderLogDev; + + final provider = CreateRiderLogProvider(); + final resp = await provider.createRiderLog(base, payload); + + if (resp == null || resp.isEmpty) return; + final det = (resp['details'] is Map) + ? (resp['details'] as Map) + : resp; + final newLogId = + int.tryParse('${det['logid'] ?? 0}') ?? (det['logid'] as int? ?? 0); + await prefs.setInt('logid', newLogId); + await prefs.setInt('logId', newLogId); + + // ✅ MQTT BACKGROUND PUBLISH ( Lane Split ) + final mqttService = DoormileMqttService(); + if (!mqttService.isConnected) { + // Use a slightly different client ID for background to avoid kicking the main one off + await mqttService.connect(); + } + + if (mqttService.isConnected) { + // Gather Telemetry + final battery = Battery(); + final int batteryLevel = await battery.batteryLevel; + final BatteryState batteryState = await battery.batteryState; + final isCharging = + batteryState == BatteryState.charging || + batteryState == BatteryState.full; + + final connectivity = await Connectivity().checkConnectivity(); + final String connType = connectivity.isNotEmpty + ? connectivity.first.toString().split('.').last + : 'none'; + + // 1. Direct Telemetry (Feeding the /full API) + mqttService.publish('battery', '$batteryLevel%'); + mqttService.publish('charging', isCharging ? 'yes' : 'no'); + mqttService.publish('speed', loc['speed'] ?? '0'); + mqttService.publish('connection', connType); + mqttService.publish('accuracy', loc['accuracy'] ?? '0'); + + // 2. Alert if Location is Off + final String locStatus = loc['status'] ?? 'unknown'; + if (locStatus != 'enabled') { + mqttService.publish('alerts', { + 'userid': userid, + 'username': (username ?? '').trim(), + 'event': 'location_turned_off', + 'error_type': locStatus, + 'battery': '$batteryLevel%', + 'is_charging': isCharging, + 'connection': connType, + 'logdate': iso, + }); + } + + // 3. Low Battery Alert + if (batteryLevel < 15 && !isCharging) { + mqttService.publish('alerts', { + 'userid': userid, + 'username': (username ?? '').trim(), + 'event': 'low_battery_warning', + 'battery': '$batteryLevel%', + 'logdate': iso, + }); + } + + // 4. Poor GPS Accuracy Alert + final double accuracy = double.tryParse(loc['accuracy'] ?? '0') ?? 0; + if (accuracy > 30) { + mqttService.publish('alerts', { + 'userid': userid, + 'username': (username ?? '').trim(), + 'event': 'poor_gps_signal', + 'accuracy': '${accuracy.toStringAsFixed(1)}m', + 'logdate': iso, + }); + } + + // 5. Lane: Status + mqttService.updateStatus( + riderStatus == 'active' ? 'Active' : MqttConstants.statusOnline, + ); + + // 6. Lane: Periodic Log (Comprehensive Snapshot) + mqttService.publishLog('rider_periodic_log', { + 'userid': userid, + 'username': username, + 'logdate': iso, + 'latitude': loc['lat'] ?? '0', + 'longitude': loc['lng'] ?? '0', + 'speed': loc['speed'] ?? '0', + 'heading': loc['heading'] ?? '0', + 'accuracy': loc['accuracy'] ?? '0', + 'status': riderStatus, + 'orderid': orderId, + 'battery': '$batteryLevel%', + 'is_charging': isCharging, + 'connection': connType, + 'location_service': locStatus, + 'is_background': true, + }); + } + } catch (e) { + // ignore background errors + } + } +} + +class RiderLogTaskHandler extends TaskHandler { + Timer? _timer; // not used; plugin provides repeat callback, but keep safety + + @override + Future onStart(DateTime timestamp, SendPort? sendPort) async { + // No-op + } + + @override + Future onRepeatEvent(DateTime timestamp, SendPort? sendPort) async { + // 1. Rider Log (existing) + await _BackgroundRiderLog.createLoginNow(); + + // 2. Delivery Log (new) + await BackgroundDeliveryLog.processActiveDeliveries(); + + // 3. Auto Shift End (new) + await BackgroundDeliveryLog.checkShiftEnd(); + } + + @override + Future onDestroy(DateTime timestamp, SendPort? sendPort) async { + _timer?.cancel(); + _timer = null; + } +} + +@pragma('vm:entry-point') +void riderLogCallback() { + HttpOverrides.global = MyHttpOverrides(); + FlutterForegroundTask.setTaskHandler(RiderLogTaskHandler()); +} diff --git a/lib/xpress/data/delivery_api.dart b/lib/xpress/data/delivery_api.dart index e4eba13..39652ad 100644 --- a/lib/xpress/data/delivery_api.dart +++ b/lib/xpress/data/delivery_api.dart @@ -6,13 +6,12 @@ import 'package:miler/data/api_config.dart'; /// ── Why this file exists ── /// /// The delivery screens under `lib/xpress` are a verbatim port of the -/// Xpress-rider app, which talked to a different backend entirely: -/// `jupiter.nearle.app` for reads and `queue.workolik.com` for writes, with the -/// URLs scattered inline across `homepage.dart`, `cartpage.dart`, -/// `deliveries.dart`, `summary.dart` and three controllers. Repointing that at -/// Doormile by find/replacing hostnames is exactly the class of change that -/// produced the `deliver`→`Pick up` corruption this repo has already had to -/// undo once. +/// Xpress-rider app, which talked to a **retired backend** entirely — two +/// hosts, one for reads and one for writes, with the URLs scattered inline +/// across `homepage.dart`, `cartpage.dart`, `deliveries.dart`, `summary.dart` +/// and three controllers. Repointing that at Doormile by find/replacing +/// hostnames is exactly the class of change that produced the +/// `deliver`→`Pick up` corruption this repo has already had to undo once. /// /// So every delivery-line URL is named here, once, and the call sites read a /// getter. Repointing the line is now one file. @@ -46,7 +45,7 @@ import 'package:miler/data/api_config.dart'; /// be thin. /// /// This is a known, accepted gap: it was chosen deliberately over keeping the -/// working Nearle hosts. See the note in `docs/` if one is added. +/// retired platform's hosts alive. See the note in `docs/` if one is added. /// ───────────────────────────────────────────────────────────────────────── class DeliveryApi { DeliveryApi._(); @@ -173,9 +172,9 @@ class DeliveryApi { /// **ABSENT** — image upload for a support ticket attachment. /// - /// Was `/api/v1/partners/uploadimage/`, with the result served back from - /// `images.nearle.app`. Doormile has no upload route and no image host, so - /// attachments cannot be sent. + /// Was `/api/v1/partners/uploadimage/` on the retired platform, with the + /// result served back from its own image host. Doormile has no upload route + /// and no image host, so attachments cannot be sent. static String get uploadImage => url('/miler/support/upload'); /// Public base for an uploaded image. **ABSENT** — see [uploadImage]. diff --git a/lib/xpress/providers/Riderlog/riderlog_provider.dart b/lib/xpress/providers/Riderlog/riderlog_provider.dart index 81182e5..111622a 100644 --- a/lib/xpress/providers/Riderlog/riderlog_provider.dart +++ b/lib/xpress/providers/Riderlog/riderlog_provider.dart @@ -1,260 +1,260 @@ -import 'dart:convert'; -import 'dart:io'; -import 'package:http/http.dart'; -import 'package:http/io_client.dart'; -import 'package:flutter/foundation.dart'; - -// Combined Riderlog providers: - -/// Hardcoded known-good IPs for hosts where carrier DNS returns broken nodes. -/// -/// **Empty since the delivery line moved to Doormile.** The one entry here -/// pinned `queue.workolik.com` to 66.116.225.226, because some Indian carriers' -/// DNS resolved that host to a CDN node that answered 404. Nothing calls that -/// host any more — every delivery-line write goes to `api.doormile.com` via -/// [DeliveryApi] — so the pin was routing advice about a server this app no -/// longer talks to. -/// -/// It is deliberately not re-pointed at the Doormile host: that IP belongs to -/// the old backend, and pinning it for `api.doormile.com` would send every -/// request to the wrong server with the right SNI. If Doormile ever needs the -/// same workaround, add it here with its own verified address. -const _knownGoodIPs = {}; - -/// Creates an IOClient that: -/// 1. Bypasses SSL certificate errors -/// 2. Forces known-good IPs for hosts where carrier DNS returns broken CDN nodes -/// 3. Manually does TLS upgrade with correct SNI (hostname, not IP) -IOClient _buildSslBypassClient() { - final httpClient = HttpClient() - ..badCertificateCallback = (X509Certificate cert, String host, int port) => - true; - - httpClient - .connectionFactory = (Uri uri, String? proxyHost, int? proxyPort) async { - final host = uri.host; - final port = uri.port; - - // Use known-good IP if available, else resolve normally (prefer IPv4) - InternetAddress? target; - final knownIP = _knownGoodIPs[host]; - if (knownIP != null) { - target = InternetAddress(knownIP); - debugPrint('[SSL_CLIENT] Using known-good IP: $knownIP for $host'); - } else { - try { - final addresses = await InternetAddress.lookup( - host, - type: InternetAddressType.IPv4, - ); - if (addresses.isNotEmpty) target = addresses.first; - } catch (_) {} - } - - if (uri.scheme == 'https') { - final socketFuture = Socket.connect(target ?? InternetAddress(host), port) - .then( - (plain) => SecureSocket.secure( - plain, - host: host, // SNI = original hostname for Nginx routing - onBadCertificate: (_) => true, - supportedProtocols: ['http/1.1'], - ), - ) - .then((s) => s as Socket); - return ConnectionTask.fromSocket(socketFuture, () {}); - } - - return Socket.startConnect(target ?? InternetAddress(host), port); - }; - - return IOClient(httpClient); -} - -class CreateRiderLogProvider { - Future?> createRiderLog( - String urldata, - Map data, - ) async { - const maxAttempts = 3; - try { - debugPrint('createRiderLog payload ${json.encode(data)}'); - } catch (_) {} - - for (int attempt = 1; attempt <= maxAttempts; attempt++) { - final client = _buildSslBypassClient(); - try { - final url = Uri.parse(urldata); - final response = await client.post( - url, - body: json.encode(data), - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - ); - debugPrint('createRiderLog url $urldata (attempt $attempt)'); - debugPrint('createRiderLog status ${response.statusCode}'); - debugPrint('createRiderLog response ${response.body}'); - - if (response.statusCode >= 200 && response.statusCode < 300) { - return json.decode(response.body.toString()) as Map; - } else { - debugPrint( - 'createRiderLog failed: HTTP ${response.statusCode} (attempt $attempt/$maxAttempts)', - ); - // On 404/5xx, wait and retry to potentially hit a different CDN node - if (attempt < maxAttempts) { - await Future.delayed(const Duration(seconds: 1)); - } - } - } catch (e) { - debugPrint('createRiderLog exception (attempt $attempt): $e'); - if (attempt < maxAttempts) { - await Future.delayed(const Duration(seconds: 1)); - } - } finally { - client.close(); - } - } - - debugPrint('createRiderLog failed after $maxAttempts attempts'); - return null; - } -} - -class UpdateRiderLogProvider { - Future?> updateRiderLog( - String urldata, - Map data, - ) async { - try { - final url = Uri.parse(urldata); - final response = await put( - url, - body: json.encode(data), - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - ); - - debugPrint('updateRiderLog url: $urldata'); - debugPrint('updateRiderLog response: ${response.body}'); - - if (response.statusCode >= 200 && response.statusCode < 300) { - final decoded = json.decode(response.body); - if (decoded is Map) { - return decoded; - } else { - debugPrint( - '⚠️ updateRiderLog: Expected Map but got ${decoded.runtimeType}', - ); - return {}; - } - } else { - debugPrint('❌ updateRiderLog failed with code ${response.statusCode}'); - return {}; - } - } catch (e) { - debugPrint('❌ Exception in updateRiderLog: $e'); - return {}; - } - } -} - -class GetRiderLogProvider { - Future?> getRiderLog(String urldata) async { - Map? getRiderLogResponse; - try { - final url = Uri.parse(urldata); - final response = await get( - url, - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - ); - debugPrint('getRiderLog response ${response.body}'); - debugPrint('getRiderLog url ${urldata.toString()}'); - getRiderLogResponse = - json.decode(response.body.toString()) as Map; - } catch (e) { - debugPrint(e.toString()); - } - return getRiderLogResponse; - } - - Future?> getRiderCount(String urldata) async { - Map? getRiderCountResponse; - try { - final url = Uri.parse(urldata); - final response = await get( - url, - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - ); - debugPrint('getRiderCount response ${response.body}'); - debugPrint('getRiderCount url ${urldata.toString()}'); - getRiderCountResponse = - json.decode(response.body.toString()) as Map; - } catch (e) { - debugPrint(e.toString()); - } - return getRiderCountResponse; - } -} - -class BreakRiderLogProvider { - Future?> createBreakRiderLog( - String urldata, - Map data, - ) async { - Map? breakLogResponse; - try { - final url = Uri.parse(urldata); - final response = await post( - url, - body: json.encode(data), - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - ); - debugPrint('createBreakRiderLog url $urldata'); - debugPrint('createBreakRiderLog response ${response.body}'); - breakLogResponse = - json.decode(response.body.toString()) as Map; - } catch (e) { - debugPrint(e.toString()); - } - return breakLogResponse; - } - - Future?> updateBreakRiderLog( - String urldata, - Map data, - ) async { - Map? breakLogResponse; - try { - final url = Uri.parse(urldata); - final response = await put( - url, - body: json.encode(data), - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - ); - debugPrint('updateBreakRiderLog url $urldata'); - debugPrint('updateBreakRiderLog response ${response.body}'); - breakLogResponse = - json.decode(response.body.toString()) as Map; - } catch (e) { - debugPrint(e.toString()); - } - return breakLogResponse; - } -} +import 'dart:convert'; +import 'dart:io'; +import 'package:http/http.dart'; +import 'package:http/io_client.dart'; +import 'package:flutter/foundation.dart'; + +// Combined Riderlog providers: + +/// Hardcoded known-good IPs for hosts where carrier DNS returns broken nodes. +/// +/// **Empty since the delivery line moved to Doormile.** The one entry here +/// pinned `queue.workolik.com` to 66.116.225.226, because some Indian carriers' +/// DNS resolved that host to a CDN node that answered 404. Nothing calls that +/// host any more — every delivery-line write goes to `api.doormile.com` via +/// [DeliveryApi] — so the pin was routing advice about a server this app no +/// longer talks to. +/// +/// It is deliberately not re-pointed at the Doormile host: that IP belongs to +/// the old backend, and pinning it for `api.doormile.com` would send every +/// request to the wrong server with the right SNI. If Doormile ever needs the +/// same workaround, add it here with its own verified address. +const _knownGoodIPs = {}; + +/// Creates an IOClient that: +/// 1. Bypasses SSL certificate errors +/// 2. Forces known-good IPs for hosts where carrier DNS returns broken CDN nodes +/// 3. Manually does TLS upgrade with correct SNI (hostname, not IP) +IOClient _buildSslBypassClient() { + final httpClient = HttpClient() + ..badCertificateCallback = (X509Certificate cert, String host, int port) => + true; + + httpClient + .connectionFactory = (Uri uri, String? proxyHost, int? proxyPort) async { + final host = uri.host; + final port = uri.port; + + // Use known-good IP if available, else resolve normally (prefer IPv4) + InternetAddress? target; + final knownIP = _knownGoodIPs[host]; + if (knownIP != null) { + target = InternetAddress(knownIP); + debugPrint('[SSL_CLIENT] Using known-good IP: $knownIP for $host'); + } else { + try { + final addresses = await InternetAddress.lookup( + host, + type: InternetAddressType.IPv4, + ); + if (addresses.isNotEmpty) target = addresses.first; + } catch (_) {} + } + + if (uri.scheme == 'https') { + final socketFuture = Socket.connect(target ?? InternetAddress(host), port) + .then( + (plain) => SecureSocket.secure( + plain, + host: host, // SNI = original hostname for Nginx routing + onBadCertificate: (_) => true, + supportedProtocols: ['http/1.1'], + ), + ) + .then((s) => s as Socket); + return ConnectionTask.fromSocket(socketFuture, () {}); + } + + return Socket.startConnect(target ?? InternetAddress(host), port); + }; + + return IOClient(httpClient); +} + +class CreateRiderLogProvider { + Future?> createRiderLog( + String urldata, + Map data, + ) async { + const maxAttempts = 3; + try { + debugPrint('createRiderLog payload ${json.encode(data)}'); + } catch (_) {} + + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + final client = _buildSslBypassClient(); + try { + final url = Uri.parse(urldata); + final response = await client.post( + url, + body: json.encode(data), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ); + debugPrint('createRiderLog url $urldata (attempt $attempt)'); + debugPrint('createRiderLog status ${response.statusCode}'); + debugPrint('createRiderLog response ${response.body}'); + + if (response.statusCode >= 200 && response.statusCode < 300) { + return json.decode(response.body.toString()) as Map; + } else { + debugPrint( + 'createRiderLog failed: HTTP ${response.statusCode} (attempt $attempt/$maxAttempts)', + ); + // On 404/5xx, wait and retry to potentially hit a different CDN node + if (attempt < maxAttempts) { + await Future.delayed(const Duration(seconds: 1)); + } + } + } catch (e) { + debugPrint('createRiderLog exception (attempt $attempt): $e'); + if (attempt < maxAttempts) { + await Future.delayed(const Duration(seconds: 1)); + } + } finally { + client.close(); + } + } + + debugPrint('createRiderLog failed after $maxAttempts attempts'); + return null; + } +} + +class UpdateRiderLogProvider { + Future?> updateRiderLog( + String urldata, + Map data, + ) async { + try { + final url = Uri.parse(urldata); + final response = await put( + url, + body: json.encode(data), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ); + + debugPrint('updateRiderLog url: $urldata'); + debugPrint('updateRiderLog response: ${response.body}'); + + if (response.statusCode >= 200 && response.statusCode < 300) { + final decoded = json.decode(response.body); + if (decoded is Map) { + return decoded; + } else { + debugPrint( + '⚠️ updateRiderLog: Expected Map but got ${decoded.runtimeType}', + ); + return {}; + } + } else { + debugPrint('❌ updateRiderLog failed with code ${response.statusCode}'); + return {}; + } + } catch (e) { + debugPrint('❌ Exception in updateRiderLog: $e'); + return {}; + } + } +} + +class GetRiderLogProvider { + Future?> getRiderLog(String urldata) async { + Map? getRiderLogResponse; + try { + final url = Uri.parse(urldata); + final response = await get( + url, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ); + debugPrint('getRiderLog response ${response.body}'); + debugPrint('getRiderLog url ${urldata.toString()}'); + getRiderLogResponse = + json.decode(response.body.toString()) as Map; + } catch (e) { + debugPrint(e.toString()); + } + return getRiderLogResponse; + } + + Future?> getRiderCount(String urldata) async { + Map? getRiderCountResponse; + try { + final url = Uri.parse(urldata); + final response = await get( + url, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ); + debugPrint('getRiderCount response ${response.body}'); + debugPrint('getRiderCount url ${urldata.toString()}'); + getRiderCountResponse = + json.decode(response.body.toString()) as Map; + } catch (e) { + debugPrint(e.toString()); + } + return getRiderCountResponse; + } +} + +class BreakRiderLogProvider { + Future?> createBreakRiderLog( + String urldata, + Map data, + ) async { + Map? breakLogResponse; + try { + final url = Uri.parse(urldata); + final response = await post( + url, + body: json.encode(data), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ); + debugPrint('createBreakRiderLog url $urldata'); + debugPrint('createBreakRiderLog response ${response.body}'); + breakLogResponse = + json.decode(response.body.toString()) as Map; + } catch (e) { + debugPrint(e.toString()); + } + return breakLogResponse; + } + + Future?> updateBreakRiderLog( + String urldata, + Map data, + ) async { + Map? breakLogResponse; + try { + final url = Uri.parse(urldata); + final response = await put( + url, + body: json.encode(data), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ); + debugPrint('updateBreakRiderLog url $urldata'); + debugPrint('updateBreakRiderLog response ${response.body}'); + breakLogResponse = + json.decode(response.body.toString()) as Map; + } catch (e) { + debugPrint(e.toString()); + } + return breakLogResponse; + } +} diff --git a/lib/xpress/widget/Bottom_page.dart b/lib/xpress/widget/Bottom_page.dart index 1f99abf..37aac88 100644 --- a/lib/xpress/widget/Bottom_page.dart +++ b/lib/xpress/widget/Bottom_page.dart @@ -47,7 +47,7 @@ class _BottomPageState extends State { body: IndexedStack(index: selected, children: _pages), bottomNavigationBar: Container( decoration: const BoxDecoration( - color: Colors.white, + color: Color(0xFFE9EDF2), boxShadow: [ BoxShadow( color: Colors.black12, @@ -57,7 +57,7 @@ class _BottomPageState extends State { ], ), child: BottomNavigationBar( - backgroundColor: Colors.white, + backgroundColor: Color(0xFFE9EDF2), currentIndex: selected, onTap: (index) => setState(() => selected = index), type: BottomNavigationBarType.fixed, diff --git a/test/activity_list_test.dart b/test/activity_list_test.dart index c47f3c8..f0c9b79 100644 --- a/test/activity_list_test.dart +++ b/test/activity_list_test.dart @@ -9,6 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/views/Dashboard/activity/activity_page.dart'; import 'package:miler/views/Dashboard/profile/Profilepage.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/narrative.dart'; import 'package:miler/views/helpers/widgets/miler_app_bar.dart'; import 'package:miler/data/accepted_store.dart'; @@ -151,11 +152,30 @@ void main() { // Activity were on `pureSurface`. Bookings hides its background under // cards; Account is bare rows, so the whole tab read a shade dirtier. // - // The paper moved: the scaffold's own background is the **brand** now, so - // it shows through the two rounded top corners of [MilerSheet], and the - // sheet paints the page. Both halves are contracts — a tab whose ground is - // white draws no corners at all, and a tab whose sheet is off-white is the - // original bug one layer down — so both are asserted. + // The paper moved: the scaffold's own background became the **brand**, so + // it could show through the two rounded top corners [MilerSheet] used to + // clip, and the sheet painted the page. + // + // ── Why the ground is no longer a fixed colour ── + // + // That arrangement is gone. The curve moved onto the bar, which paints the + // page colour behind its own corners (see [milerBrandSurface]), and + // [MilerSheet] clips at zero radius. A brand-red ground is therefore no + // longer load-bearing — it is only invisible for as long as the sheet + // covers every pixel of the body. + // + // On Activity it did not. The sheet was pushed down 30pt to make room for + // the floating figure card, and what showed in the gap was a full-width + // band of maroon directly under the app bar — bare down both gutters, and + // on a day with nothing finished (no card built at all) a solid red stripe + // across the screen. It read as a second, broken app bar. + // + // So the ground is asserted against the thing that actually matters: it + // must be either the brand — fine, where the sheet covers it whole — or + // the sheet's own colour, which is the only other way a band cannot + // appear. What stays banned is a *third* colour, which is how a tab ends + // up a shade dirtier than the rest. See also the Activity-specific test + // below, which pins the case that broke. T? firstOf() { final found = []; void visit(Element e) { @@ -182,13 +202,16 @@ void main() { ); await tester.pump(); - expect( - firstOf()?.backgroundColor, - ColorConstants.primary, - reason: 'the ground behind the sheet corners must be the brand', - ); final sheet = firstOf(); expect(sheet, isNotNull, reason: 'every tab sits in a MilerSheet'); + final paper = sheet!.color ?? ColorConstants.pureSurface; + + expect( + firstOf()?.backgroundColor, + anyOf(ColorConstants.primary, paper), + reason: + 'a ground that is neither the brand nor the page shows as a band', + ); // What the tabs must agree on is the *ladder*, not one flat paper: // a sheet is either the working white (rows drawn straight on it — // Activity) or the canvas with white panels standing on it (Home, @@ -196,13 +219,69 @@ void main() { // the warm off-white that made one tab read a shade dirtier than the // rest. expect( - sheet!.color ?? ColorConstants.pureSurface, - anyOf(ColorConstants.pureSurface, ColorConstants.daylightSurface), - reason: 'a sheet is a rung of the surface ladder, never an off-white', + paper, + anyOf( + ColorConstants.pureSurface, + ColorConstants.daylightSurface, + // Activity was rebuilt against the Narrative brief, which pairs a + // *lighter* canvas with cards that carry a border and a lift. Its + // ground is a rung of that ladder rather than this one — see + // [Narrative], and the note there about not mixing the two recipes. + Narrative.canvas, + ), + reason: 'a sheet is a rung of a surface ladder, never an off-white', ); } }); + testWidgets('Activity shows no brand band under the app bar', ( + tester, + ) async { + // The regression, pinned at the layer it happened on. Activity is the one + // tab that floats a card over the seam, so it is the one tab whose sheet + // was inset — and an inset sheet over a brand-red ground is a red stripe. + // + // Its ground is the page now, so there is nothing to leak whatever the + // sheet does, and the empty-day case — where the card is not built at all + // and the stripe was full width — is covered by the same fact. + T? firstOf() { + final found = []; + void visit(Element e) { + final w = e.widget; + if (w is T) found.add(w); + e.visitChildren(visit); + } + + WidgetsBinding.instance.rootElement!.visitChildren(visit); + return found.isEmpty ? null : found.first; + } + + SharedPreferences.setMockInitialValues({'userId': 1}); + tester.view.physicalSize = const Size(390 * 3, 844 * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => const MaterialApp(home: ActivityPage()), + ), + ); + await tester.pump(); + + final ground = firstOf()?.backgroundColor; + expect( + ground, + isNot(ColorConstants.primary), + reason: 'the 30pt maroon band under the bar was this colour', + ); + expect( + ground, + firstOf()?.color ?? ColorConstants.pureSurface, + reason: 'ground and page must match, so no inset can expose a band', + ); + }); + // ── A row offers one gesture, and it is the whole row ── // // The card carried a footer band: a hairline, ~22pt of gaps, the booking diff --git a/test/activity_page_test.dart b/test/activity_page_test.dart index 4d0ebf7..09902e1 100644 --- a/test/activity_page_test.dart +++ b/test/activity_page_test.dart @@ -9,6 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/data/service_profile.dart'; import 'package:miler/views/Dashboard/activity/activity_page.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/narrative.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; import 'package:miler/data/accepted_store.dart'; @@ -127,46 +128,60 @@ void main() { // opens this screen for between stops is how his day is going, so that // is what the space says now. expect(find.text('Your recent delivery activity'), findsNothing); - // The header is a figure now: the completed count as a numeral with - // "delivered today" as its caption — the same treatment Home's head - // uses, and the fixture's two finished stops are the number it shows. + // ── The header is two figures now ── + // + // A count and a distance, given equal halves of the card with a hairline + // between: what the shift produced and what it cost. `today` came off + // both captions — the card is the day, and saying so twice inside it was + // the word doing no work. expect(find.text('2'), findsOneWidget); - expect(find.textContaining('today'), findsOneWidget); + // The caption is the line's own completion verb — `picked up` on the + // parcel profile the fixture runs, `delivered` on a meal round — so the + // assertion is on the shape rather than on one line's word. + expect( + find.text(ServiceProfile.active.completionVerb.toLowerCase()), + findsOneWidget, + ); for (final f in const ['All', 'Active', 'Completed', 'Cancelled']) { expect(find.text(f), findsOneWidget, reason: '$f is missing'); } }); - testWidgets('the selected filter is a rule, not a chip', (tester) async { + testWidgets('the selected filter is one travelling pill', (tester) async { await pump(tester, completed: [done('A1', 'Joe Mathew')]); - // ── This assertion moved down a level, deliberately ── + // ── This assertion has moved three times, and the principle held ── // - // It used to require exactly one *filled chip*, which was already the - // quiet version: a bar where all four are outlined is four boxes - // arguing about which one is on. But a filled pill is still a container, - // and on a page whose brief is that containers recede it was the - // loudest object above the list. + // It began as "exactly one *filled chip*", against a bar where all four + // were outlined — four boxes arguing about which one is on. It became "a + // rule, not a chip" when the page's brief was that containers recede. + // Then a segmented control, with the selected state a physical object + // sliding along a track. // - // What survives is the principle, one step further: the selected state - // is carried by the brand colour on the word plus a 2pt rule under it, - // and the unselected ones carry nothing at all. + // The track is gone: it was a container drawn around a control that + // already marks its own selection, and its equal slots forced every label + // to the width of the longest. Four separate pills now, and the selected + // one is filled brand with white type. + // + // The principle is the same one, stated a fourth time — **one** mark of + // selection on the bar, never four. final selected = tester.widget(find.text('All')); - expect(selected.style!.color, ColorConstants.primary); + expect(selected.style!.color, Colors.white); expect(selected.style!.fontWeight, FontWeight.w700); final other = tester.widget(find.text('Completed')); - expect(other.style!.color, ColorConstants.secondaryText); + expect(other.style!.color, Narrative.inkMuted); + expect(other.style!.fontWeight, FontWeight.w600); - // One rule, on the selected word only. - final rules = tester + // Exactly one filled brand pill in the bar. + final filled = tester .widgetList(find.byType(AnimatedContainer)) .map((c) => c.decoration) .whereType() - .where((d) => d.color == ColorConstants.primary) + .where((d) => d.color == Narrative.burgundy) .toList(); - expect(rules.length, 1); + expect(filled, hasLength(1), reason: 'four selected filters is none'); }); testWidgets('there is no chrome to operate on an empty day', ( @@ -189,15 +204,22 @@ void main() { ServiceProfile.setActive(ServiceProfile.milkMan); await pump(tester, completed: [done('A1', 'Joe Mathew')]); - // The outcome word goes quiet for the norm: the rail disc beside the - // row already carries "delivered" in shape and colour, and a day of 25 - // green words left no colour for the row that went wrong. The word only - // wears the status ink when the status is the exception — pinned in - // activity_row_test's parked-stop case. + // ── The outcome is coloured again ── + // + // It went quiet when every row was drawn on the same white paper and + // colour was the only thing separating them. The rows are cards now, so + // the separation is structural, and the design brief makes emerald *the* + // delivered state — a status the eye can pick out without reading is the + // difference between scanning a day and reading one. The exception keeps + // its own ink, pinned in activity_row_test's parked-stop case. final word = tester.widget(find.textContaining('Delivered')); - expect(word.style!.color, ColorConstants.secondaryText); + expect(word.style!.color, Narrative.emerald); // The rider knows a stop by who was at the door, so the name leads. + // w600 — the brief's `title-lg`, which is what a card's own heading is + // set at. It briefly ran at w700 while the cards had no border and no + // lift to rank them; with both, the weight does not have to do that job + // as well. final name = tester.widget(find.text('Joe Mathew')); expect(name.style!.fontWeight, FontWeight.w600); expect( @@ -219,7 +241,11 @@ void main() { expect(find.text('#A1'), findsNothing); // The measurements are running text, not columns and not chips. + // + // One match. The hero's half prints the bare figure with `km ridden` as + // its caption, so the only `3.2 km` on the page is this row's own. expect(find.text('3.2 km'), findsOneWidget); + expect(find.text('km ridden'), findsOneWidget); expect(find.text('28m'), findsOneWidget); // The address is detail for one stop; it belongs in the record. diff --git a/test/activity_today_only_test.dart b/test/activity_today_only_test.dart new file mode 100644 index 0000000..7f08dde --- /dev/null +++ b/test/activity_today_only_test.dart @@ -0,0 +1,373 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/data/service_day.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// ACTIVITY IS STRICTLY TODAY +/// +/// The page is a shift log, not an archive, and the rule it rests on is one +/// predicate: [ServiceDay.belongsToToday]. These are the cases that broke it. +/// +/// ── What actually went wrong ── +/// +/// `ServiceDay.timeKeys` used to read `deliveredat`, `updatedat` and +/// `createdat` — three field names the backend does not send. A booking +/// finished yesterday comes back from `GET /miler/bookings` carrying +/// `pickedtime` and `updatedon`, neither of which was looked at, so +/// [ServiceDay.of] returned `''`, the predicate took its "undated rows are +/// kept" branch, and yesterday's work was admitted as today's. +/// +/// Two things changed. The key list is now the one `happenedAt` sorts by — the +/// same fact read out of one list instead of two — and an undated row is now +/// **excluded and reported** rather than silently kept. The burden of proof +/// sits with the record: a row that cannot show it belongs to today does not +/// appear on a screen headed Today. +/// +/// Nothing is deleted anywhere. This is a view filter, and [ServiceDay.of] +/// still answers for any caller that legitimately wants history. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + const today = '2026-08-27'; + const yesterday = '2026-08-26'; + + /// A row as the backend sends it: no `completedday`/`skippedday` stamp, + /// because it has never been through either local store. This is the shape + /// that used to slip through. + Map apiRow({ + required String id, + required String status, + required String at, + String key = 'pickedtime', + }) => {'orderid': id, 'orderstatus': status, key: at}; + + group('yesterday does not appear on today', () { + test('a Delivered booking finished yesterday is excluded', () { + final row = apiRow( + id: 'D-1', + status: 'delivered', + // The false trailing `Z` the backend really sends — IST wall-clock in + // a naive Postgres column, marshalled with a zone marker it never had. + at: '2026-08-26T14:20:00Z', + ); + + expect(stopStatusOf(row).isDelivered, isTrue); + expect(ServiceDay.of(row), yesterday); + expect(ServiceDay.belongsToToday(row, now: today), isFalse); + }); + + test('a Skipped stop parked yesterday is excluded', () { + // The skipped store stamps its own records, so this carries the + // authoritative pair — and it still must not show today. + final row = { + 'orderid': 'S-1', + 'orderstatus': 'skipped', + 'skippedat': '2026-08-26T19:05:00.000', + 'skippedday': yesterday, + }; + + expect(stopStatusOf(row).isSkipped, isTrue); + expect(ServiceDay.of(row), yesterday); + expect(ServiceDay.belongsToToday(row, now: today), isFalse); + }); + + test('a Cancelled booking from yesterday is excluded', () { + final row = apiRow( + id: 'C-1', + status: 'cancelled', + at: '2026-08-26T09:00:00Z', + key: 'updatedon', + ); + + expect(stopStatusOf(row).isCancelled, isTrue); + expect(ServiceDay.of(row), yesterday); + expect(ServiceDay.belongsToToday(row, now: today), isFalse); + }); + + test('the three slices are filtered by one rule, not three', () { + // Delivered, Skipped and Cancelled all pass through the same boundary + // filter before the page ever slices them, which is what stops the three + // tabs from disagreeing about what "today" means. + final rows = [ + apiRow(id: 'D-1', status: 'delivered', at: '2026-08-26T14:20:00Z'), + apiRow(id: 'S-1', status: 'skipped', at: '2026-08-26T19:05:00Z'), + apiRow(id: 'C-1', status: 'cancelled', at: '2026-08-26T09:00:00Z'), + ]; + + expect( + rows.where((r) => ServiceDay.belongsToToday(r, now: today)), + isEmpty, + ); + }); + }); + + group("today's records are visible", () { + test('a booking finished today is admitted', () { + final row = apiRow( + id: 'D-2', + status: 'delivered', + at: '2026-08-27T11:42:00Z', + ); + + expect(ServiceDay.of(row), today); + expect(ServiceDay.belongsToToday(row, now: today), isTrue); + }); + + test("this session's local completion stamp is proof enough", () { + // `accepted_store.dart` writes `completedat` + `completedday` at the + // moment the rider acts. That pair is the *only* record of a stop the + // backend has not caught up with, and the strict rule must not cost it. + final row = { + 'orderid': 'D-3', + 'orderstatus': 'picked', + 'completedat': '2026-08-27T08:15:00.000', + 'completedday': today, + }; + + expect(ServiceDay.of(row), today); + expect(ServiceDay.belongsToToday(row, now: today), isTrue); + }); + + test("this session's local skip stamp is proof enough", () { + final row = { + 'orderid': 'S-2', + 'orderstatus': 'skipped', + 'skippedat': '2026-08-27T10:02:00.000', + 'skippedday': today, + }; + + expect(ServiceDay.belongsToToday(row, now: today), isTrue); + }); + + test('a day stamp outranks a stale API timestamp on the same row', () { + // The merge in `_load` overlays the local store onto the API row, so both + // can be present. `dayKeys` is read first, which is what makes the + // rider's own action the answer. + final row = { + 'orderid': 'D-4', + 'orderstatus': 'picked', + 'updatedon': '2026-08-26T22:00:00Z', + 'completedday': today, + }; + + expect(ServiceDay.belongsToToday(row, now: today), isTrue); + }); + }); + + group('the day boundary is IST wall-clock, not UTC', () { + // The old `of` did `DateTime.tryParse` then `toLocal()`. On a timestamp + // carrying the false `Z`, that shifted an IST evening by +5:30 — so a stop + // finished at 23:50 was filed under the *next* day and vanished from + // Activity the instant it was completed. Every evening shift lost its last + // stop. `parseStamp` strips the marker and reads the digits the backend + // meant, which is the day the rider would name. + + test('23:50 stays on its own day', () { + final row = apiRow( + id: 'L-1', + status: 'delivered', + at: '2026-08-26T23:50:00Z', + ); + + expect(ServiceDay.of(row), yesterday); + expect(ServiceDay.belongsToToday(row, now: yesterday), isTrue); + expect( + ServiceDay.belongsToToday(row, now: today), + isFalse, + reason: 'a 23:50 stop must not leak into the next day', + ); + }); + + test('00:10 belongs to the new day', () { + final row = apiRow( + id: 'L-2', + status: 'delivered', + at: '2026-08-27T00:10:00Z', + ); + + expect(ServiceDay.of(row), today); + expect(ServiceDay.belongsToToday(row, now: today), isTrue); + expect(ServiceDay.belongsToToday(row, now: yesterday), isFalse); + }); + + test('a stamp with no zone marker reads the same as one with a false Z', () { + final naive = apiRow( + id: 'L-3', + status: 'delivered', + at: '2026-08-26T23:50:00', + ); + final falseZ = apiRow( + id: 'L-4', + status: 'delivered', + at: '2026-08-26T23:50:00Z', + ); + + expect(ServiceDay.of(naive), ServiceDay.of(falseZ)); + }); + }); + + group('undated rows are excluded and reported', () { + test('a historical row carrying no usable timestamp is excluded', () { + final row = { + 'orderid': 'U-1', + 'orderstatus': 'delivered', + }; + + expect(ServiceDay.of(row), isEmpty); + expect( + ServiceDay.belongsToToday(row, now: today), + isFalse, + reason: 'this branch is the hole yesterday came through', + ); + }); + + test('exclusion is announced, not silent', () { + final row = {'orderid': 'U-2', 'orderstatus': 'skipped'}; + final seen = >[]; + + ServiceDay.belongsToToday(row, now: today, onUndated: seen.add); + + expect(seen, [row], reason: 'a data defect worth counting'); + }); + + test('a dated row never reports itself as undated', () { + final row = apiRow( + id: 'U-3', + status: 'delivered', + at: '2026-08-27T11:42:00Z', + ); + var reported = false; + + ServiceDay.belongsToToday( + row, + now: today, + onUndated: (_) => reported = true, + ); + + expect(reported, isFalse); + }); + + test('the fields the backend really sends are all read', () { + // The regression in one line: every one of these was invisible to the + // old key list, and each is enough on its own to date a row. + for (final key in const [ + 'pickedtime', + 'picked_time', + 'deliverytime', + 'updatedon', + 'modifiedon', + 'expected_pickup_time', + ]) { + final row = {key: '2026-08-26T14:20:00Z'}; + expect( + ServiceDay.of(row), + yesterday, + reason: '$key must date a row', + ); + } + }); + }); + + group('the day turns over under an open screen', () { + // `_load` filters against the day as read at fetch time, so the list is + // only ever as fresh as the last fetch. [ServiceDayRollover] is what + // notices that the wall clock has moved past it. + + test('midnight rollover reports the old day as ended', () { + var now = DateTime(2026, 8, 26, 23, 59); + final day = ServiceDayRollover(clock: () => now); + + expect(day.day, yesterday); + expect(day.hasRolled, isFalse); + expect(day.rollIfNeeded(), isFalse, reason: 'still the same day'); + + // The timer fires a second past midnight. + now = DateTime(2026, 8, 27, 0, 0, 1); + + expect(day.hasRolled, isTrue); + expect(day.rollIfNeeded(), isTrue); + expect(day.day, today); + }); + + test("yesterday's rows do not survive the rollover", () { + // What the page does with that answer: it clears state *before* the + // refetch, so a new day starts empty even with no signal. The alternative + // is a rider in a dead spot at 6am reading yesterday's stops under a + // heading that says Today. + var now = DateTime(2026, 8, 26, 22, 0); + final day = ServiceDayRollover(clock: () => now); + + final rows = [ + apiRow(id: 'D-1', status: 'delivered', at: '2026-08-26T14:20:00Z'), + apiRow(id: 'S-1', status: 'skipped', at: '2026-08-26T19:05:00Z'), + apiRow(id: 'C-1', status: 'cancelled', at: '2026-08-26T09:00:00Z'), + ]; + + expect( + rows.where((r) => ServiceDay.belongsToToday(r, now: day.day)).length, + 3, + ); + + now = DateTime(2026, 8, 27, 0, 0, 1); + day.rollIfNeeded(); + + expect( + rows.where((r) => ServiceDay.belongsToToday(r, now: day.day)), + isEmpty, + ); + }); + + test('resume after midnight reports the old day as ended', () { + // The timer case is the one a phone is *least* likely to deliver: both + // platforms suspend timers in the background. This is the path that + // actually runs — the app was backgrounded on Activity at 22:00 and the + // rider opens it again at six. + var now = DateTime(2026, 8, 26, 22, 0); + final day = ServiceDayRollover(clock: () => now); + + now = DateTime(2026, 8, 27, 6, 0); + + expect(day.rollIfNeeded(), isTrue); + expect(day.day, today); + }); + + test('a resume inside the same day changes nothing', () { + var now = DateTime(2026, 8, 27, 9, 0); + final day = ServiceDayRollover(clock: () => now); + + now = DateTime(2026, 8, 27, 17, 30); + + expect(day.rollIfNeeded(), isFalse); + expect(day.day, today); + }); + + test('the check is idempotent, so both triggers are safe', () { + // The timer and the resume listener both land in the same place; whichever + // arrives second must find the day already current rather than clearing + // the list a second time. + var now = DateTime(2026, 8, 26, 23, 59); + final day = ServiceDayRollover(clock: () => now); + now = DateTime(2026, 8, 27, 0, 0, 1); + + expect(day.rollIfNeeded(), isTrue); + expect(day.rollIfNeeded(), isFalse); + }); + + test('the wake lands just past the next local midnight', () { + final day = ServiceDayRollover( + clock: () => DateTime(2026, 8, 26, 23, 50), + ); + + expect(day.untilNextDay, const Duration(minutes: 10, seconds: 1)); + }); + + test('the wake is correct across a month end', () { + // `day + 1` relies on `DateTime` normalising 32 August into 1 September. + final day = ServiceDayRollover( + clock: () => DateTime(2026, 8, 31, 23, 30), + ); + + expect(day.untilNextDay, const Duration(minutes: 30, seconds: 1)); + }); + }); +} diff --git a/test/activity_trips_test.dart b/test/activity_trips_test.dart index 362bea5..8388e71 100644 --- a/test/activity_trips_test.dart +++ b/test/activity_trips_test.dart @@ -135,12 +135,31 @@ void main() { expect(ServiceDay.belongsToToday(row, now: '2026-08-22'), isFalse); }); - test('a record with no date is kept, quietly', () { - // A payload defect is not evidence the work happened yesterday, and it - // is not something to print on the rider's screen either. + test('a record with no date is excluded, and reported', () { + // ── This assertion was inverted, deliberately ── + // + // It used to read `isTrue`: a payload defect was held not to be evidence + // the work happened yesterday, so an undated row was admitted to today. + // That reasoning had the burden of proof backwards. `GET /miler/bookings` + // returns the rider's whole open set, so "the API sent it" says nothing + // about *when* — and this branch turned out to be the hole yesterday's + // finished bookings came through, on a screen whose entire premise is + // that everything on it happened today. + // + // A row that cannot prove it is today's is not shown today. Nothing is + // deleted: [ServiceDay.of] still answers, both local stores keep their + // records, and the backend is untouched. It is a view filter. + // + // Still not something to print on the rider's screen — he can do nothing + // about a backend row with no timestamp — so it goes to the console + // instead of to a badge. See [ServiceDay.logUndated]. final row = {'orderid': 'x', 'orderstatus': 'delivered'}; - expect(ServiceDay.belongsToToday(row), isTrue); + expect(ServiceDay.belongsToToday(row), isFalse); expect(ServiceDay.of(row), ''); + + final reported = >[]; + ServiceDay.belongsToToday(row, onUndated: reported.add); + expect(reported, [row], reason: 'excluded, but never in silence'); }); test('a timestamp is used when no day stamp was written', () { diff --git a/test/app_bar_glass_test.dart b/test/app_bar_glass_test.dart index 3c4f932..c25903c 100644 --- a/test/app_bar_glass_test.dart +++ b/test/app_bar_glass_test.dart @@ -57,7 +57,21 @@ void main() { // One ground now, and everything on it inverts. The wash survives as // `milerGlassSurface()` for the sheets, which sit over content and still // want it. - testWidgets('is solid brand, so every tab shares one chrome', ( + // ── The wash gave way to the brand, and the brand grew a gradient ── + // + // The four tabs wore a 7% frosted brand wash flattened against white while + // Home wore solid brand, so switching tab changed the colour of the top + // 76pt of the phone AND the colour of the status-bar glyphs — which reads + // as arriving in a different app rather than a different page. + // + // One ground now. It is [kBrandGradient] rather than a flat fill: two stops + // about eight per cent apart, enough that the bar reads as a surface with + // light on it and not enough to be seen as a gradient. A colour cannot be a + // gradient, so it rides as `flexibleSpace` — and the fill underneath stays + // the brand red, so a bar that fails to paint its surface comes out the + // right colour rather than transparent. Both halves are asserted, because + // it is the pair that makes the fallback safe. + testWidgets('is one brand ground, so every tab shares one chrome', ( tester, ) async { await pump( @@ -66,12 +80,23 @@ void main() { ); final bar = tester.widget(find.byType(AppBar)); - expect(bar.backgroundColor, ColorConstants.primary); - expect( - bar.flexibleSpace, - isNull, - reason: 'a solid ground needs no surface layered over it', - ); + // ── The fill is transparent, and the surface paints ── + // + // It was the brand as a safety net behind [kBrandGradient]. It cannot be + // one now: the bar's bottom corners are rounded so the page shows through + // them (see [milerBrandSurface]), and an opaque rectangle behind a + // rounded surface fills in exactly the two corners the curve exists to + // open. What must hold is that the bar still *renders* the brand, which + // is the gradient assertion below. + expect(bar.backgroundColor, Colors.transparent); + expect(bar.flexibleSpace, isNotNull); + + final painted = tester + .widgetList(find.byType(DecoratedBox)) + .map((d) => d.decoration) + .whereType() + .where((d) => d.gradient == kBrandGradient); + expect(painted, isNotEmpty, reason: 'the bar does not wear the brand'); }); testWidgets('inverts everything drawn on it', (tester) async { @@ -106,9 +131,10 @@ void main() { const Scaffold(appBar: MilerAppBar(title: 'Activity')), ); - // The wash is a surface, and a surface against an untinted page is - // already an edge. A hairline under it drew that boundary a second time — - // the same doubled-up border this app has been pulling out of its cards. + // The brand ground is a surface, and a surface against an untinted page + // is already an edge. A hairline under it drew that boundary a second + // time — the same doubled-up border this app has been pulling out of its + // cards. final bar = tester.widget(find.byType(AppBar)); expect(bar.bottom, isNull); }); @@ -140,25 +166,17 @@ void main() { ); final bar = tester.widget(find.byType(AppBar)); - expect(bar.backgroundColor, ColorConstants.primary); - expect(bar.flexibleSpace, isNull); + // ── The fill is transparent, and the surface paints ── + // + // It was the brand as a safety net behind [kBrandGradient]. It cannot be + // one now: the bar's bottom corners are rounded so the page shows through + // them (see [milerBrandSurface]), and an opaque rectangle behind a + // rounded surface fills in exactly the two corners the curve exists to + // open. What must hold is that the bar still *renders* the brand, which + // is the gradient assertion below. + expect(bar.backgroundColor, Colors.transparent); + expect(bar.flexibleSpace, isNotNull); expect(bar.bottom, isNull, reason: 'no rule under the bar here either'); }); }); - - // The wash survives as `milerGlassSurface()`, which the SHEETS use — they sit - // over scrolling content and genuinely want a translucent surface, where a - // bar sits over nothing and does not. This still pins what the wash has to - // be, wherever it is used. - test('the wash is a brand tint light enough to carry near-black type', () { - // Flattened against white, which is what the sheets composite it over. - final flat = Color.alphaBlend(ColorConstants.glassRed, Colors.white); - expect( - flat.computeLuminance(), - greaterThan(0.85), - reason: - 'a saturated red surface would force white text onto a mid-tone ' - 'red, the weakest contrast pairing in the palette', - ); - }); } diff --git a/test/arrived_rung_test.dart b/test/arrived_rung_test.dart new file mode 100644 index 0000000..dd29980 --- /dev/null +++ b/test/arrived_rung_test.dart @@ -0,0 +1,220 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/data/accepted_store.dart'; +import 'package:miler/data/api_config.dart'; +import 'package:miler/data/consignment_state.dart'; +import 'package:miler/data/milk_run.dart'; +import 'package:miler/data/service_profile.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// TWO RUNGS THAT WERE NOT THERE +/// +/// Reported from a milk run, and they are the same failure at two ends of the +/// ladder: a rung the rider walked was not a rung the app kept. +/// +/// 1. **Arrived did nothing.** He marked arrived at the kitchen, the row went +/// back to ACCEPTED, and the sheet offered him arrival again. The write +/// was fine. `POST /miler/bookings/:id/reached` answers 200 and leaves the +/// booking on `Miler_Assigned` (request 15), so the rung lived only as a +/// field on an in-memory row — and `_advanceStopWithSheet` finishes with +/// `_fetchQueues()`, which rebuilt that row from the server about a second +/// later. Nothing kept his report, so nothing showed it. +/// +/// 2. **Picked jumped to active.** He slid to collect and the card went +/// straight to ACTIVE without ever showing PICKED, with **Start delivery** +/// never offered. `pickup-complete` in compatibility mode releases the +/// consignment itself, so the stop reached the Deliveries tab already +/// `Out_for_Delivery` — and the release gate read that as "he has set +/// off". +/// +/// The ladder the rider actually walks, and what answers each rung: +/// +/// ``` +/// ACCEPTED the queue, or the accepted store +/// ARRIVED HIS report → the arrived store (nothing else has it) +/// PICKED pickup-complete → the collected store +/// ACTIVE HIS press → the out-for-delivery store +/// DELIVERED deliver +/// ``` +/// +/// Two of the five are the rider's own and cannot be read back from anywhere. +/// That is why they are stored, and it is the whole content of both bugs. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + ServiceProfile.setActive(ServiceProfile.milkMan); + }); + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + /// A stop as the queue hands it over. + Map stop(String backendStatus, {String id = '1001'}) => { + 'orderid': id, + 'pickupid': int.parse(id), + 'sourcename': 'Vidhya Kitchen', + 'orderstatus': ApiConfig.legacyStatusFromNew(backendStatus), + 'pickuplat': 11.01, + 'pickuplon': 76.95, + 'droplat': 11.05, + 'droplon': 76.99, + }; + + group('1 · arrival is kept, because nothing else keeps it', () { + test('the server does not persist it — this is the premise', () { + // Not an assertion about our code: it is the reason the store exists. + // `reached` leaves the booking where it was, so a poll cannot return the + // rung and the app cannot read it back. + expect( + ApiConfig.legacyStatusFromNew('Miler_Assigned'), + isNot('arrived'), + reason: + 'if this ever becomes "arrived" the backend has shipped request ' + '15 and the arrived store should be deleted, not kept', + ); + }); + + test('an arrival survives being written and read back', () async { + await addArrivedOrderIds(['1001']); + expect(await getArrivedOrderIds(), {'1001'}); + }); + + test( + 'the rung it produces is ARRIVED, over a queue that says accepted', + () async { + await addArrivedOrderIds(['1001']); + final arrived = await getArrivedOrderIds(); + + // What `_fetchQueues` does with the set: the row comes back on + // Miler_Assigned and the rider's own record puts it back on the rung. + final row = stop('Miler_Assigned'); + expect( + stopStatusFromRaw(row['orderstatus']), + isNot(StopStatus.arrived), + reason: 'the server really does hand it back one rung down', + ); + + if (arrived.contains(row['orderid'])) row['orderstatus'] = 'arrived'; + + expect( + MilkRun.stageOf( + row, + acceptedIds: {'1001'}, + collectedIds: const {}, + outForDeliveryIds: const {}, + ), + StopStatus.arrived, + ); + }, + ); + + test('and it is dropped the moment the stop is collected', () async { + await addArrivedOrderIds(['1001']); + await removeArrivedOrderIds(['1001']); + expect( + await getArrivedOrderIds(), + isEmpty, + reason: + 'a local record that outlives its rung pins tomorrow\'s row to ' + 'yesterday\'s arrival', + ); + }); + + test('one kitchen\'s arrival does not move another\'s stop', () async { + await addArrivedOrderIds(['1001']); + expect(await getArrivedOrderIds(), isNot(contains('2002'))); + }); + }); + + group('2 · picked is not active until he says so', () { + test('compatibility mode really does release at the counter', () { + // The premise of the second bug, stated as the contract does: a + // hyperlocal booking is `Out_for_Delivery` the moment it is collected. + expect(consignmentStateFromRaw('Out_for_Delivery').isDeliverable, isTrue); + expect( + consignmentStateFromRaw('Out_for_Delivery').needsRelease, + isFalse, + reason: + 'which is exactly why reading needsRelease first skipped the rung ' + '— the consignment does not need releasing, the RIDER has not ' + 'released it', + ); + }); + + test('the card holds at PICKED while the round is unstarted', () { + expect( + MilkRun.stageOf( + stop('Out_for_Delivery'), + acceptedIds: {'1001'}, + collectedIds: {'1001'}, + outForDeliveryIds: const {}, + ), + StopStatus.picked, + ); + }); + + test('and moves to ACTIVE only on his own press', () { + expect( + MilkRun.stageOf( + stop('Out_for_Delivery'), + acceptedIds: {'1001'}, + collectedIds: {'1001'}, + outForDeliveryIds: {'1001'}, + ), + StopStatus.outForDelivery, + ); + }); + + test('a delivered stop is never dragged back down the ladder', () { + // The one direction the local sets must not win in: the server knows + // something the rider's phone does not. + expect( + MilkRun.stageOf( + stop('Delivered'), + acceptedIds: {'1001'}, + collectedIds: {'1001'}, + outForDeliveryIds: const {}, + ), + StopStatus.delivered, + ); + }); + }); + + group('3 · the ladder in order, walked once', () { + test('accepted → arrived → picked → active', () async { + final row = stop('Miler_Assigned'); + final collected = {}; + final released = {}; + + StopStatus rung() => MilkRun.stageOf( + row, + acceptedIds: {'1001'}, + collectedIds: collected, + outForDeliveryIds: released, + ); + + expect(rung(), StopStatus.accepted); + + // He arrives. + await addArrivedOrderIds(['1001']); + row['orderstatus'] = 'arrived'; + expect(rung(), StopStatus.arrived); + + // He collects. pickup-complete converts and — in compatibility mode — + // releases; the rider's rung is still PICKED. + await removeArrivedOrderIds(['1001']); + row['orderstatus'] = 'picked'; + row['consignmentstatus'] = 'Out_for_Delivery'; + collected.add('1001'); + expect(rung(), StopStatus.picked); + expect(await getArrivedOrderIds(), isEmpty); + + // He slides to start the ride. + released.add('1001'); + expect(rung(), StopStatus.outForDelivery); + }); + }); +} diff --git a/test/arrived_stays_on_home_test.dart b/test/arrived_stays_on_home_test.dart new file mode 100644 index 0000000..b9bd8a1 --- /dev/null +++ b/test/arrived_stays_on_home_test.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/views/Dashboard/home/trip_card.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// A STOP HE IS STANDING AT MUST NOT LEAVE THE SCREEN +/// +/// Reported from a live run: the rider marked **Arrived**, the console updated +/// correctly — and the stop vanished off Home. Deliveries did not have it +/// either, and could not: arrival is a *pre-pickup* rung, and the delivery tab +/// only takes work once it is collected. The stop existed in neither place. +/// +/// ── What caused it ── +/// +/// Nothing on the backend. [StopState.arrived] and [StopState.active] used to +/// be one value, and `TripCard._showsOnHome` — like a dozen other sites — asked +/// `state == StopState.active` to mean *is he at the counter*. Splitting the +/// two apart so the row could say **Arrived** instead of **Active** left every +/// one of those comparisons answering `false` for the new rung. +/// +/// The visibility filter was the one the rider could see. The others were just +/// as broken and quieter: the selection bar offered *Accept* for a stop he was +/// already at, the card stopped building its **Mark as picked** button, and the +/// group he was working no longer opened by default. +/// +/// These pin the rung end to end — the predicate that now answers the question +/// once ([StopStateX.isAtSource]), and the screen behaviour that regressed. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + Map meal(String id, String who, {String? status}) => { + 'orderid': id, + 'type': 'delivery', + 'step': int.parse(id), + 'pickupcustomer': who, + 'kitchenid': 'K1', + 'kitchenname': 'Vidhya Kitchen', + 'sourcename': 'Vidhya Kitchen', + 'pickupcontactno': '9787698259', + 'pickupaddress': '4 Cross, RS Puram, Coimbatore', + 'dropaddress': '12 SNS Colony, Peelamedu, Coimbatore 641004', + 'Quantity': '1', + if (status != null) 'orderstatus': status, + }; + + Future pumpHome( + WidgetTester tester, + List> stops, + ) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + tester.view.physicalSize = const Size(390 * 3, 2600 * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: TripCard( + trip: Trip.fromStops(id: 'run', stops: stops), + acceptedIds: const {}, + selectedIds: const {}, + onToggleSelect: (_) {}, + onSelectAll: (_) {}, + onStopTap: (_) {}, + onCallStop: (_) {}, + ), + ), + ), + ), + ), + ); + // Not `pumpAndSettle`: an arrived row is a **live** row, and [LiveMark] + // pulses for as long as it is on screen — settling waits for an animation + // that is never going to stop. Two pumps lay the frame out and let the + // pulse start, which is all these assertions need. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + } + + group('1 · the predicate that answers it once', () { + test('arrived and active both mean "he is at the source"', () { + expect(StopState.arrived.isAtSource, isTrue); + expect(StopState.active.isAtSource, isTrue); + }); + + test('and nothing else does', () { + for (final other in const [ + StopState.pending, + StopState.accepted, + StopState.collected, + StopState.done, + StopState.skipped, + StopState.rejected, + ]) { + expect( + other.isAtSource, + isFalse, + reason: '$other must not read as standing at the counter', + ); + } + }); + + test('an arrived stop is committed work, not an open offer', () { + // It rode in on `active` and was dropped by the same split. A rider who + // has reported an arrival has plainly committed to the stop. + expect(StopState.arrived.isCommitted, isTrue); + expect(StopState.pending.isCommitted, isFalse); + }); + }); + + group('2 · the screen behaviour that regressed', () { + testWidgets('an arrived stop stays on Home', (tester) async { + await pumpHome(tester, [ + meal('1', 'Joe Mathew', status: 'arrived'), + meal('2', 'Arun Prakash'), + ]); + + expect( + find.text('Joe Mathew'), + findsOneWidget, + reason: + 'the rider is standing at this counter — it cannot leave Home, ' + 'and Deliveries will not take it until it is collected', + ); + }); + + testWidgets('it is still there beside an untouched stop', (tester) async { + // Guards the opposite overcorrection: making arrived visible must not + // have cost the ordinary rows their place in the run. + await pumpHome(tester, [ + meal('1', 'Joe Mathew', status: 'arrived'), + meal('2', 'Arun Prakash'), + ]); + + expect(find.text('Joe Mathew'), findsOneWidget); + expect(find.text('Arun Prakash'), findsOneWidget); + }); + + testWidgets('a collected stop still leaves — that one is not a bug', ( + tester, + ) async { + // The filter's real job, asserted so a fix for the disappearance cannot + // quietly turn into "nothing ever leaves Home". + await pumpHome(tester, [ + meal('1', 'Joe Mathew', status: 'picked'), + meal('2', 'Arun Prakash'), + ]); + + expect(find.text('Joe Mathew'), findsNothing); + expect(find.text('Arun Prakash'), findsOneWidget); + }); + }); +} diff --git a/test/card_density_test.dart b/test/card_density_test.dart index 6a4f8fb..d674b5a 100644 --- a/test/card_density_test.dart +++ b/test/card_density_test.dart @@ -86,13 +86,27 @@ void main() { // // So: no rule, no disclosure, one card that is always in the state the // rider previously had to discover. That costs height, and the budget says - // how much — enough for a head, a divider and one row of four figures, and + // how much — enough for a head, a bar, and the four facts two-by-two, and // not enough for a fifth thing to be added without a decision. + // + // ── 190 → 245 ── + // + // Raised deliberately, once, when the four journey figures came out of the + // fold and onto the face. It buys the thing the fold was costing: a rider + // could not see the day's duration, distance, parcel count or cash without + // pressing something, and the press was rare enough that the figures were + // effectively shipped and unreachable. + // + // The 55 points are paid for elsewhere in the same change — the header + // collapses to 52 on scroll where it used to be a permanent 160, and the + // run's ratio came off this card because the header states it. The guard + // stays because the failure mode is unchanged: a card that grows a line at + // a time until the route is below the fold again. testWidgets('stays within the card budget', (tester) async { final h = await briefHeight(tester); expect( h, - lessThan(190), + lessThan(245), reason: 'measured $h — the brief is the permanent furniture above the ' 'route, so every point of it is charged at every scroll position', @@ -121,46 +135,41 @@ void main() { ), ); - // ── Reversed, on an explicit product call ── + // ── Reversed twice, and this is the third position ── // - // This once required the four figures ABSENT until tapped; then it was - // flipped to require them present, on the argument that a once-a-shift - // read behind a chevron nobody presses is information the app has and - // the rider does not. That argument is about *discoverability* and it - // was answered by a measurement: on a 390×844 phone the four columns - // plus the trip tabs plus the app bar reach the fold before the first - // kitchen, so Home opened on a dashboard about the run instead of on - // the run. + // The four figures were once required ABSENT until tapped, on a + // measurement: four columns plus the trip tabs plus the app bar reached + // the fold before the first kitchen, so Home opened on a dashboard about + // the run instead of on the run. // - // The founder's call is that the figures are reference material and the - // route is the screen. What stays out is the progress face — stops left, - // and cash in hand — which are the two a rider acts on mid-shift. + // What changed is the space, not the argument. The header collapses now, + // the tabs are a row of pills rather than a strip, and the figures are + // two-by-two rather than four across — so the same information costs + // about half the vertical it did when it was banished, and the fold it + // was pushing the route past is no longer where it was. + // + // So they are on the face, in sentence case, with the run's ratio taken + // OFF the card entirely — the header states that now. for (final label in const [ - 'DURATION', - 'DISTANCE', - 'PARCELS', - 'PAYMENT', + 'Duration', + 'Distance', + 'Parcels', + 'Payment', ]) { expect( find.text(label), - findsNothing, - reason: '"$label" is reference; it is one tap away, not permanent', + findsOneWidget, + reason: '"$label" is on the face, not one tap away', ); } - // And the face itself is still out, unasked. + // The face's own headline is still the thing a rider acts on. expect(find.textContaining('stop'), findsWidgets); - // One tap brings the whole set back. + // The fold keeps what it is actually for: the shift and slot windows, + // read once in the morning. await tester.tap(find.byType(TripBriefStrip)); await tester.pumpAndSettle(); - for (final label in const ['DURATION', 'DISTANCE', 'PARCELS']) { - expect( - find.text(label), - findsOneWidget, - reason: '$label after the tap', - ); - } expect( find.text('CURRENT SHIFT'), findsNothing, @@ -217,11 +226,18 @@ void main() { }); }); - group('Trip tabs', () { - testWidgets('fit in one compact row', (tester) async { + // ── The trip selector moved into the header ── + // + // Its height and its three-slot rule are pinned in `trip_tabs_pinned_test`, + // against the widget's own declared height rather than against a sliver + // extent it no longer has. What is left here is the density guard: it is + // chrome the rider sees at every scroll position, so its height is charged + // continuously and must not creep. + group('Trip selector', () { + testWidgets('stays inside its own height budget', (tester) async { await pump( tester, - TripTabs( + TripSelector( trips: [ Trip.fromStops(id: 't1', stops: [stop('A')]), Trip.fromStops(id: 't2', stops: [stop('B')]), @@ -231,37 +247,25 @@ void main() { ), ); - final h = tester.getSize(find.byType(TripTabs)).height; - // ── This bound was raised deliberately, and it cost something ── - // - // The strip went from one line (~41pt) to a segmented control carrying a - // label, a status line and a running-indicator (~70pt). That is ~30pt of - // *permanent* height, and it is worse than it sounds because the bar is - // now a pinned sliver: it is on screen at every scroll position, not just - // at the top. - // - // What it buys is the separation of "the trip I am looking at" from "the - // trip that is running", which the one-line version could not express at - // all, plus per-trip progress in words rather than an ambiguous bar. That - // is a real trade, taken on purpose — but the guard stays, because the - // failure mode here is a strip that keeps growing a line at a time until - // it crowds out the route it is supposed to be navigating. + final h = tester.getSize(find.byType(TripSelector)).height; + // The old segmented strip carried a numbered disc, the word "Trip" and a + // status line, and came to ~70pt. This carries a numeral and a bar, and + // is bigger *per target* at the same overall height — the budget did not + // move, the content did. expect( h, lessThan(80), reason: - 'the tab strip is pinned furniture — it is on screen at ' - 'every scroll position, so its height is charged continuously', + 'the selector is header furniture — it is on screen at every ' + 'scroll position, so its height is charged continuously', ); expect(tester.takeException(), isNull); }); - testWidgets('still show three slots before the hub assigns them', ( - tester, - ) async { + testWidgets('names three trips, and says only that', (tester) async { await pump( tester, - TripTabs( + TripSelector( trips: [ Trip.fromStops(id: 't1', stops: [stop('A')]), ], @@ -269,9 +273,13 @@ void main() { onSelect: (_) {}, ), ); - expect(find.text('Trip 1'), findsOneWidget); - expect(find.text('Trip 2'), findsOneWidget); - expect(find.text('Trip 3'), findsOneWidget); + for (final n in ['Trip 1', 'Trip 2', 'Trip 3']) { + expect(find.text(n), findsOneWidget); + } + // The run's count belongs to the card below, in type four times the + // size. A filter says what it filters to. See [TripSelector]. + expect(find.text('0 / 1'), findsNothing); + expect(find.text('Not set'), findsNothing); }); }); @@ -599,7 +607,7 @@ void main() { ); }); - testWidgets('only on START · HUB, never on RETURN · HUB', (tester) async { + testWidgets('only on START · BASE, never on RETURN · BASE', (tester) async { await pump( tester, TripCard( @@ -614,16 +622,26 @@ void main() { ); expect( tester.getRect(find.text('Map')).top, - lessThan(tester.getRect(find.text('RETURN · HUB')).top), + lessThan(tester.getRect(find.text('RETURN · BASE')).top), ); }); }); - group('Trip tab states', () { + // ── The per-segment colour test is gone with the word it probed ── + // + // It found each segment by the shared text "Trip" and read that Text's + // colour. There is no shared word any more — a segment is a numeral and a + // bar — and what the test was really guarding, that the selected trip reads + // as picked, is now carried by the travelling marker rather than by ink on a + // noun. `trip_tabs_pinned_test` measures the marker against the numeral it + // belongs to, which is the same guarantee stated in the terms the control + // now uses. + + group('Trip selector marker', () { Future pumpTabs(WidgetTester tester, {required int selected}) async { await pump( tester, - TripTabs( + TripSelector( trips: [ Trip.fromStops(id: 't1', stops: [stop('A')]), Trip.fromStops(id: 't2', stops: [stop('B')]), @@ -634,58 +652,7 @@ void main() { ); } - Color colourOf(WidgetTester tester, String label) => - tester.widget(find.text(label)).style!.color!; - - testWidgets('the selected tab takes the brand accent', (tester) async { - // The bug: the pill moved but the label stayed slate, so standing on - // Trip 2 looked no different from Trip 2 sitting idle. - await pumpTabs(tester, selected: 1); - expect( - colourOf(tester, 'Trip 2'), - ColorConstants.primary, - reason: 'the tab the rider is on must read as picked', - ); - expect(colourOf(tester, 'Trip 1'), isNot(ColorConstants.primary)); - - await pumpTabs(tester, selected: 0); - expect(colourOf(tester, 'Trip 1'), ColorConstants.primary); - expect( - colourOf(tester, 'Trip 3'), - isNot(ColorConstants.primary), - reason: 'an unassigned slot stays quiet', - ); - }); - - testWidgets('the strip stays compact after the state work', (tester) async { - await pumpTabs(tester, selected: 0); - final h = tester.getSize(find.byType(TripTabs)).height; - expect( - h, - lessThan(66), - reason: - 'measured $h — the segmented strip is pinned furniture, so ' - 'every point of it is charged at every scroll position', - ); - }); - }); - - group('Trip tab selection marker', () { - Future pumpTabs(WidgetTester tester, {required int selected}) async { - await pump( - tester, - TripTabs( - trips: [ - Trip.fromStops(id: 't1', stops: [stop('A')]), - Trip.fromStops(id: 't2', stops: [stop('B')]), - ], - selectedIndex: selected, - onSelect: (_) {}, - ), - ); - } - - /// Same tree, but leaves the pill mid-flight so it can be sampled. + /// Same tree, but left mid-flight so the marker can be sampled. Future pumpTabsNoSettle( WidgetTester tester, { required int selected, @@ -696,7 +663,7 @@ void main() { builder: (_, _) => MaterialApp( home: Scaffold( body: SingleChildScrollView( - child: TripTabs( + child: TripSelector( trips: [ Trip.fromStops(id: 't1', stops: [stop('A')]), Trip.fromStops(id: 't2', stops: [stop('B')]), @@ -714,128 +681,101 @@ void main() { /// The travelling selection marker. /// - /// Found by key, not by colour. It moved out of the segments and into the - /// pill when it was made to travel, and colour cannot identify it any more: - /// crossing an unassigned trip it is deliberately grey rather than the brand - /// red, so a colour finder loses it exactly during the journey these tests - /// exist to measure. - Rect underline(WidgetTester tester) { - final marks = find.byKey(TripTabs.markerKey); + /// Found by key, not by colour. It moved out of the segments and into a + /// single travelling object when it was made to travel, and colour cannot + /// identify it: the segments themselves fade between two inks as it passes, + /// so a colour finder loses it exactly during the journey these tests exist + /// to measure. + Rect marker(WidgetTester tester) { + final marks = find.byKey(TripSelector.markerKey); expect( marks, findsOneWidget, - reason: 'exactly one tab may be marked at a time', + reason: 'exactly one trip may be marked at a time', ); return tester.getRect(marks); } + /// One tab's label. + Rect numeral(WidgetTester tester, int slot) => + tester.getRect(find.text('Trip ${slot + 1}')); + // The marker followed a separate "this trip is running" signal for a while, - // which left Trip 1 permanently underlined while the rider stood on Trip 2 — - // so a tap appeared to do nothing. The marker belongs to selection. - testWidgets('the underline sits under the selected tab', (tester) async { - await pumpTabs(tester, selected: 0); - final first = underline(tester); - final trip1 = tester.getRect(find.text('Trip 1')); - expect( - first.center.dx, - closeTo(trip1.center.dx, 2), - reason: 'the marker should be under Trip 1', - ); - }); - - testWidgets('and travels when a different tab is picked', (tester) async { - await pumpTabs(tester, selected: 0); - final before = underline(tester).center.dx; - + // which left trip 1 permanently marked while the rider stood on trip 2 — so + // a tap appeared to do nothing. The marker belongs to selection. + testWidgets('the marker sits under the selected pill', (tester) async { await pumpTabs(tester, selected: 1); - await tester.pumpAndSettle(); - final after = underline(tester).center.dx; - final trip2 = tester.getRect(find.text('Trip 2')); - - expect( - after, - greaterThan(before), - reason: - 'the marker moved from $before to $after — it must follow ' - 'the selection rightward to Trip 2', - ); - expect(after, closeTo(trip2.center.dx, 2)); + final m = marker(tester); + final second = numeral(tester, 1); + // A short centred rule, not a full-width underline — so it is centred on + // its pill rather than spanning it. + expect(m.center.dx, closeTo(second.center.dx, 6)); + expect(m.top, greaterThan(second.bottom)); }); - testWidgets('Trip 3 → Trip 1 slides across Trip 2 rather than jumping', ( + testWidgets('and travels when a different segment is picked', ( tester, ) async { - // The pill used to belong to each segment: one faded out on the right - // while a *different* one faded in on the left, so nothing ever occupied - // the space between and there was no object to cross Trip 2 with. - await pumpTabs(tester, selected: 2); - await tester.pumpAndSettle(); - final trip2 = tester.getRect(find.text('Trip 2')); - final start = underline(tester).center.dx; - expect( - start, - greaterThan(trip2.center.dx), - reason: 'sanity: the marker starts to the right of Trip 2', - ); + await pumpTabs(tester, selected: 0); + final first = marker(tester).center.dx; + + await pumpTabs(tester, selected: 2); + final third = marker(tester).center.dx; + + expect( + third, + greaterThan(first), + reason: 'the marker did not move with the selection', + ); + }); + + testWidgets('3 → 1 slides across 2 rather than jumping', (tester) async { + // The regression this whole mechanism exists for: the marker used to be + // each segment's own fill, so crossing two slots faded one out on the + // right and a different one in on the left. Nothing ever occupied the + // space between. + await pumpTabs(tester, selected: 2); + final middle = numeral(tester, 1); - // `pumpTabs` pumps and settles; drive the frames by hand instead so the - // journey can be sampled. await pumpTabsNoSettle(tester, selected: 0); final samples = []; - for (var elapsed = 0; elapsed < 420; elapsed += 20) { + for (var i = 0; i < 20; i++) { await tester.pump(const Duration(milliseconds: 20)); - samples.add(underline(tester).center.dx); + samples.add(marker(tester).center.dx); } - final half = trip2.width / 2; + final half = middle.width; expect( - samples.any((x) => (x - trip2.center.dx).abs() < half), + samples.any((x) => (x - middle.center.dx).abs() < half * 3), isTrue, - reason: 'the marker never passed over Trip 2; samples: $samples', + reason: 'the marker never passed over tab 2; samples: \$samples', ); for (var i = 1; i < samples.length; i++) { expect( samples[i], lessThanOrEqualTo(samples[i - 1] + 0.5), - reason: 'the marker moved backwards at sample $i: $samples', + reason: 'the marker moved backwards at sample \$i: \$samples', ); } - - await tester.pumpAndSettle(); - expect( - underline(tester).center.dx, - closeTo(tester.getRect(find.text('Trip 1')).center.dx, 2), - ); }); - testWidgets('the pill is the brand tint, not a white card', (tester) async { - // It was a white pill with a drop shadow, which made this the one - // selected-state in the app that was not the brand colour — the two - // controls a rider uses most looked like they came from different apps. - await pumpTabs(tester, selected: 0); - - final fills = tester - .widgetList(find.byType(Container)) - .map((c) => c.decoration) - .whereType() - .toList(); - - final glass = ColorConstants.primary.withValues(alpha: 0.12); - expect( - fills.map((d) => d.color), - contains(glass), - reason: - 'the selection pill should be the light glass red the bottom ' - 'nav bar uses', - ); - expect( - fills.where( - (d) => d.color == Colors.white && (d.boxShadow?.isNotEmpty ?? false), - ), - isEmpty, - reason: 'no raised white pill should remain', - ); + testWidgets('exactly one marker, whatever is selected', (tester) async { + // ── This assertion has been on both sides, and the principle held ── + // + // It began as "no raised white pill" — the bar was bare words with a + // travelling tint behind them, and a white card would have been the + // loudest object on a page whose brief was that containers recede. Then + // the bar became a segmented control on the canvas, and the pill was the + // design rather than the bug. It is now a white marker on the brand + // header, where white is simply the only mark that reads. + // + // What has to hold through all three is the part that always did: **one** + // mark of selection, never three. + for (final selected in const [0, 1, 2]) { + await pumpTabs(tester, selected: selected); + expect(find.byKey(TripSelector.markerKey), findsOneWidget); + } }); }); @@ -936,10 +876,23 @@ void main() { // chip, and there is no bound on how many of those a shift produces. Six // stops in, the rider was scrolling past six rows he could do nothing with to // reach the one he could. They move to the Activity tab — but the *trip* - // keeps them, because the brief's remaining count and its parcel figure are - // counted from it. + // object keeps them, because the numbering and the progress head are + // measured against a total that must not move. + // + // ── What the brief's four figures do NOT do ── + // + // They used to be counted from that same total, so duration, distance and + // the parcel count described the slot as assigned and never moved while the + // rider worked it. `20 stops left` counted down above four figures that + // still described the whole morning, including everything already collected + // and handed to Deliveries. + // + // They are counted from [Trip.outstanding] now — the collections still owed + // — so the two halves of the card finally answer the same question. The + // trip's own totals are untouched, which is what the first two assertions + // below are for. group('completed stops', () { - testWidgets('are not listed, but still count towards the trip', ( + testWidgets('leave the list and the figures, but not the trip', ( tester, ) async { final trip = Trip.fromStops( @@ -971,34 +924,44 @@ void main() { // a trip of one with none done both say. The parcel count can only reach // 2 if the finished stop is still part of the trip, which is the whole // contract here. - // The hero sets the figure and its caption as two texts — numeral at - // headline scale, words as its caption — so the claim is asserted on - // the pair rather than on one sentence. + // The headline is one string now — `1 stop left` — rather than a numeral + // at headline scale beside its caption at body scale. Two sizes and a + // manual baseline nudge to make a figure and its unit look like one + // thing, when they are one thing. expect( find.descendant( of: find.byType(TripBriefStrip), - matching: find.text('1'), + matching: find.text('1 stop left'), ), findsOneWidget, ); - expect(find.text('stop left'), findsOneWidget); final singleStop = Trip.fromStops(id: 't2', stops: [stop('B')]); expect( trip.totalParcels, greaterThan(singleStop.totalParcels), - reason: 'the collected stop still contributes its load', + reason: 'the TRIP still carries the collected stop and its load', ); - // The parcel figure is reference now, so it is behind the fold — but it - // must still be the *trip's* total rather than the visible one, which is - // the contract this test exists for. + + // ── But the brief states what is left, not what the slot held ── + // + // Stop A is `picked`, so it is across the boundary and belongs to + // Deliveries. One collection is still owed, and the parcel figure says + // one — the trip's own total of two is deliberately NOT what is drawn. + final outstanding = trip.outstanding(); + expect( + outstanding.totalParcels, + lessThan(trip.totalParcels), + reason: 'a collected stop is no longer work in front of the rider', + ); + await openBrief(tester); expect( find.descendant( of: find.byType(TripBriefStrip), - matching: find.text('${trip.totalParcels}'), + matching: find.text('${outstanding.totalParcels}'), ), findsOneWidget, - reason: 'and the card states that total, not the visible one', + reason: 'the card states the outstanding count, not the slot total', ); }); @@ -1041,12 +1004,16 @@ void main() { // // The brief and the route now stand on [MilerPanel], layer 1 of the // surface ladder, because Home's ground is the canvas and nothing white - // was standing on it. The panel owns the inset: `panelGutter` 12 outside - // the white plus `panelPad` 14 inside it, and `TripCard` no longer adds a - // gutter of its own. So both land on 26, and — the part that matters — - // they land on the *same* number. `home_gutter_test` holds them together. - const homeGutter = 26.0; - const stopRowInset = 26.0; + // was standing on it. The panel owns the inset, and `TripCard` no longer + // adds a gutter of its own — so both land on the *same* number, which is + // the part that matters. `home_gutter_test` holds them together. + // + // 26 → 29: `panelGutter` 12 outside the white, `panelPad` 16 inside it, + // and one point for the hairline the card carries now (see [Narrative] — + // the border is half of what buys a card its separation on the lighter + // ground). A pixel of border is a pixel of inset. + const homeGutter = 29.0; + const stopRowInset = 29.0; testWidgets('the summary cards and the stop rows each keep their inset', ( tester, @@ -1401,15 +1368,23 @@ void main() { ); }); - // ── The disclosure has to LOOK like one ── + // ── The header's right edge belongs to the journey ── // - // The chevron was removed once when the header became the toggle, and its - // signal went with it: a collapsed kitchen showed nothing that said "there - // is a list under me". It is back as a passive indicator inside the - // header's own gesture — one arena, one owner — and this holds the three - // facts that make it honest: it exists, it turns with the state, and a - // flat stop (whose tap navigates instead) wears the pointing variant. - testWidgets('the header wears a chevron that turns with the state', ( + // A chevron has been on and off this header three times, and the argument + // each way was about what the right edge was *for*. It came back once as a + // passive indicator, because a collapsed kitchen showed nothing that said + // "there is a list under me". + // + // The corner is not empty any more: the distance and the ride time live + // there, and they are the reason a rider looks at it — down a multi-kitchen + // run they form the column that answers *which of these is nearest*. A + // glyph between them and the card's edge pushed that column ~24 points off + // the margin so it lined up with nothing. + // + // What is asserted now is the trade: no fold mark on a group header, and + // the journey column sitting on the card's own right edge. The fold state + // is still legible — an open group has its orders on screen under it. + testWidgets('the group header ends on its journey, not a chevron', ( tester, ) async { ServiceProfile.setActive(ServiceProfile.milkMan); @@ -1426,43 +1401,45 @@ void main() { 'kitchenname': 'Vidhya Kitchen', 'Quantity': 1, 'orderstatus': 'new', + 'pickuplat': 11.04, + 'pickuplon': 76.98, }, ]; await pump( tester, TripCard( trip: Trip.fromStops(id: 't', stops: stops), + riderLat: 11.0168, + riderLng: 76.9558, onToggleSelect: (_) {}, onStopTap: (_) {}, ), ); - // Scoped to the route. The trip brief above it also folds, and since - // Home moved to one icon family both disclosure marks are the same - // glyph — correctly, they mean the same thing — so a bare `byIcon` here - // matches the brief's chevron as well and stops being a statement about - // the group header at all. - final chevron = find.descendant( - of: find.byType(RouteTimeline), - matching: find.byIcon(LucideIcons.chevronDown), + // Scoped to the route: the trip brief above it still folds and wears the + // same glyph, so an unscoped finder would be a claim about the whole card. + expect( + find.descendant( + of: find.byType(RouteTimeline), + matching: find.byIcon(LucideIcons.chevronDown), + ), + findsNothing, + reason: 'the corner is the journey column, not a fold mark', ); - expect(chevron, findsOneWidget); - double turns() => tester - .widget( - find.ancestor(of: chevron, matching: find.byType(AnimatedRotation)), - ) - .turns; - - final openTurns = turns(); + // And the group still folds — the gesture was always the header's, and + // removing the glyph must not have taken the behaviour with it. + // Measured by height rather than by the rows' presence: the body + // collapses through an `AnimatedSize`, which keeps its child mounted at + // zero height, so a `findsNothing` here would be asserting an + // implementation detail of the animation rather than the fold. + final open = tester.getSize(find.byType(RouteTimeline)).height; await tester.tap(find.text('Vidhya Kitchen')); await tester.pumpAndSettle(); expect( - turns(), - isNot(openTurns), - reason: - 'the chevron must turn when the list opens or closes — a ' - 'static disclosure mark is a printed one', + tester.getSize(find.byType(RouteTimeline)).height, + lessThan(open), + reason: 'tapping the header still closes its orders', ); }); diff --git a/test/consignment_state_test.dart b/test/consignment_state_test.dart index f0f93f6..8e8d834 100644 --- a/test/consignment_state_test.dart +++ b/test/consignment_state_test.dart @@ -106,8 +106,9 @@ void main() { test('a genuine hub hold stays blocked — the logistics guard', () { // The guard the brief requires be kept: a consignment inside the hub's // half of the network is not this rider's to hand over. + // + // `Created` is deliberately NOT in this list — see the test below. for (final s in const [ - ConsignmentState.created, ConsignmentState.inwardedAtHub, ConsignmentState.tripsheetLoaded, ConsignmentState.inTransit, @@ -118,6 +119,36 @@ void main() { } }); + test('`Created` is the rider\'s own parcel, not a hub hold', () { + // The bug this pins: `Created` was in the hub list, so sliding **Start + // ride** on a freshly converted consignment answered "This parcel is + // with the hub — it will be delivered from there, not by you" about a + // bag on the rider's own back, and nothing on that screen cleared it. + // + // The backend writes `Created` at the pivot for hub-routed work + // (`next_action: inward_at_hub`) and holds it there transiently while + // hyperlocal work is routed. The hub has not taken it in either way. + const s = ConsignmentState.created; + expect(s.awaitsHub, isFalse, reason: 'the hub has never seen it'); + expect(s.awaitsHubInward, isTrue); + expect(ConsignmentGate.gateFor(s), DeliverGate.awaitingInward); + + // Still not deliverable: `deliver` only accepts `Out_for_Delivery`. The + // fix changes which sentence the rider is shown, never what is claimed. + expect(s.isDeliverable, isFalse); + expect(s.needsRelease, isFalse); + + // And no other state may drift into the new rung. + for (final other in ConsignmentState.values) { + if (other == ConsignmentState.created) continue; + expect( + other.awaitsHubInward, + isFalse, + reason: '${other.name} is not the pre-inward rung', + ); + } + }); + test('closed work is refused without blaming the rider', () { for (final s in const [ ConsignmentState.cancelled, diff --git a/test/delivery_details_test.dart b/test/delivery_details_test.dart index 4bcad7c..14f6799 100644 --- a/test/delivery_details_test.dart +++ b/test/delivery_details_test.dart @@ -7,6 +7,7 @@ import 'package:miler/data/order_events.dart'; import 'package:miler/data/service_profile.dart'; import 'package:miler/views/Dashboard/activity/delivery_details_page.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/narrative.dart'; /// ───────────────────────────────────────────────────────────────────────── /// DELIVERY DETAILS — one stop, the whole story @@ -111,6 +112,20 @@ void main() { } } + /// Opens the full journey log. + /// + /// The rungs are behind **View full log** now: the panel leads with a + /// one-line strip of the same ladder, and the detailed version — clocks, + /// places, addresses, the gaps between them — is one tap under it. Every + /// assertion about *which* rungs exist and in what order is unchanged; what + /// changed is that reading them costs a tap, so the tests take it. + Future openLog(WidgetTester tester) async { + final link = find.text('View full log'); + if (link.evaluate().isEmpty) return; + await tester.tap(link); + await tester.pumpAndSettle(); + } + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); group('the summary', () { @@ -124,7 +139,11 @@ void main() { // stop cannot be called one thing on the list and another on the page // that list opens. It appears twice: the summary and the timeline's head // rung, which is the newest event. - expect(find.text('Delivered'), findsNWidgets(2)); + // Once, not twice. The timeline's own copy of the word is behind + // **View full log** now — the strip above it carries `DELIVERED`, and + // the card under it says where — so the only `Delivered` on a closed + // record page is the verdict itself. + expect(find.text('Delivered'), findsOneWidget); final headline = tester.widget(find.text('Delivered').first); expect(headline.style!.color, ColorConstants.acceptGreen); @@ -175,12 +194,15 @@ void main() { testWidgets('runs oldest first, named for the leg', (tester) async { ServiceProfile.setActive(ServiceProfile.milkMan); await pump(tester, record()); + await openLog(tester); // The door arrival is not a rung: it folds into the outcome as a // duration, because "he got there 5m before he handed it over" is a // subtraction, not an event worth its own line. expect(find.text('Arrived at customer'), findsNothing); - expect(find.text('5m at the door'), findsOneWidget); + // Twice with the log open: on the rung, and on the card under the strip + // that restates how the last leg went. + expect(find.text('5m at the door'), findsNWidgets(2)); expect(find.text('Out for delivery'), findsOneWidget); // ── Downwards, like every order-tracking screen ── @@ -199,6 +221,7 @@ void main() { testWidgets('a collection is named for its own leg', (tester) async { await pump(tester, record(type: 'pickup', status: 'picked')); + await openLog(tester); expect(find.text('Picked up'), findsWidgets); // On a collection `arrivedat` IS the counter, so it fills the same rung @@ -224,6 +247,7 @@ void main() { arrived: false, ), ); + await openLog(tester); expect(find.text('Set off'), findsNothing); expect(find.text('Arrived'), findsNothing); @@ -250,6 +274,7 @@ void main() { tester, record(type: 'pickup', status: 'picked', arrived: false), ); + await openLog(tester); expect(find.text('Set off'), findsOneWidget); expect(find.text('Arrived'), findsNothing); }); @@ -280,6 +305,7 @@ void main() { .add(const Duration(minutes: 65)) .toIso8601String(), }); + await openLog(tester); for (final rung in const [ 'Assigned', @@ -335,13 +361,15 @@ void main() { // existed. It must degrade to what it has, not to a page of placeholders. ServiceProfile.setActive(ServiceProfile.milkMan); await pump(tester, record()); + await openLog(tester); expect(find.text('Delivered'), findsNWidgets(2)); // The door arrival is not a rung: it folds into the outcome as a // duration, because "he got there 5m before he handed it over" is a // subtraction, not an event worth its own line. expect(find.text('Arrived at customer'), findsNothing); - expect(find.text('5m at the door'), findsOneWidget); + // The rung, and the card under the strip. + expect(find.text('5m at the door'), findsNWidgets(2)); expect(find.text('Out for delivery'), findsOneWidget); for (final absent in const [ 'Order placed', @@ -363,18 +391,22 @@ void main() { tester, record(status: 'skipped', skipReason: 'Customer unreachable'), ); + await openLog(tester); // The one rung on this page allowed to exist without a timestamp: a // skipped stop has a delivery in its story that did not occur, and saying // so is the point of the record. It is an open ring, not a filled disc, // so it cannot read as a step that is merely pending. - expect(find.text('Delivered'), findsOneWidget); - expect(find.text('Not completed — return visit owed'), findsOneWidget); + // The unreached rung, and the card under the strip that names where the + // story stopped. + expect(find.text('Delivered'), findsNWidgets(2)); + expect(find.text('Not completed — return visit owed'), findsNWidgets(2)); }); testWidgets('a completed stop has nothing left unreached', (tester) async { ServiceProfile.setActive(ServiceProfile.milkMan); await pump(tester, record()); + await openLog(tester); // Twice — the summary and the timeline's last rung — and never a third // time as a step still to come. @@ -388,12 +420,13 @@ void main() { testWidgets('a round draws both ends; a parcel draws one', (tester) async { ServiceProfile.setActive(ServiceProfile.milkMan); await pump(tester, record()); + await openLog(tester); // ── ROUTE is gone; its two addresses moved onto the journey ── // // Tracking said *when*, Route said *where*, and the rider joined them // himself. Each place is now written onto the rung that happened there. expect(find.text('ROUTE'), findsNothing); - expect(find.text('JOURNEY'), findsOneWidget); + expect(find.text('Journey'), findsOneWidget); // Once now, not twice. The hero's quiet `from → to` line names both // ends; the journey names the place on the rung that happened there, @@ -402,11 +435,14 @@ void main() { expect(find.text('Vidhya Kitchen'), findsOneWidget); expect( find.text('78, Cross Cut Road, Gandhipuram'), - findsOneWidget, + findsNWidgets(2), reason: 'the address hangs on the rung that happened at it. With no ' 'drop address on the booking the one address it has belongs to ' - 'the door — the fallback ROUTE encoded and this inherited.', + 'the door — the fallback ROUTE encoded and this inherited. ' + 'Twice with the log open: the rung, and the card under the strip ' + 'that restates where it ended, which is the one line the strip ' + 'itself has no room for.', ); // A parcel booking is a first-mile collection *from* the customer, and @@ -415,7 +451,7 @@ void main() { ServiceProfile.setActive(ServiceProfile.parcel); await pump(tester, record(type: 'pickup', status: 'picked')); expect(find.text('ROUTE'), findsNothing); - expect(find.text('JOURNEY'), findsOneWidget); + expect(find.text('Journey'), findsOneWidget); }); }); @@ -431,23 +467,31 @@ void main() { ); await unfold(tester); - // ── The labelled stats strip is gone ── + // ── The stats strip is back, as the app's own fact strip ── // - // DISTANCE / TOTAL TIME / ORDERS was three captioned cells in a tinted - // box — a dashboard widget, and a dashboard is for comparing many things - // across records. There is one stop on this page and it is finished. The - // figures still earn a place; they earn a line. - expect(find.text('DISTANCE'), findsNothing); - expect(find.text('TOTAL TIME'), findsNothing); + // It was removed once, as DISTANCE / TOTAL TIME / ORDERS in a tinted box, + // on the argument that a dashboard is for comparing many things and there + // is one stop on this page. That argument was about the *panel*; what + // replaced it — one grey centred sentence — went too far the other way, + // and the page it left behind read as unfinished. + // + // What is here now is the tonal fact strip Account and the stop sheet + // already use, inside the panel that owns the figures. So the assertion + // flips: the cells exist, and what is still forbidden is a cell for + // something nobody measured (below). + expect(find.text('DISTANCE'), findsOneWidget); - // The distance now reads once in that line, and once in the folded row - // that names it. - expect(find.text('4.6 km'), findsOneWidget); + // Twice, and both are honest: the strip's value, and the folded row that + // names it against the planned figure. + expect(find.text('4.6 km'), findsNWidgets(2)); expect(find.textContaining('4.6 km'), findsWidgets); expect(find.text('4.1 km'), findsOneWidget); + // The brief's emerald, which is what "delivered / met / yes" is set in + // on the rebuilt screens. Same meaning, one hue deeper than the old + // `acceptGreen`. final verdict = tester.widget(find.text('Yes')); - expect(verdict.style!.color, ColorConstants.acceptGreen); + expect(verdict.style!.color, Narrative.emerald); // A record that invents a plausible number cannot be caught. for (final invented in const ['0 km', '0.0 km', '0m', '₹0']) { @@ -474,7 +518,9 @@ void main() { testWidgets('the reference is there and copyable', (tester) async { await pump(tester, record(type: 'pickup', status: 'picked')); await unfold(tester); - expect(find.text('#DM101'), findsOneWidget); + // Twice: the app bar's quiet identifier and the folded row that owns + // it. Only one of them is copyable, which is the point of the row. + expect(find.text('#DM101'), findsNWidgets(2)); expect(find.byIcon(LucideIcons.copy), findsOneWidget); }); @@ -518,9 +564,10 @@ void main() { await unfold(tester); expect(find.text('Payment'), findsWidgets); - // Due, collected, and again under EARNINGS — the cash that changed hands - // is the one earnings figure this stop really has. - expect(find.text('₹340'), findsNWidgets(3)); + // Four: the fact strip at the top of the page, then due, collected, and + // again under EARNINGS — the cash that changed hands is the one earnings + // figure this stop really has. + expect(find.text('₹340'), findsNWidgets(4)); // Earnings folded into Payment — one group about money, not two. expect(find.text('Payment'), findsWidgets); expect(find.text('Cash collected'), findsOneWidget); @@ -576,11 +623,14 @@ void main() { // Both addresses now sit on the journey, each on the rung that happened // at it — the counter on the collection, the door on the outcome — so // this reads the ladder rather than a separate ROUTE section. + await openLog(tester); final kitchen = tester.getRect( find.text('4 Cross, RS Puram, Coimbatore 641002'), ); + // `.first`: the door's address is on its rung and again on the card + // under the strip, and the rung is the one this ordering is about. final door = tester.getRect( - find.text('12, SNS Colony, Peelamedu, Coimbatore 641004'), + find.text('12, SNS Colony, Peelamedu, Coimbatore 641004').first, ); expect( @@ -600,16 +650,22 @@ void main() { // hanging its one address exactly where it always hung. ServiceProfile.setActive(ServiceProfile.milkMan); await pump(tester, record()); + await openLog(tester); await unfold(tester); - expect(find.text('78, Cross Cut Road, Gandhipuram'), findsOneWidget); + // The rung, and the outcome card that restates the last one. + expect(find.text('78, Cross Cut Road, Gandhipuram'), findsNWidgets(2)); }); testWidgets('the same street is never printed twice', (tester) async { ServiceProfile.setActive(ServiceProfile.milkMan); await pump(tester, record()); + await openLog(tester); await unfold(tester); - expect(find.text('78, Cross Cut Road, Gandhipuram'), findsOneWidget); + // Once on the rung and once on the outcome card, and never a third time + // — the duplication this test was written against was a whole ROUTE + // section repeating the journey. + expect(find.text('78, Cross Cut Road, Gandhipuram'), findsNWidgets(2)); }); }); } diff --git a/test/delivery_line_test.dart b/test/delivery_line_test.dart index e635b74..9e1d41a 100644 --- a/test/delivery_line_test.dart +++ b/test/delivery_line_test.dart @@ -169,10 +169,13 @@ void main() { // the build flag still reaches it, and a profile that lies about the work // would be worse than one nobody uses. test('names the work Doormile, never the app it was ported from', () { + // Exact equality, not a list of things it must not contain. The old + // version spelled out the retired platform's name to forbid it, which + // put the word back in the repo it was being banned from — and it was + // the weaker assertion anyway: `== 'Doormile'` rules out every other + // name at once, including ones nobody has thought of. const p = ServiceProfile.delivery; expect(p.label, 'Doormile'); - expect(p.label.toLowerCase(), isNot(contains('nearle'))); - expect(p.label.toLowerCase(), isNot(contains('xpress'))); }); test('speaks in deliveries, not bookings', () { diff --git a/test/geofence_flag_test.dart b/test/geofence_flag_test.dart index 56d3413..f07c8e1 100644 --- a/test/geofence_flag_test.dart +++ b/test/geofence_flag_test.dart @@ -7,19 +7,22 @@ import 'package:miler/controllers/pickups_controller.dart'; /// /// [kGeofenceEnforced] decides whether "Picked up" means the rider was standing /// at the door or merely pressed a button, and it has already been wrong in -/// both directions: it was `kDebugMode` — so every debug build had no fence and -/// the fence was therefore never tested — and then a hard `false`, so the flow -/// could not be walked at a desk at all. +/// three directions: it was `kDebugMode` — so every debug build had no fence +/// and the fence was therefore never tested — then a hard `false`, so the flow +/// could not be walked at a desk at all, then a define defaulting to off. /// -/// **It is currently OFF**, decided on 2026-08-19 because the fence was -/// refusing real work: riders pressing *Picked up* at a counter were told they -/// were kilometres away, most often because the booking's own coordinates were -/// wrong rather than the rider. See the declaration for the full reasoning and -/// for how to restore it. +/// **It is OFF, decided 2026-08-25.** It was turned on that morning with a +/// radius of [kGeofenceRadiusMeters] and turned off again the same day, at the +/// founder's call. Nothing was found wrong with it — this is a decision about +/// *when* to switch it on. /// -/// So this file no longer guards a direction. It guards the two properties that -/// made the old defaults dangerous: that the state is *deliberate*, and that -/// every gate reads the same switch. +/// So the direction this file guards has flipped twice in a day, which is +/// exactly the pattern that made the old defaults dangerous, and it is why the +/// three tests below are not all about the direction. The measurement work is +/// unchanged and still asserted: a `best` fix with a shelf life, the phone's +/// error credited to the rider, and one radius rather than three. All of it is +/// dormant while [kGeofenceEnforced] is false and none of it needs revisiting +/// on the day it goes back on. void main() { test('there is one switch, and the legacy name derives from it', () { // Two constants that could disagree is exactly how a build ends up with @@ -33,8 +36,8 @@ void main() { // If this fails, one of two things happened, and both should be read rather // than silenced: // - // • the default was changed back to enforced — which is the intended - // end state, so update this test with the date and the reason; or + // • the default was turned back on — record the date and the reason here, + // as the previous flips did; or // • this run passed `--dart-define=ENFORCE_GEOFENCE=true`, in which case // the failure is correct and is telling you this build has the fence on. expect( @@ -46,4 +49,19 @@ void main() { '--dart-define=ENFORCE_GEOFENCE=true.', ); }); + + test('the radius is one number, and it is the tight one', () { + // Asserted while the fence is off, deliberately. The app used to hold + // three — a configured `pickupradius` defaulting to 100 in the controller, + // a hardcoded 500 in Home's bulk gate, and no agreement between them — so + // which fence a rider met depended on whether he ticked boxes or slid a + // sheet. Whichever way the switch above is set, that must not come back. + expect(kGeofenceRadiusMeters, 10); + }); + + test('a cached fix has a shelf life', () { + // On a round, a stale position is reliably the *previous* stop. Anything + // much longer than this and the fence starts measuring from the last door. + expect(kGeofenceFixMaxAge, const Duration(seconds: 30)); + }); } diff --git a/test/group_accept_count_test.dart b/test/group_accept_count_test.dart new file mode 100644 index 0000000..ea8800d --- /dev/null +++ b/test/group_accept_count_test.dart @@ -0,0 +1,214 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/home/route_timeline.dart'; +import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/views/Dashboard/home/trip_card.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// `Accepted 1/10`, NOT `Accepted 1/63` +/// +/// The kitchen card's state chip is a fraction: how many of this counter's +/// orders the rider has taken, out of how many there are. The numerator was +/// right and the denominator was the whole day. +/// +/// ── What it was counting ── +/// +/// [RouteGroup.allStops] is every order the trip ever bucketed under a source, +/// and `groupSize` was its raw length. That deliberately includes orders the +/// rider has **collected** — they left the visible list but not the counter, +/// and renumbering the place under him is how a rider stops trusting a number. +/// +/// It also included every order already **delivered** and every one he had +/// **declined**, which are Activity's business and not this card's. Off a +/// kitchen that supplies a rider all day that is most of the bucket: ten orders +/// in the dropdown, one accepted, and a chip reading `Accepted 1/63` — a +/// fraction whose denominator appears nowhere on the screen. +/// +/// The load is now stated the way the rider would state it: **what is still in +/// the dropdown, plus what he has already taken off this counter**. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + tearDown(() { + Get.reset(); + ServiceProfile.setActive(ServiceProfile.parcel); + }); + + /// One order off Vidhya Kitchen. [status] is what the backend calls it, which + /// is what `stopStateOf` reads to place it on the ladder. + Map order(int id, {required String status}) => + { + 'orderid': 'M-$id', + 'pickupid': id, + 'step': id, + 'kitchenname': 'Vidhya Kitchen', + 'sourceid': 'K1', + 'pickupcustomer': 'Customer $id', + 'pickupaddress': '9 Kitchen Street, Coimbatore', + 'dropaddress': 'Door $id, Peelamedu, Coimbatore', + 'orderstatus': status, + 'type': 'delivery', + 'pickuplat': 11.01, + 'pickuplon': 76.95, + }; + + /// The card's own grouping, run the way the screen runs it. + List groupsFor( + List> stops, { + Set accepted = const {}, + Set collected = const {}, + }) { + ServiceProfile.setActive(ServiceProfile.milkMan); + return TripCard( + trip: Trip.fromStops(id: 'T1', stops: stops), + acceptedIds: accepted, + collectedIds: collected, + ).routeGroups(); + } + + group('the denominator is the counter, not the day', () { + test('ten in the dropdown and one accepted reads 1 of 10', () { + // The reported case, at the size it was reported: one kitchen, ten + // orders still to decide, and fifty-three already delivered today. + final stops = >[ + for (var i = 1; i <= 10; i++) order(i, status: 'assigned'), + for (var i = 11; i <= 63; i++) order(i, status: 'delivered'), + ]; + + final g = groupsFor(stops, accepted: {'M-1'}).single; + + expect(g.stops.length, 10, reason: 'the dropdown holds ten'); + expect(g.allStops.length, 63, reason: 'the bucket still holds the day'); + expect(g.onRungCount, 1); + expect( + g.groupSize, + 10, + reason: 'fifty-three delivered orders are Activity\'s, not this card\'s', + ); + }); + + test('a collected order keeps its place in the arithmetic', () { + // The original rationale, which must survive the fix: three bags are in + // the rider's box and off Home, but they came off this counter and the + // place still holds six. + final stops = >[ + for (var i = 1; i <= 6; i++) order(i, status: 'assigned'), + ]; + + final g = groupsFor( + stops, + accepted: {'M-1', 'M-2', 'M-3'}, + collected: {'M-1', 'M-2', 'M-3'}, + ).single; + + expect(g.stops.length, 3, reason: 'the collected three left Home'); + expect( + g.groupSize, + 6, + reason: 'renumbering the counter under him is the bug this prevents', + ); + }); + + test('a declined order drops out of the load', () { + final stops = >[ + for (var i = 1; i <= 4; i++) order(i, status: 'assigned'), + order(5, status: 'rejected'), + ]; + + final g = groupsFor(stops).single; + + expect(g.groupSize, 4, reason: 'he has finished with the declined one'); + }); + + test('nothing delivered means the two counts agree', () { + final stops = >[ + for (var i = 1; i <= 5; i++) order(i, status: 'assigned'), + ]; + + final g = groupsFor(stops).single; + + expect(g.groupSize, 5); + expect(g.groupSize, g.allStops.length); + }); + + test('a caller that tracks no states gets the old arithmetic', () { + // Better to over-count than to invent a denominator: a hand-built group + // with no `allStopStates` must not silently report zero. + const g = RouteGroup( + key: 'K1', + name: 'Vidhya Kitchen', + stops: [], + allStops: [{}, {}, {}], + firstIndex: 0, + state: StopState.pending, + stopStates: [], + ); + + expect(g.groupSize, 3); + }); + }); + + group('the chip on the card says the same number', () { + Future pump( + WidgetTester tester, + List> stops, { + Set accepted = const {}, + }) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + tester.view.physicalSize = const Size(390 * 3, 1600 * 3); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: TripCard( + trip: Trip.fromStops(id: 'T1', stops: stops), + acceptedIds: accepted, + onToggleSelect: (_) {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('the accepted chip is a fraction of the dropdown', ( + tester, + ) async { + await pump(tester, [ + for (var i = 1; i <= 10; i++) order(i, status: 'assigned'), + for (var i = 11; i <= 63; i++) order(i, status: 'delivered'), + ], accepted: {'M-1'}); + + expect(find.text('Accepted 1/10'), findsOneWidget); + expect( + find.text('Accepted 1/63'), + findsNothing, + reason: 'the day is not the counter', + ); + }); + + testWidgets('a single-order place states no fraction at all', ( + tester, + ) async { + // `Accepted (1 of 1)` is noise, and the guard for it reads `groupSize`. + await pump( + tester, + [order(1, status: 'assigned')], + accepted: {'M-1'}, + ); + + expect(find.text('Accepted'), findsOneWidget); + expect(find.textContaining('Accepted 1/'), findsNothing); + }); + }); +} diff --git a/test/home_brief_outstanding_test.dart b/test/home_brief_outstanding_test.dart new file mode 100644 index 0000000..ecb100c --- /dev/null +++ b/test/home_brief_outstanding_test.dart @@ -0,0 +1,149 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/data/service_profile.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE BRIEF DESCRIBES WHAT IS LEFT +/// +/// Home's brief card carries four figures — duration, distance, parcels, +/// payment — under a headline that counts down (`20 stops left`). +/// +/// The headline moved as the rider worked. The figures did not. They were +/// counted once over every stop the slot ever held, so a rider with five +/// collections to go read `≈4h 15m · 29.0 km · 25 parcels`: the morning he +/// started with, including the twenty already in his box and gone to +/// Deliveries. The one question the card exists to answer — *what is in front +/// of me* — was the one thing on it that was stale. +/// +/// [Trip.outstanding] is the recount. The line it draws is +/// `WorkBoundary.pickupComplete`, the same boundary Home and Deliveries split +/// on, so a stop cannot be counted here and owned there. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + Map stop( + String id, { + String status = 'accepted', + int parcels = 1, + double lat = 11.0, + double lng = 76.95, + double cash = 0, + }) => { + 'orderid': id, + 'pickupid': id, + 'orderstatus': status, + 'pickupquantity': parcels, + 'pickuplat': lat, + 'pickuplon': lng, + 'collectionamt': cash, + }; + + group('what the figures count', () { + test('a collected stop drops out of every figure', () { + final trip = Trip.fromStops( + id: 't1', + stops: [ + stop('A', status: 'picked', lat: 11.00, lng: 76.90), + stop('B', lat: 11.05, lng: 76.95), + stop('C', lat: 11.10, lng: 77.00), + ], + hubLat: 11.0, + hubLng: 76.95, + ); + + final left = trip.outstanding(originLat: 11.0, originLng: 76.95); + + expect(trip.stopCount, 3, reason: 'the trip itself is untouched'); + expect(left.stopCount, 2); + expect(left.totalParcels, lessThan(trip.totalParcels)); + expect(left.totalDuration, lessThan(trip.totalDuration)); + expect(left.routeMeters, lessThan(trip.routeMeters)); + }); + + test('the local collected record counts, not just the status', () { + // The queue is a poll behind, so a stop the rider has just handed over + // still reports its old status. `collectedIds` is what closes that + // window — the same set Home writes after pickup-complete returns. + final trip = Trip.fromStops( + id: 't1', + stops: [stop('A'), stop('B')], + ); + + expect(trip.outstanding().stopCount, 2, reason: 'nothing collected yet'); + expect(trip.outstanding(collectedIds: const {'A'}).stopCount, 1); + }); + + test('nothing collected returns the very same object', () { + // The common case — a rider who has not started — must not pay for a + // rebuild of the whole trip on every frame of the brief. + final trip = Trip.fromStops(id: 't1', stops: [stop('A'), stop('B')]); + expect(identical(trip.outstanding(), trip), isTrue); + }); + + test('skipped and cancelled work is not still-to-do', () { + final trip = Trip.fromStops( + id: 't1', + stops: [ + stop('A', status: 'skipped'), + stop('B', status: 'cancelled'), + stop('C'), + ], + ); + expect( + trip.outstanding().stopCount, + 1, + reason: 'a written-off stop is a record, not a stop to plan around', + ); + }); + + test('a run with everything collected leaves nothing to describe', () { + final trip = Trip.fromStops( + id: 't1', + stops: [ + stop('A', status: 'picked'), + stop('B', status: 'picked'), + ], + ); + final left = trip.outstanding(); + expect(left.stopCount, 0); + expect(left.totalParcels, 0); + expect( + left.totalDuration, + Duration.zero, + reason: 'the four cells fall back to the em dash, not to a stale figure', + ); + }); + }); + + group('payment on a meal run', () { + test('is nothing to collect, whatever the payload says', () { + ServiceProfile.setActive(ServiceProfile.milkMan); + final trip = Trip.fromStops( + id: 't1', + stops: [stop('A', cash: 250), stop('B', cash: 130)], + ); + // The cell prints the em dash when this is zero — the rider on a meal + // run is never asked for money, so a figure here would be a prompt to + // collect one. + expect(trip.outstanding().cashToCollect, 0); + }); + + test('but a parcel route still counts the cash it is owed', () { + ServiceProfile.setActive(ServiceProfile.parcel); + final trip = Trip.fromStops( + id: 't1', + stops: [ + stop('A', status: 'picked', cash: 250), + stop('B', cash: 130), + ], + ); + expect( + trip.outstanding().cashToCollect, + 130, + reason: 'the collected stop\'s cash is no longer waiting to be taken', + ); + }); + }); +} diff --git a/test/home_grid_test.dart b/test/home_grid_test.dart index c3b2d4a..02f5dbf 100644 --- a/test/home_grid_test.dart +++ b/test/home_grid_test.dart @@ -109,18 +109,28 @@ void main() { } }); - testWidgets('the select-all label sits on the name axis', (tester) async { + testWidgets('the select-all closes the row it belongs to', (tester) async { await pumpHome(tester); - // The regression this file exists for. It heads the list it selects, so - // it starts where the list starts — not one rail further in, and not two - // points off it either. - final label = tester.getRect(find.text('Select all 3')).left; - final name = tester.getRect(find.text('Customer 1')).left; + // ── The axis it answers to is the right one now ── + // + // This pinned the label to the *name* axis, when the control headed its + // list from the left. Inside a group it is a control over a list rather + // than a heading for one, so it sits on the edge a list's own controls + // use — the same right edge the bag labels and the chevrons below it + // already keep. What must not happen is that it floats between the two. + final label = tester.getRect(find.text('Select all 3')).right; + final list = tester.getRect(find.byType(RouteTimeline)).right; + final bag = tester.getRect(find.text('Bag 1')).right; expect( label, - closeTo(name, 0.5), - reason: 'the select-all is $label, the names are $name', + closeTo(list, 4), + reason: 'the select-all ends at $label, the list at $list', + ); + expect( + label, + greaterThan(bag), + reason: 'and it closes past the bag column rather than inside it', ); }); diff --git a/test/home_gutter_test.dart b/test/home_gutter_test.dart index b4996f7..49e3aa5 100644 --- a/test/home_gutter_test.dart +++ b/test/home_gutter_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get/get.dart'; @@ -98,11 +99,20 @@ void main() { ) async { await pump(tester); - // The brief's first ink is the hero numeral now — the caption sits to - // its right — so the claim is measured on the numeral. + // ── Measured on the metrics, not on the headline ── + // + // The headline is centred now: the card is symmetrical — a title over a + // full-width bar — and a left-aligned headline over a centred bar reads as + // two elements that failed to line up. So the brief's left edge is the one + // its *left-aligned* content sits on, which is the metric grid under the + // bar. That is the column the route below has to agree with. + // The grid's first ink is the icon tile, not the word beside it. final brief = tester.getRect( find - .descendant(of: find.byType(TripBriefStrip), matching: find.text('2')) + .ancestor( + of: find.byIcon(LucideIcons.clock), + matching: find.byType(Container), + ) .first, ); final route = tester.getRect(find.text('TODAY’S RUN')); diff --git a/test/home_header_test.dart b/test/home_header_test.dart new file mode 100644 index 0000000..be97955 --- /dev/null +++ b/test/home_header_test.dart @@ -0,0 +1,418 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/views/Dashboard/home/home_header.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE HOME HEADER +/// +/// Two things this file is for, and the second is the one that will save +/// somebody later. +/// +/// **The budget.** The header's whole justification is that the rider's first +/// stop was below the fold. That is an arithmetic claim, so it is measured +/// here: expanded content stays inside 150–175dp, and the collapsed state is +/// 52. A header that creeps back up a line at a time is exactly how the +/// original one got to 160. +/// +/// **The extent contract.** A `SliverPersistentHeader` declares a fixed height +/// and anything longer than it is *silently clipped* — no exception, no +/// overflow stripe, just a trip selector with its bottom cut off. Two of the +/// lines in here are type that grows with the system font, so the extent is +/// derived rather than hardcoded, and these tests walk it from 1.0× to 2.0× on +/// four screen widths to prove it is never short. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + /// Pumps the header as the page does: a pinned sliver at the head of a + /// `CustomScrollView`, with something long under it to scroll. + Future pump( + WidgetTester tester, { + String greeting = 'Good afternoon, Rajan', + String subtitle = '17 stops remaining', + String collapsed = 'Trip 1 · 17 left · 33/50', + bool onDuty = true, + bool locked = false, + bool busy = false, + VoidCallback? onToggle, + double width = 390, + double textScale = 1.0, + double topInset = 47, + }) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + + tester.view.physicalSize = Size(width * 3, 844 * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + debugShowCheckedModeBanner: false, + home: MediaQuery( + data: MediaQueryData( + size: Size(width, 844), + padding: EdgeInsets.only(top: topInset), + textScaler: TextScaler.linear(textScale), + ), + child: Builder( + builder: (context) => Scaffold( + body: CustomScrollView( + controller: controller, + slivers: [ + SliverPersistentHeader( + pinned: true, + delegate: HomeHeaderDelegate( + topInset: topInset, + expandedContent: HomeHeaderDelegate.expandedContentFor( + context, + ), + greeting: greeting, + subtitle: subtitle, + onDuty: onDuty, + dutyLocked: locked, + dutyBusy: busy, + onToggleDuty: onToggle ?? () {}, + collapsedSummary: collapsed, + ), + ), + SliverList.list( + children: [ + for (var i = 0; i < 40; i++) + SizedBox(height: 60, child: Text('row $i')), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + return controller; + } + + group('1 · the height budget', () { + testWidgets('expanded content is inside 95–120dp', (tester) async { + late double content; + tester.view.physicalSize = const Size(390 * 3, 844 * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Builder( + builder: (context) { + content = HomeHeaderDelegate.expandedContentFor(context); + return const SizedBox(); + }, + ), + ), + ), + ); + + // ── The budget moved when the tabs did ── + // + // It was 150–175, for a header that carried the trip selector. The + // selector sits on the page under it now, so what is left up here is a + // wordmark row, a greeting and one operational line — and the budget is + // the sum of those rather than a number inherited from the old shape. + // + // The tabs are still charged, just not to this. Header + tabs together + // are about 124 + 46 + their padding, against the 160 the fixed block + // cost before — and unlike that block, 72 of it scrolls away. + // ── The budget moved again when the duplicate line went ── + // + // It was 110–135 with `N stops remaining` under the greeting. That line + // was the brief card's headline said twice, forty points apart, smaller + // and greyer the first time — so it is gone and the header is ~20 points + // cheaper. Every one of those points is the rider's first stop moving up + // the screen, which is the whole reason this test exists. + expect( + content, + inInclusiveRange(95, 120), + reason: + 'measured $content — the header exists to get the first stop above ' + 'the fold, and every point it grows takes one back', + ); + }); + + testWidgets('collapsed content is 52dp', (tester) async { + tester.view.physicalSize = const Size(390 * 3, 844 * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => const MaterialApp(home: SizedBox()), + ), + ); + expect(HomeHeaderDelegate.collapsedContent, closeTo(52, 0.5)); + }); + }); + + group('2 · the extent is never short', () { + for (final width in const [320.0, 360.0, 390.0, 430.0]) { + for (final scale in const [1.0, 1.3, 1.5, 2.0]) { + testWidgets('${width.toInt()}dp at ${scale}x', (tester) async { + await pump(tester, width: width, textScale: scale); + // A clipped sliver throws nothing, so the assertion that matters is + // that the layout itself did not complain — an overflowing Column + // inside the header WOULD throw, and that is the failure mode these + // widths and scales are hunting. + expect(tester.takeException(), isNull); + expect(find.text('ON'), findsOneWidget); + }); + } + } + + testWidgets('a long rider name does not push anything out', (tester) async { + await pump( + tester, + width: 320, + greeting: 'Good afternoon, Venkateswara Subramaniam', + ); + expect(tester.takeException(), isNull); + // The greeting ellipsises to one line rather than wrapping and pushing + // the header past its declared extent. + expect(find.text('Good afternoon, Venkateswara Subramaniam'), + findsOneWidget); + }); + }); + + group('3 · what each state says', () { + testWidgets('off duty reads OFF and keeps the same shape', (tester) async { + await pump(tester, onDuty: false); + expect(find.text('OFF'), findsOneWidget); + expect(find.text('ON'), findsNothing); + }); + + testWidgets('the stop count is NOT repeated up here', (tester) async { + // ── It was said twice, forty points apart ── + // + // `17 stops remaining` sat under the greeting in grey 13pt, and the + // brief card immediately below leads with `17 stops left` as its + // headline at 17pt against a progress bar. Two sizes, two wordings, one + // figure — and the smaller, greyer, vaguer one came first. + // + // The header carries identity and duty. The count belongs to the card + // that also shows the duration, distance and parcels behind it. + await pump(tester); + expect(find.text('Good afternoon, Rajan'), findsOneWidget); + expect(find.text('17 stops remaining'), findsNothing); + expect(find.textContaining('stops remaining'), findsNothing); + }); + + testWidgets('but the collapsed line still carries it', (tester) async { + // The one place the figure is still the header's job: collapsed, the + // brief card has scrolled away and this is all that is left on screen. + final c = await pump(tester); + c.jumpTo(400); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Trip 1 · 17 left · 33/50'), findsOneWidget); + }); + }); + + group('4 · duty', () { + testWidgets('the tap target clears 48dp even though the pill is smaller', ( + tester, + ) async { + await pump(tester); + final target = tester.getSize(find.byType(DutyPill)); + expect(target.height, greaterThanOrEqualTo(DutyPill.tapTarget)); + }); + + testWidgets('tapping asks the page, it does not decide', (tester) async { + var asked = 0; + await pump(tester, onToggle: () => asked++); + await tester.tap(find.byType(DutyPill)); + expect(asked, 1); + }); + + testWidgets('a write in flight refuses a second press', (tester) async { + var asked = 0; + await pump(tester, busy: true, onToggle: () => asked++); + await tester.tap(find.byType(DutyPill), warnIfMissed: false); + expect(asked, 0, reason: 'two duty writes for one intent'); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('locked mid-pickup refuses, and says so', (tester) async { + var asked = 0; + await pump(tester, locked: true, onToggle: () => asked++); + await tester.tap(find.byType(DutyPill), warnIfMissed: false); + expect(asked, 0); + // The state is still readable — the control dims rather than vanishing. + expect(find.text('ON'), findsOneWidget); + }); + }); + + group('5 · collapsing', () { + testWidgets('the greeting goes and the operational line arrives', ( + tester, + ) async { + final c = await pump(tester); + + expect(find.text('Good afternoon, Rajan'), findsOneWidget); + expect(find.text('Trip 1 · 17 left · 33/50'), findsNothing); + + c.jumpTo(400); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // Each layout is dropped entirely once its side of the cross-fade + // reaches zero, rather than kept at `Opacity(0)`. A fully transparent + // subtree still lays out and still paints into a saved layer on every + // frame of a scroll, and this one holds a wordmark, a greeting and a + // duty control — which is a lot of nothing to composite forty times a + // second. + expect(find.text('Good afternoon, Rajan'), findsNothing); + expect(find.text('Trip 1 · 17 left · 33/50'), findsOneWidget); + }); + + testWidgets('duty stays reachable in the collapsed state', (tester) async { + var asked = 0; + final c = await pump(tester, onToggle: () => asked++); + c.jumpTo(400); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // The expanded copy is gone by now, so this can only be the collapsed + // one — which is the point: duty is the one control that must never + // scroll away. + expect(find.byType(DutyPill), findsOneWidget); + await tester.tap(find.byType(DutyPill)); + expect(asked, 1); + }); + + testWidgets('the header holds the top of the screen', (tester) async { + final c = await pump(tester); + c.jumpTo(600); + await tester.pump(); + + // The pinned extent is exactly the collapsed one plus the inset. + final material = tester.getSize( + find.byKey(HomeHeaderDelegate.surfaceKey), + ); + expect( + material.height, + closeTo(47 + HomeHeaderDelegate.collapsedContent, 1), + ); + }); + }); + + group('6 · rebuild scope', () { + test('an unrelated change does not rebuild the header', () { + HomeHeaderDelegate make({String subtitle = '17 stops remaining'}) => + HomeHeaderDelegate( + topInset: 47, + expandedContent: 170, + greeting: 'Good afternoon, Rajan', + subtitle: subtitle, + onDuty: true, + dutyLocked: false, + dutyBusy: false, + onToggleDuty: () {}, + collapsedSummary: 'Trip 1 · 17 left · 33/50', + ); + + // Same inputs — a row further down the list changing touches none of + // them, so the sliver does not repaint for it. + expect(make().shouldRebuild(make()), isFalse); + // A figure it actually draws does. + expect( + make().shouldRebuild(make(subtitle: '16 stops remaining')), + isTrue, + ); + }); + }); + + // ══════════════════════════════════════════════════════════════════════ + // THE COLLAPSE ITSELF + // + // The budget tests above measure the header at its two REST states. The bug + // this group exists for lived entirely in between them. + // + // `_ExpandedBody` was a plain child of a `StackFit.expand` stack, so it was + // handed the header's *current* height — which, mid-collapse, is less than + // the content. Its Column cannot compress, so from the first pixel of scroll + // Flutter drew `BOTTOM OVERFLOWED BY 28 PIXELS` in yellow and black across + // the top of Home, over the greeting, on a rider's phone. + // + // Neither rest state shows it: fully expanded there is room, fully collapsed + // the body has faded to nothing. Only the travel between them does — which + // is why every assertion below walks the scroll rather than sampling its + // ends. + // ══════════════════════════════════════════════════════════════════════ + group('7 · nothing overflows while it collapses', () { + /// Every offset from rest to fully pinned, finely enough that no fade + /// threshold (0.55 / 0.6) can be stepped over. + Future walk( + WidgetTester tester, + ScrollController controller, + double range, + ) async { + for (var i = 0; i <= 40; i++) { + controller.jumpTo(range * i / 40); + await tester.pump(); + expect( + tester.takeException(), + isNull, + reason: + 'overflowed at scroll offset ${range * i / 40} of $range — the ' + 'header must clip its content, never squeeze it', + ); + } + } + + testWidgets('at 1.0x on a 390pt phone', (tester) async { + final c = await pump(tester); + await walk(tester, c, 200); + }); + + testWidgets('at every text scale a rider can set', (tester) async { + for (final scale in const [1.0, 1.3, 1.5, 2.0]) { + final c = await pump(tester, textScale: scale); + await walk(tester, c, 200); + } + }); + + testWidgets('on a narrow phone, where the greeting wraps', (tester) async { + // 320pt with a long name is where the greeting is most likely to take a + // second line and push the Column past the extent. + final c = await pump( + tester, + width: 320, + greeting: 'Good afternoon, Rajanikanth Subramaniam', + subtitle: '20 stops remaining', + ); + await walk(tester, c, 200); + }); + + testWidgets('the expanded body keeps its full height as it goes', ( + tester, + ) async { + // The fix, stated as behaviour rather than as a widget choice: the + // greeting does not shrink on the way out, it slides out. If it ever + // reflows again, this catches it before the overflow banner does. + final c = await pump(tester); + final full = tester.getSize(find.text('Good afternoon, Rajan')).height; + + c.jumpTo(30); + await tester.pump(); + expect(tester.takeException(), isNull); + expect( + tester.getSize(find.text('Good afternoon, Rajan')).height, + full, + reason: 'the body is measured at its own height, not the header\'s', + ); + }); + }); +} diff --git a/test/home_structure_test.dart b/test/home_structure_test.dart index 973fd9e..1784f4b 100644 --- a/test/home_structure_test.dart +++ b/test/home_structure_test.dart @@ -134,11 +134,30 @@ void main() { anyOf(isNull, isEmpty), reason: 'a shadow lifts a group off the page; the spine groups it', ); - expect( - d.border, - isNull, - reason: 'an outline around a group is the card by another name', - ); + // ── A rule between rows is not an outline around them ── + // + // This required `border` to be null outright, which caught the thing + // it was written for — a rectangle drawn round a kitchen — and also + // catches a one-sided hairline separating one order from the next. + // Those are opposites: a box groups by enclosing, a rule separates by + // dividing, and the second is the spine's own grammar rather than a + // competitor to it. What stays banned is an enclosure. + final border = d.border; + if (border is Border) { + expect( + border.left.style == BorderStyle.none && + border.right.style == BorderStyle.none && + border.top.style == BorderStyle.none, + isTrue, + reason: 'an outline around a group is the card by another name', + ); + } else { + expect( + border, + isNull, + reason: 'an outline around a group is the card by another name', + ); + } } // ── What a fill may be here, since the route moved onto a panel ── diff --git a/test/intro_screen_test.dart b/test/intro_screen_test.dart index b6b9bc0..56d22ee 100644 --- a/test/intro_screen_test.dart +++ b/test/intro_screen_test.dart @@ -5,12 +5,31 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:miler/views/helpers/constants/app_theme.dart'; import 'package:miler/views/introscreens/introscreen.dart'; -/// The first-run slides. The illustrations are miniature mock-ups of the real -/// Home screen rather than a centred Material icon, which means they are made of -/// fixed-width rows — the hub rail on slide 1 is a dozen sized children in a -/// single unflexed `Row`. That is exactly the shape that overflows on a narrow -/// phone, so every slide is laid out at the narrowest screen the app supports. +/// ───────────────────────────────────────────────────────────────────────── +/// THE FIRST-RUN SLIDES +/// +/// These used to be miniature mock-ups of Home built out of fixed-width rows — +/// the hub rail on slide 1 was a dozen sized children in a single unflexed +/// `Row` — and the tests existed because that is exactly the shape which +/// overflows on a narrow phone. +/// +/// The slides are finished posters now: headline, sub-copy, product shot and +/// the MILER lockup are all baked into the artwork, and the screen draws no +/// text of its own. A `BoxFit.cover` image cannot overflow, so the width sweep +/// is no longer about the illustrations — it is about the controls laid over +/// them, which are the only Flutter-built objects left on the screen. +/// +/// What is worth pinning instead is the part a wrong asset path would break +/// silently: three specific images, in the order the rider is meant to see +/// them, with the CTA changing on the last one. +/// ───────────────────────────────────────────────────────────────────────── void main() { + const slides = [ + 'assets/images/onboard_1.png', + 'assets/images/onboard_2.png', + 'assets/images/onboard_3.png', + ]; + Future pump(WidgetTester tester, {required Size screen}) async { tester.view.physicalSize = Size(screen.width * 3, screen.height * 3); tester.view.devicePixelRatio = 3.0; @@ -27,6 +46,18 @@ void main() { await tester.pumpAndSettle(); } + /// The asset the slide currently on screen is drawing. + String? shownAsset(WidgetTester tester) { + for (final image in tester.widgetList(find.byType(Image))) { + final provider = image.image; + if (provider is AssetImage && + provider.assetName.startsWith('assets/images/onboard_')) { + return provider.assetName; + } + } + return null; + } + for (final size in const [ Size(320, 640), // smallest Android still in the fleet Size(360, 740), @@ -39,45 +70,104 @@ void main() { await pump(tester, screen: size); expect(tester.takeException(), isNull); - // Slide 1 — the slot, its load, and the round trip. - expect(find.text('Your route arrives ready'), findsOneWidget); - expect(find.text('12:00 PM – 3:00 PM'), findsOneWidget); + expect(shownAsset(tester), slides[0]); expect(find.text('Next'), findsOneWidget); + expect(find.text('Skip'), findsOneWidget); // A near-full-width drag, so the page always snaps to the next one. A - // velocity fling does not reliably cross the threshold here, because each - // slide hosts its own vertical scroll view and the two share the arena. + // velocity fling does not reliably cross the threshold here. final swipe = Offset(-size.width * 0.9, 0); await tester.drag(find.byType(PageView), swipe); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); - expect(find.text('Work the stops in order'), findsOneWidget); - expect(find.text('Skipped'), findsOneWidget); + expect(shownAsset(tester), slides[1]); + expect(find.text('Next'), findsOneWidget); await tester.drag(find.byType(PageView), swipe); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); - expect(find.text('Collect, then close the loop'), findsOneWidget); - expect(find.text('₹340 cash'), findsOneWidget); + expect(shownAsset(tester), slides[2]); - // Last slide swaps the CTA and drops Skip. + // Last slide swaps the CTA and drops Skip — an invisible-but-present + // control is a trap for screen readers, so it is unmounted rather than + // faded to `opacity: 0`. expect(find.text('Get started'), findsOneWidget); + expect(find.text('Next'), findsNothing); expect(find.text('Skip'), findsNothing); }); } + testWidgets('the three posters are in the order they were given', ( + tester, + ) async { + await pump(tester, screen: const Size(390, 844)); + + // `PageView.builder` only builds the page on screen, so the order has to be + // walked rather than read out of the tree in one go. Which is the honest + // test anyway: what matters is the sequence the rider is shown, and 1-3-2 + // would pass any assertion made against a set. + final seen = [shownAsset(tester)]; + for (var i = 1; i < slides.length; i++) { + await tester.drag(find.byType(PageView), const Offset(-351, 0)); + await tester.pumpAndSettle(); + seen.add(shownAsset(tester)); + } + + expect(seen, slides); + }); + + testWidgets('the artwork runs edge to edge', (tester) async { + await pump(tester, screen: const Size(390, 844)); + + final image = tester.widget( + find + .byWidgetPredicate( + (w) => + w is Image && + w.image is AssetImage && + (w.image as AssetImage).assetName == slides[0], + ) + .first, + ); + + // `cover` is what makes a 940×1672 poster fill a phone of any aspect + // without letterboxing. `contain` would put bars down the sides, which on + // artwork that ends in a red wave reads as a rendering fault. + expect(image.fit, BoxFit.cover); + }); + + testWidgets('the screen adds no captions of its own', (tester) async { + await pump(tester, screen: const Size(390, 844)); + + // Every slide's headline and sub-copy are baked into the image. A Flutter + // caption under the picture would say the same thing twice, in a second + // typeface, at a second size — so the only text on the screen is the two + // controls. + final texts = tester + .widgetList(find.byType(Text)) + .map((t) => t.data) + .whereType() + .toSet(); + + expect(texts, {'Skip', 'Next'}); + }); + testWidgets('Skip is offered on the first slide', (tester) async { await pump(tester, screen: const Size(390, 844)); expect(find.text('Skip'), findsOneWidget); }); - // The old copy pitched a gig marketplace — "pickups near you", accepted from a - // feed. This product assigns routes from a hub. Onboarding that teaches the - // wrong model is worse than none, so the wrong words are pinned out. - testWidgets('does not promise a marketplace the app does not have', ( - tester, - ) async { + // The old code-drawn copy pitched a gig marketplace — "pickups near you", + // accepted from a feed. This product assigns routes from a hub, and + // onboarding that teaches the wrong model is worse than none. + // + // NOTE: this can only speak for text the *app* draws. The posters carry their + // own headlines as pixels, and `onboard_1.png` does print "Powered by AI" — + // a claim the previous slides deliberately dropped because nothing in the app + // backs it. That is a copy decision about the artwork, not something a widget + // test can hold. + testWidgets('the app draws no marketplace copy of its own', (tester) async { await pump(tester, screen: const Size(390, 844)); for (final wrong in [ 'Find pickups near you', @@ -85,11 +175,7 @@ void main() { 'Earn more, every shift', 'Powered by AI', ]) { - expect( - find.text(wrong), - findsNothing, - reason: '"$wrong" is not this app', - ); + expect(find.text(wrong), findsNothing, reason: '"$wrong" is not this app'); } }); } diff --git a/test/miler_contract_regression_test.dart b/test/miler_contract_regression_test.dart new file mode 100644 index 0000000..0fedcd1 --- /dev/null +++ b/test/miler_contract_regression_test.dart @@ -0,0 +1,865 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/controllers/riderkm.dart'; +import 'package:miler/data/api_config.dart'; +import 'package:miler/data/miler_api.dart'; +import 'package:miler/data/milk_run.dart'; +import 'package:miler/data/route_order.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/home/homepage.dart'; +import 'package:miler/data/stop_compliance.dart'; +import 'package:miler/controllers/pickups_controller.dart'; +import 'package:miler/providers/pickuplog/pickuplog_provider.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// FOUR CONTRACTS THE APP MEETS THE BACKEND ON +/// +/// Each of these has already been wrong once, and each was wrong in the same +/// way: the app read a field the server does not send, or ignored one it does. +/// None of them raised anything — an absent key and an empty answer are the +/// same value, so every failure was silent and every symptom was cosmetic +/// ("the chart is empty", "the route is in the wrong order"). That is what +/// makes them worth pinning rather than trusting. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // ══════════════════════════════════════════════════════════════════════ + // 1 · ROUTE SEQUENCING + // + // The backend's rule, given 25 Aug: `sequencedat` is the authority, not + // `step`. Non-null → a route was assigned, follow `step` exactly. Null → no + // route, and nearest-first is correct rather than a fallback for a bug. + // + // `RouteOrder` implemented that correctly and it never ran, because + // `pickupFromBooking` was dropping the field. Every adapted row looked + // unsequenced and the app re-sorted routes the hub had solved. + // ══════════════════════════════════════════════════════════════════════ + group('1 · sequencedat survives the adapter and decides the order', () { + Map booking(int id, Map extra) => { + 'bookingid': id, + 'bookingno': 'B$id', + 'pickupaddress': '$id Thadagam Road, Coimbatore', + 'pickupcustomer': 'Sri Balaji Stores', + ...extra, + }; + + /// The rider is beside stop 3, so nearest-first would put it first. Any + /// test that expects the hub's order has to be able to lose to proximity + /// if the gate fails — otherwise it passes for the wrong reason. + double distanceTo(Map s) => switch ('${s['orderid']}') { + '3' => 10, + '1' => 4000, + '2' => 9000, + _ => 99999, + }; + + test('a sequenced booking keeps its stamp and its step', () { + final row = ApiConfig.pickupFromBooking( + booking(1, {'step': 2, 'sequencedat': '2026-08-25T09:00:00+05:30'}), + ); + + expect( + RouteOrder.isSequenced(row), + isTrue, + reason: 'the stamp did not survive pickupFromBooking', + ); + expect(RouteOrder.sequenceOf(row), 2); + }); + + test('the hub order is followed, and proximity does not override it', () { + final stops = [ + for (final (id, step) in const [(3, 3), (1, 1), (2, 2)]) + ApiConfig.pickupFromBooking( + booking(id, { + 'step': step, + 'sequencedat': '2026-08-25T09:00:00+05:30', + }), + ), + ]; + + final (sorted, source) = RouteOrder.sort(stops, distanceTo: distanceTo); + + expect(source, RouteOrderSource.adminSequence); + expect( + sorted.map((s) => '${s['orderid']}').toList(), + ['1', '2', '3'], + reason: + 'the rider is standing on B3 — if this comes back B3-first the ' + 'stamp was ignored and nearest-first won', + ); + }); + + test('no stamp means no route, and the fallback is correct', () { + // The backend's own words: `step: 0` with a null stamp is a rider holding + // fewer than two active stops, or a stop with no coordinates. Neither is + // a route, and re-sorting is the right answer rather than a workaround. + final stops = [ + for (final id in const [1, 2, 3]) + ApiConfig.pickupFromBooking( + booking(id, {'step': 0, 'sequencedat': null}), + ), + ]; + + expect(RouteOrder.hasAdminSequence(stops), isFalse); + + final (sorted, source) = RouteOrder.sort(stops, distanceTo: distanceTo); + expect(source, isNot(RouteOrderSource.adminSequence)); + expect( + '${sorted.first['orderid']}', + '3', + reason: 'with no route assigned, the nearest stop leads', + ); + }); + + test('a blank stamp is no stamp', () { + final row = ApiConfig.pickupFromBooking( + booking(1, {'step': 0, 'sequencedat': ' '}), + ); + expect(RouteOrder.isSequenced(row), isFalse); + }); + }); + + // ══════════════════════════════════════════════════════════════════════ + // 2 · PER-STOP DISTANCE + // + // Three spellings, because the app writes two of them itself: the contract's + // `compliance.actualkm`, the backend's `riderkms` on a raw booking row, and + // `actualkms` — what `PickupsController` posts on the arrival and pickup + // writes, so it is the key on any record stamped locally before the queue + // caught up. Reading only the first two made a stop the rider had just + // finished report no distance at all. + // ══════════════════════════════════════════════════════════════════════ + group('2 · every distance spelling resolves', () { + test('compliance.actualkm — the contract\'s own', () { + final c = StopCompliance.of({ + 'compliance': {'actualkm': 4.2}, + }); + expect(c.actualKm, 4.2); + }); + + test('riderkms — a row straight off the API', () { + expect(StopCompliance.of({'riderkms': 3.1}).actualKm, 3.1); + }); + + test('actualkms — a row this app stamped itself', () { + expect(StopCompliance.of({'actualkms': '2.75'}).actualKm, 2.75); + }); + + test('the contract wins when more than one is present', () { + final c = StopCompliance.of({ + 'compliance': {'actualkm': 9.9}, + 'riderkms': 3.1, + 'actualkms': '2.75', + }); + expect( + c.actualKm, + 9.9, + reason: 'the authoritative figure must outrank the locally stamped one', + ); + }); + + test('riderkms outranks the local stamp', () { + final c = StopCompliance.of({'riderkms': 3.1, 'actualkms': '2.75'}); + expect(c.actualKm, 3.1); + }); + + test('no distance at all is null, not zero', () { + // Null is what the UI turns into the em dash. A zero would render as a + // real measurement of nothing, which is a different claim. + expect(StopCompliance.of(const {}).actualKm, isNull); + expect(StopCompliance.of({'riderkms': 0}).actualKm, isNull); + expect(StopCompliance.of({'actualkms': ''}).actualKm, isNull); + }); + }); + + // ══════════════════════════════════════════════════════════════════════ + // 3 · THE WEEK'S DISTANCE + // + // `GET /miler/earnings` answers with six totals for the period asked for and + // no series. The app read a `breakdown` array that is not on the contract, so + // it was always null and the chart drew an empty week while the rider had + // ridden all of it. + // + // Seven daily calls build the week now. The fast path stays, so the day the + // field ships the seven calls stop — and that switch has to be automatic and + // has to refuse anything it cannot draw. + // ══════════════════════════════════════════════════════════════════════ + group('3 · weekly distance: fast path and fallback', () { + late List asked; + + /// Answers `weekly` with [weekly] and every `daily` with its own total. + setUp(() => SharedPreferences.setMockInitialValues({})); + + void stub({ + Map weekly = const {'total_kms': 40}, + double dailyKm = 5, + }) { + asked = []; + MilerApi.client = MockClient((req) async { + asked.add(req.url); + final period = req.url.queryParameters['period']; + final body = period == 'weekly' ? weekly : {'total_kms': dailyKm}; + return http.Response( + jsonEncode({'success': true, 'data': body}), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + } + + int dailyCalls() => + asked.where((u) => u.queryParameters['period'] == 'daily').length; + + test( + 'FAST PATH · a valid breakdown is used, and no daily call is made', + () async { + stub( + weekly: { + 'total_kms': 40.5, + // The backend's own example shape: ISO dates, not weekday names. + 'breakdown': [ + {'day': '2026-08-19', 'kms': 18.4}, + {'day': '2026-08-20', 'kms': 22.1}, + ], + }, + ); + + final res = await RiderWeeklyKmController().getRiderWeeklyKms(1); + final details = res['details'] as List; + + expect(details, hasLength(2)); + expect(res['total_kms'], 40.5); + expect( + dailyCalls(), + 0, + reason: 'the seven daily calls must stop the day breakdown ships', + ); + }, + ); + + test('FAST PATH · an ISO date is labelled with the day it names', () async { + stub( + weekly: { + 'total_kms': 18.4, + 'breakdown': [ + {'day': '2026-08-19', 'kms': 18.4}, + ], + }, + ); + + final res = await RiderWeeklyKmController().getRiderWeeklyKms(1); + final first = (res['details'] as List).first; + + // 2026-08-19 is a Wednesday. The chart labels a bar with the first three + // characters of `day`, so an un-normalised ISO date renders as `202`. + expect(first.day, 'Wed'); + expect(first.kms, 18.4); + }); + + test('FAST PATH · a weekday name is passed through untouched', () async { + stub( + weekly: { + 'breakdown': [ + {'day': 'Mon', 'kms': 3}, + ], + }, + ); + final res = await RiderWeeklyKmController().getRiderWeeklyKms(1); + expect((res['details'] as List).first.day, 'Mon'); + expect(dailyCalls(), 0); + }); + + for (final (name, bad) in <(String, Object?)>[ + ('absent', null), + ('empty', []), + ('not a list', 'nope'), + ('entries that are not maps', ['Mon', 'Tue']), + ( + 'an entry with no day', + [ + {'kms': 4}, + ], + ), + ( + 'an entry whose distance will not parse', + [ + {'day': 'Mon', 'kms': 'lots'}, + ], + ), + ]) { + test('FALLBACK · $name breakdown falls through to seven calls', () async { + stub(weekly: {'total_kms': 35, if (bad != null) 'breakdown': bad}); + + final res = await RiderWeeklyKmController().getRiderWeeklyKms(1); + final details = res['details'] as List; + + expect( + dailyCalls(), + 7, + reason: 'a series the chart cannot draw is not a series', + ); + expect(details, hasLength(7)); + expect( + details.every((d) => d.kms == 5), + isTrue, + reason: 'each bar is its own day\'s total_kms', + ); + }); + } + + test( + 'FALLBACK · the daily series is labelled and ordered oldest first', + () async { + stub(); + final res = await RiderWeeklyKmController().getRiderWeeklyKms(1); + final days = (res['details'] as List).map((d) => d.day).toList(); + + expect(days, hasLength(7)); + expect(days.toSet(), hasLength(7), reason: 'seven distinct weekdays'); + expect( + days.last, + const [ + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat', + 'Sun', + ][DateTime.now().weekday - 1], + reason: 'the last bar is today', + ); + }, + ); + + test('FALLBACK · the server\'s weekly total is not overruled', () async { + // Seven daily figures are a reconstruction. The weekly number the backend + // computed is the one that stands. + stub(weekly: {'total_kms': 99}, dailyKm: 5); + final res = await RiderWeeklyKmController().getRiderWeeklyKms(1); + expect(res['total_kms'], 99); + }); + + test( + 'FALLBACK · with no weekly total, the reconstruction stands in', + () async { + stub(weekly: {'total_kms': 0}, dailyKm: 5); + final res = await RiderWeeklyKmController().getRiderWeeklyKms(1); + expect(res['total_kms'], 35); + }, + ); + }); + + // ══════════════════════════════════════════════════════════════════════ + // 3b · THE TWO LIFECYCLE WRITES ACTUALLY LEAVE THE PHONE + // + // The defect these pin: the map sheet's **I've arrived** control never called + // the arrival API. `updateArrivedStatus` was reachable from `homepage.dart` + // and nowhere else, so on the logistics line — where the map sheet is the + // only arrival control — `reached` never fired, the local record was never + // written, and the rung went from ACCEPTED straight to whatever came next. + // + // Nothing raised. A transition that is never attempted looks exactly like one + // that succeeded and was not reflected, which is why this was read as a + // status-mapping bug for as long as it was. + // + // These assert the wire, not the widget: the endpoint, the identifier in the + // path, and the body — with the geofence bypass ON, which is the state the + // build ships in and the thing that was suspected of eating the call. + // ══════════════════════════════════════════════════════════════════════ + group('3b · the arrival and pickup writes reach the network', () { + late List sent; + + void stub() { + sent = []; + MilerApi.client = MockClient((req) async { + sent.add(req); + return http.Response( + jsonEncode({'success': true, 'data': {}}), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + } + + setUp(() { + SharedPreferences.setMockInitialValues({}); + stub(); + }); + tearDown(() => MilerApi.client = http.Client()); + + http.Request only(String fragment) { + final hits = sent.where((r) => r.url.path.contains(fragment)).toList(); + expect( + hits, + hasLength(1), + reason: + 'expected exactly one request to …/$fragment, sent: ' + '${sent.map((r) => r.url.path).toList()}', + ); + return hits.single; + } + + test('ARRIVED · endpoint, path id and body', () async { + await MilerApi.reached(4211, lat: 11.0168, lon: 76.9558); + + final req = only('reached'); + expect(req.method, 'POST'); + // The **booking** id, in the path. Not the assignment id — that one keys + // accept/reject only — and not a consignment, which does not exist yet. + expect(req.url.path, endsWith('/miler/bookings/4211/reached')); + expect(jsonDecode(req.body), {'latitude': 11.0168, 'longitude': 76.9558}); + }); + + test( + 'ARRIVED · a fix-less arrival still posts, with no coordinates', + () async { + // The body omits what it does not have rather than sending zeros, which + // would be a claim about where the rider was standing. + await MilerApi.reached(4211); + expect(jsonDecode(only('reached').body), isEmpty); + }, + ); + + test('PICKED · endpoint, path id and body', () async { + await MilerApi.pickupComplete(4211, lat: 11.0168, lon: 76.9558); + + final req = only('pickup-complete'); + expect(req.method, 'POST'); + expect(req.url.path, endsWith('/miler/bookings/4211/pickup-complete')); + expect(jsonDecode(req.body), {'latitude': 11.0168, 'longitude': 76.9558}); + expect( + req.headers['Idempotency-Key'], + isNotNull, + reason: + 'the pivot is the one write a dropped acknowledgement must not ' + 'be able to run twice', + ); + }); + + test( + 'the provider routes ARRIVED to reached, not to anything else', + () async { + await UpdatePickupProvider().updatePickup({ + 'pickupid': 4211, + 'orderheaderid': 77, + 'orderstatus': 'arrived', + 'riderslat': '11.016800', + 'riderslon': '76.955800', + }); + + final req = only('reached'); + // `pickupid` IS the booking id — see `ApiConfig.pickupFromBooking`, which + // maps `bookingid` onto it. This pins that, because the provider picks + // the path id off the payload and a change there would be silent. + expect(req.url.path, endsWith('/miler/bookings/4211/reached')); + expect(jsonDecode(req.body), { + 'latitude': 11.0168, + 'longitude': 76.9558, + }); + }, + ); + + test('the provider routes PICKED to pickup-complete', () async { + await UpdatePickupProvider().updatePickup({ + 'pickupid': 4211, + 'orderheaderid': 77, + 'orderstatus': 'picked', + 'riderslat': '11.016800', + 'riderslon': '76.955800', + }); + + expect( + only('pickup-complete').url.path, + endsWith('/miler/bookings/4211/pickup-complete'), + ); + }); + + test( + 'a missing booking id refuses the call, it does not post to zero', + () async { + // ── The silent one ── + // + // The provider resolved its path id with `pickupid ?? orderheaderid`, and + // `??` catches only null. Every caller builds the payload with + // `int.tryParse(...) ?? 0`, so a row without a booking id arrived as + // **0** — not null — the fallback never fired, and the app posted to + // `/miler/bookings/0/reached`. + // + // Nothing complained on the phone: arrival is the rider's own report and + // is deliberately not gated on the write landing, so his rung advanced + // and the row looked healthy. The hub was told about booking zero, and + // the console showed no arrival — which is exactly how this was reported. + final res = await UpdatePickupProvider().updatePickup({ + 'pickupid': 0, + 'orderheaderid': 0, + 'orderstatus': 'arrived', + 'riderslat': '11.016800', + 'riderslon': '76.955800', + }); + + expect(sent, isEmpty, reason: 'booking 0 is not a booking'); + expect(res?['status'], isFalse); + }, + ); + + test( + 'a real id still posts, and zero on the other key is ignored', + () async { + await UpdatePickupProvider().updatePickup({ + 'pickupid': 0, + 'orderheaderid': 77, + 'orderstatus': 'arrived', + 'riderslat': '11.016800', + 'riderslon': '76.955800', + }); + expect( + only('reached').url.path, + endsWith('/miler/bookings/77/reached'), + ); + }, + ); + + test('the geofence bypass does not stop the write leaving', () async { + // The suspicion this rules out. `_checkGeofence` returns true early when + // enforcement is off and its success branch contains nothing else — so + // the bypass skips the distance *rejection* and nothing more. + // + // Asserted through the provider rather than by reading the flag, because + // "the request went out with the fence off" is the claim, and the flag is + // only evidence for it. + expect(kBypassGeofenceForTesting, isTrue); + + await UpdatePickupProvider().updatePickup({ + 'pickupid': 4211, + 'orderstatus': 'arrived', + 'riderslat': '11.016800', + 'riderslon': '76.955800', + }); + + expect(sent, isNotEmpty, reason: 'the bypass swallowed the request'); + expect(only('reached').url.path, contains('/4211/reached')); + }); + }); + + // ══════════════════════════════════════════════════════════════════════ + // 3c · THE PICKUP MILESTONE IS NOT THE DELIVERY STATUS + // + // The backend's booking lifecycle, confirmed by them and with no Arrived rung + // anywhere in it: + // + // pending → miler_assigned → pickup_scheduled → converted_to_consignment + // → out_for_delivery / active → delivered + // + // Three of those words carry the **pickup** milestone and all three parsed as + // `unknown`: `pickup_scheduled`, `converted_to_consignment` and `picked_up`. + // So the one field the pickup UI reads had nothing in it, and `active` — a + // *delivery* state, which does parse — won by default. That is the second + // half of "Picked shows as Active"; the first was the app writing `active` + // onto the rung itself. + // + // Nothing about the backend's meanings is changed here. `active` still means + // active, and the delivery surfaces still read it that way. What is fixed is + // that the pickup surfaces resolve the *pickup* fact and it outranks a + // delivery state that came after it. + // ══════════════════════════════════════════════════════════════════════ + group('3c · the pickup rung, derived from authoritative facts', () { + setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan)); + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + Map row(String status) => { + 'orderid': 'B1', + 'pickupid': 'B1', + 'sourcename': 'Vidhya Kitchen', + 'orderstatus': status, + }; + + StopStatus rung( + String status, { + Set collected = const {}, + Set arrived = const {}, + Set released = const {}, + Set accepted = const {'B1'}, + }) => MilkRun.stageOf( + row(status), + acceptedIds: accepted, + collectedIds: collected, + arrivedIds: arrived, + outForDeliveryIds: released, + ); + + test('the backend\'s pickup words parse at all', () { + // Each of these was StopStatus.unknown. + expect(stopStatusFromRaw('pickup_scheduled'), StopStatus.accepted); + expect(stopStatusFromRaw('converted_to_consignment'), StopStatus.picked); + expect(stopStatusFromRaw('picked_up'), StopStatus.picked); + }); + + test('pickup_scheduled, nothing else → Accepted', () { + expect(rung('pickup_scheduled'), StopStatus.accepted); + }); + + test('pickup_scheduled + an arrival fact → Arrived', () { + expect( + rung('pickup_scheduled', arrived: {'B1'}), + StopStatus.arrived, + reason: + 'the arrival is the rider\'s own record; nothing outranks it ' + 'here because nothing further along has happened', + ); + }); + + test('converted_to_consignment → Picked', () { + expect(rung('converted_to_consignment'), StopStatus.picked); + }); + + test('picked_up → Picked', () { + expect(rung('picked_up'), StopStatus.picked); + }); + + test('active + pickup evidence → Picked on the pickup UI', () { + // The case the whole separation exists for. The delivery lifecycle has + // moved on; the pickup milestone did not un-happen. + expect(rung('active', collected: {'B1'}), StopStatus.picked); + }); + + test('out_for_delivery + pickup evidence → Picked on the pickup UI', () { + expect(rung('out_for_delivery', collected: {'B1'}), StopStatus.picked); + }); + + test('delivered keeps its own rung, not the pickup one', () { + // A delivered stop is finished. The pickup milestone is history, and the + // rung a rider is shown is the one that is current. + expect(rung('delivered', collected: {'B1'}), StopStatus.delivered); + }); + + test('active WITHOUT pickup evidence stays active', () { + // The line the brief drew: do not infer Picked merely because a booking + // is Active. Without a collection fact there is nothing to infer from. + // + // `accepted` is emptied here on purpose: the local accept store is + // checked below `active` in the ladder, so leaving it in would answer + // `accepted` and the test would pass for the wrong reason. + expect(rung('active', accepted: const {}), StopStatus.active); + }); + + test('the delivery resolver still reads active as active', () { + // Nothing about the backend's meaning is being renamed. + expect(stopStatusFromRaw('active'), StopStatus.active); + expect(stopStatusFromRaw('out_for_delivery'), StopStatus.outForDelivery); + }); + + test('an arrival record cannot walk a picked stop backwards', () { + expect( + rung('converted_to_consignment', arrived: {'B1'}, collected: {'B1'}), + StopStatus.picked, + ); + expect( + rung('out_for_delivery', arrived: {'B1'}, released: {'B1'}), + StopStatus.outForDelivery, + ); + expect(rung('delivered', arrived: {'B1'}), StopStatus.delivered); + }); + }); + + // ══════════════════════════════════════════════════════════════════════ + // 3d · START DELIVERY IS THE ONLY THING THAT MAKES A ROUND ACTIVE + // + // The one rider action that moves `Collected_By_Miler → Out_for_Delivery`. + // Not pickup-complete, not opening the Deliveries tab, not navigation, not + // availability — all of which have at some point been mistaken for it. + // ══════════════════════════════════════════════════════════════════════ + group('3d · start-delivery, and what must not call it', () { + late List sent; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + sent = []; + MilerApi.client = MockClient((req) async { + sent.add(req); + return http.Response( + jsonEncode({'success': true, 'data': {}}), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + }); + tearDown(() => MilerApi.client = http.Client()); + + test('it is keyed on the consignment, with no body', () async { + await MilerApi.startDelivery('C-4211'); + + final req = sent.single; + expect(req.method, 'POST'); + // The **consignment**, not the booking. They are different sequences and + // posting one where the other belongs is a 404 the rider is shown as + // success. + expect( + req.url.path, + endsWith('/miler/consignments/C-4211/start-delivery'), + ); + expect( + jsonDecode(req.body), + isEmpty, + reason: + 'the contract takes no parameters; inventing one is how a ' + 'handler starts rejecting a call that used to work', + ); + expect(req.headers['Idempotency-Key'], isNotNull); + }); + + test('pickup-complete does not start a round by itself', () async { + await MilerApi.pickupComplete(4211, lat: 11.0, lon: 76.9); + + expect( + sent.where((r) => r.url.path.contains('start-delivery')), + isEmpty, + reason: + 'Picked has to stay visible until the rider sets off; a ' + 'pickup that starts its own round erases the rung', + ); + expect(sent.single.url.path, contains('pickup-complete')); + }); + + test('availability is its own call and touches no lifecycle', () async { + await MilerApi.setAvailability('On_Pickup'); + + final req = sent.single; + expect(req.url.path, contains('/miler/availability')); + expect( + req.url.path, + isNot(contains('bookings')), + reason: 'availability is a fact about the RIDER', + ); + expect(req.url.path, isNot(contains('consignments'))); + }); + }); + + // ══════════════════════════════════════════════════════════════════════ + // 4 · ARRIVED PRECEDENCE + // + // The local store exists only because `reached` did not persist. It is the + // weakest record in the app and its one rule is that it must never compete + // with the server: it may speak where the server has said nothing further + // along, and nowhere else. + // + // The rule used to be three exclusions — not complete, not cancelled, not + // rejected — and `isWorkComplete` is line-dependent: on a milk run it means + // *delivered or cancelled* only. So `picked`, `outForDelivery` and + // `deliveryArrived` all passed it, and a stale local record could walk a stop + // the hub knew was on the road back to a rung the rider left an hour ago. + // ══════════════════════════════════════════════════════════════════════ + group('4 · the local ARRIVED record never outranks the server', () { + setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan)); + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + /// The shipped rule, reached through the same public surface `_fetchQueues` + /// uses — the status the server reported, parsed. + bool mayShow(String serverStatus) => + HomepageArrivalPrecedence.localArrivalMayShow( + stopStatusFromRaw(serverStatus), + ); + + test('it may speak where the server has nothing further along', () { + for (final said in const [ + 'new', + 'assigned', + 'accepted', + 'arrived', + 'this is not a status the app knows', + ]) { + expect( + mayShow(said), + isTrue, + reason: '"$said" is not a rung past arrival', + ); + } + }); + + test('it is silent once the server has moved the stop on', () { + // Every one of these passed the old three-exclusion test on a milk run. + for (final said in const [ + 'picked', + 'outfordelivery', + 'deliveryarrived', + 'delivered', + 'cancelled', + 'rejected', + ]) { + expect( + mayShow(said), + isFalse, + reason: + '"$said" is the server\'s own answer — a local record that ' + 'overwrote it would walk the stop backwards', + ); + } + }); + + test('the rung the map path writes on arrival is ARRIVED, not ACTIVE', () { + // ── Where "Picked" became "Active" ── + // + // `_startPickupNavigation` wrote `orderstatus = 'active'` onto the row. + // Two separate facts were going into one field: *which stop is live*, + // which `_activePickupOrderId` already carries and is what the LIVE mark + // reads, and *how far up the pickup ladder this stop is*. + // + // `active` was never a server state either. The provider maps it to + // `setAvailability('On_Pickup')` — a fact about the **rider** — because + // there is no rider endpoint that sets a booking Active; + // `Pickup_Scheduled` is system-set. So the word was app-generated and it + // outranked the real rung on every screen reading `orderstatus`. + // + // Pinned on the resolver, not on any widget's text. + expect( + stopStatusFromRaw('active'), + StopStatus.active, + reason: 'active parses as its own thing and is not a pickup rung', + ); + expect(stopStatusFromRaw('active'), isNot(StopStatus.picked)); + expect(stopStatusFromRaw('active').isPicked, isFalse); + expect( + stopStatusFromRaw('active').isFinishedPickup, + isFalse, + reason: 'an active stop is not a collected one', + ); + }); + + test('the server\'s own arrival word resolves to the arrived rung', () { + for (final said in const [ + 'Arrived_At_Pickup', + 'arrived_at_pickup', + 'ARRIVEDATPICKUP', + 'arrived', + ]) { + expect( + stopStatusFromRaw(said), + StopStatus.arrived, + reason: '"$said" must land on the rung the rider was shown', + ); + } + }); + + test('picked and active are distinct rungs, and stay distinct', () { + // The explicit regression: `Picked != Active`. + expect(stopStatusFromRaw('picked'), StopStatus.picked); + expect(stopStatusFromRaw('picked'), isNot(stopStatusFromRaw('active'))); + expect(stopStatusFromRaw('picked').isPicked, isTrue); + }); + + test('and it is silent on a parcel route too', () { + // `isWorkComplete` differs by line, which is exactly what made the old + // blacklist leak. The rule must not depend on which line is running. + ServiceProfile.setActive(ServiceProfile.parcel); + expect(mayShow('picked'), isFalse); + expect(mayShow('accepted'), isTrue); + }); + }); +} diff --git a/test/no_hub_in_rider_copy_test.dart b/test/no_hub_in_rider_copy_test.dart new file mode 100644 index 0000000..8910a4c --- /dev/null +++ b/test/no_hub_in_rider_copy_test.dart @@ -0,0 +1,109 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE RIDER IS NEVER TOLD ABOUT A HUB +/// +/// "Hub" is an internal word. It names a building in the logistics network and +/// a row in the consignment state machine, and neither is something a rider has +/// any use for on a screen. +/// +/// ── The failure that made this a rule ── +/// +/// A meal-run rider slid **Start ride** on a stop 8 km from the kitchen and was +/// answered: *"This parcel is with the hub — it will be delivered from there, +/// not by you."* Every part of that is wrong for him. There is no hub on a +/// hyperlocal round; the food was in his box; and the sentence told him to give +/// up on a stop that was his. He could do nothing with it, because it described +/// a system he does not work in. +/// +/// The state behind it (`Inwarded_at_Hub` and friends) cannot occur on that +/// line at all — see `_handoffHoldIsPossible` — so the message was a bad read +/// wearing confident words. That half is fixed in code. This is the other half: +/// the vocabulary itself does not belong in front of a rider, on any line. +/// +/// ── What is still allowed ── +/// +/// `debugPrint` and `ApiConfig.logGap` are engineering output — they are read +/// in a console by whoever is chasing a backend gap, and "hub" is the right +/// word there. Wire values (`inwarded_at_hub`) are the backend's own spelling +/// and must match it exactly. Identifiers are internal. +/// +/// Everything else — anything a rider can read — says *your office*, or names +/// the destination *base*. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + test('no rider-facing string says "hub"', () { + // A comment, or the backend's own wire spelling. + bool exemptLine(String line) { + final l = line.trimLeft(); + if (l.startsWith('//') || l.startsWith('///') || l.startsWith('*')) { + return true; + } + return l.contains('inwarded_at_hub') || l.contains('Inwarded_at_Hub'); + } + + /// True while the scan is inside a `debugPrint(...)` / `logGap(...)` call. + /// + /// Tracked across lines rather than matched on one, because these calls + /// wrap: the string that names the hub sits three lines below the + /// `debugPrint(` that makes it engineering output rather than copy. A + /// same-line check called those a violation and would have pushed the next + /// person to reword a console log. + var diagDepth = 0; + bool inDiagnostic(String line) { + final opensHere = + diagDepth == 0 && + (line.contains('debugPrint(') || line.contains('logGap(')); + if (diagDepth > 0 || opensHere) { + // Count only from the call itself on the line that opens it. + final from = opensHere + ? line.indexOf(RegExp(r'(debugPrint|logGap)\(')) + : 0; + for (final c in line.substring(from).split('')) { + if (c == '(') diagDepth++; + if (c == ')') diagDepth--; + } + if (diagDepth < 0) diagDepth = 0; + return true; + } + return false; + } + + // "hub" as a WORD inside a quoted literal — `hubid`, `_hubRow` and + // `endsAtHub` are identifiers and none of them reach a screen. + final quoted = RegExp(r"""(['"])((?:\\.|(?!\1).)*)\1"""); + final word = RegExp(r'\bhubs?\b', caseSensitive: false); + + final offenders = []; + for (final e in Directory('lib').listSync(recursive: true)) { + if (e is! File || !e.path.endsWith('.dart')) continue; + // The Xpress reference implementation is not this app's UI, and no live + // tenant can route into it. See `TenantController.deliveryTenantIds`. + if (e.path.contains('/xpress/')) continue; + + final lines = e.readAsLinesSync(); + diagDepth = 0; + for (var i = 0; i < lines.length; i++) { + if (inDiagnostic(lines[i])) continue; + if (exemptLine(lines[i])) continue; + for (final m in quoted.allMatches(lines[i])) { + final literal = m.group(2) ?? ''; + if (word.hasMatch(literal)) { + offenders.add('${e.path}:${i + 1} "$literal"'); + } + } + } + } + + expect( + offenders, + isEmpty, + reason: + 'These strings can reach a rider and name an internal concept.\n' + 'Use "your office" for the people, "base" for the place:\n' + ' ${offenders.join('\n ')}', + ); + }); +} diff --git a/test/order_detail_leg_test.dart b/test/order_detail_leg_test.dart new file mode 100644 index 0000000..ffbf786 --- /dev/null +++ b/test/order_detail_leg_test.dart @@ -0,0 +1,430 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; + +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/home/pickup_preview_sheet.dart'; +import 'package:miler/views/Dashboard/home/stop_detail_sheet.dart'; +import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/views/Dashboard/home/trip_card.dart'; +import 'package:miler/data/bag_manifest.dart'; +import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// WHICH LEG A SHEET IS ABOUT, AND HOW TALL IT IS +/// +/// ── Two surfaces, two subjects ── +/// +/// Home's kitchen card and the order rows inside it answer different +/// questions, and both used to answer the first one. +/// +/// Every order under a counter shares that counter's address, so a sheet +/// opened from an order row that pinned the counter drew the identical map +/// once per order and said nothing about the order the rider actually tapped. +/// What distinguishes one order from the next is the **door it is going to**. +/// +/// The place is not left without a map — it has a better one, on the preview +/// behind the card's third utility, which draws the whole leg from where the +/// rider is standing to the counter with the manifest waiting on it. +/// +/// ── Why the payload's own `type` could not decide it ── +/// +/// `ApiConfig.pickupFromBooking` 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. So the sheet's `_isDropLeg` — which reads that type +/// through `MilkRun.workingKind` — answered "pickup" for every uncollected +/// meal order, and the customer's sheet showed the kitchen. The caller knows +/// what it opened the sheet for; the payload does not. Hence +/// [StopDetailSheet.ofOrder]. +/// +/// ── And the sheet reserved space it did not use ── +/// +/// It was a `DraggableScrollableSheet` at `initialChildSize: 0.78`, which +/// claims 78% of the screen whether or not it has 78% of a screen to say. A +/// meal order ended at its CONTACT tile with a third of the sheet left over as +/// blank paper. It measures its content now, capped at 86%. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + tearDown(() { + Get.reset(); + ServiceProfile.setActive(ServiceProfile.parcel); + }); + + const kitchenStreet = '9 Kitchen Street, RS Puram, Coimbatore 641002'; + const customerStreet = '12, SNS Colony, Peelamedu, Coimbatore 641004'; + + /// An order exactly as the adapter hands it over — including the `pickup` + /// type it stamps on every row, which is the shape that broke. + Map order({bool withDrop = true}) => { + 'orderid': 'D1', + 'pickupid': 1001, + 'step': 1, + 'kitchenname': 'Vidhya Kitchen', + 'sourceid': 'K1', + 'pickupcustomer': 'Joe Mathew', + 'pickupcontactno': '9787698259', + 'pickupaddress': kitchenStreet, + 'pickuplat': 11.0168, + 'pickuplon': 76.9558, + if (withDrop) 'dropaddress': customerStreet, + if (withDrop) 'droplat': 11.0208, + if (withDrop) 'droplon': 76.9598, + 'orderstatus': 'accepted', + 'type': 'pickup', + 'baglabel': 'Bag 1', + 'Quantity': 1, + }; + + Future openSheet( + WidgetTester tester, + Future Function(BuildContext) open, { + double height = 844, + }) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + tester.view.physicalSize = Size(390 * 3, height * 3); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + + // Tear down whatever was mounted before, routes included. Without this a + // test that opens the sheet twice measures the *first* one both times — + // the old route is still on the navigator, and `.first` finds it. + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + + late BuildContext ctx; + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold( + body: Builder( + builder: (c) { + ctx = c; + return const SizedBox.expand(); + }, + ), + ), + ), + ), + ); + + unawaited(open(ctx)); + await tester.pumpAndSettle(const Duration(seconds: 1)); + } + + group('an order points at its own door', () { + testWidgets('opened from an order row, the sheet shows the delivery', ( + tester, + ) async { + await openSheet( + tester, + (c) => StopDetailSheet.show( + c, + stop: order(), + stopNumber: 1, + ofOrder: true, + riderLat: 11.0168, + riderLng: 76.9558, + ), + ); + + expect(find.text('DELIVER TO'), findsOneWidget); + expect(find.text(customerStreet), findsOneWidget); + expect( + find.text(kitchenStreet), + findsNothing, + reason: 'the counter is the card\'s subject, not the order\'s', + ); + }); + + testWidgets('opened for a place, the sheet still shows the place', ( + tester, + ) async { + // A flat group's header IS a location — one stop, one counter — so + // nothing about that case changes. + await openSheet( + tester, + (c) => StopDetailSheet.show(c, stop: order(), stopNumber: 1), + ); + + expect(find.text('PICKUP LOCATION'), findsOneWidget); + expect(find.text(kitchenStreet), findsOneWidget); + expect(find.text(customerStreet), findsNothing); + }); + + testWidgets('a row with no drop falls back without lying about it', ( + tester, + ) async { + // The half-populated payload. What must never happen is `DELIVER TO` + // printed over a kitchen's street: the eyebrow, the street and the pin + // are resolved together, so falling back to the pickup means it stops + // calling itself a drop. + await openSheet( + tester, + (c) => StopDetailSheet.show( + c, + stop: order(withDrop: false), + stopNumber: 1, + ofOrder: true, + ), + ); + + expect(find.text('DELIVER TO'), findsNothing); + expect(find.text('PICKUP LOCATION'), findsOneWidget); + expect(find.text(kitchenStreet), findsOneWidget); + }); + }); + + group('the place keeps its own leg, on the preview', () { + testWidgets('the preview is where the counter is drawn', (tester) async { + // The pickup did not disappear when the order sheet stopped showing it — + // it moved to the surface that is actually about the place, behind the + // card's third utility, where it comes with the manifest. + await openSheet( + tester, + (c) => PickupPreviewSheet.show( + c, + placeName: 'Vidhya Kitchen', + lines: [ + BagLine( + stop: order(), + orderId: 'D1', + customer: 'Joe Mathew', + bag: 'Bag 1', + ), + BagLine( + stop: order(), + orderId: 'D2', + customer: 'Priya Nair', + bag: 'Bag 2', + ), + ], + meters: 3100, + ride: const Duration(minutes: 9), + riderLat: 11.0168, + riderLng: 76.9558, + onNavigate: () {}, + onCall: () {}, + ), + ); + + expect(find.text('Vidhya Kitchen'), findsOneWidget); + expect(find.text('Joe Mathew'), findsOneWidget); + expect(find.text('Priya Nair'), findsOneWidget); + }); + }); + + group('the sheet is as tall as what is on it', () { + Finder scaffold() => find.byType(MilerSheetScaffold).first; + + Finder viewport() => find + .descendant( + of: scaffold(), + matching: find.byType(SingleChildScrollView), + ) + .first; + + Finder content() => + find.descendant(of: viewport(), matching: find.byType(Column)).first; + + testWidgets('a short order reserves no blank paper below it', ( + tester, + ) async { + await openSheet( + tester, + (c) => StopDetailSheet.show( + c, + stop: order(), + stopNumber: 1, + ofOrder: true, + riderLat: 11.0168, + riderLng: 76.9558, + ), + ); + + // ── The measurement that actually names the complaint ── + // + // "Shorter than before" is the wrong assertion: a content-sized sheet is + // sometimes *taller* than the fixed 78% it replaced, because it stopped + // hiding the last tile behind a scroll. What was wrong was the gap — the + // list's viewport was 78% of the screen while the rows inside it ended a + // third of the way up it, and the difference was blank paper. + // + // So: the scroll viewport is exactly as tall as what is inside it. That + // is true of a content-sized sheet at any height, and false of a fixed + // one at every height short of full. + expect( + tester.getSize(viewport()).height, + tester.getSize(content()).height, + reason: 'a viewport taller than its content IS the blank space', + ); + }); + + testWidgets('the sheet grows and shrinks with what is on it', ( + tester, + ) async { + await openSheet( + tester, + (c) => StopDetailSheet.show(c, stop: order(), stopNumber: 1), + ); + final plain = tester.getSize(scaffold()).height; + + await openSheet( + tester, + (c) => StopDetailSheet.show( + c, + stop: order()..['notes'] = 'Gate code 4411, ring the watchman.', + stopNumber: 1, + ), + ); + final withNote = tester.getSize(scaffold()).height; + + expect( + withNote, + greaterThan(plain), + reason: 'a fixed 0.78 would have reported the same number twice', + ); + }); + + testWidgets('a long order is capped, and scrolls rather than overflows', ( + tester, + ) async { + final long = order() + ..['pickupcustomer'] = 'Venkataraman Subramanian Balasubramanian' + ..['notes'] = + 'Gate code 4411, leave with security if unreachable. Second ' + 'building on the left after the temple, ask for the watchman at ' + 'the side entrance, the main gate is locked after nine, and the ' + 'lift stops running at eleven so use the back stairs.' + ..['dropaddress'] = + 'Flat 14B, Second Floor, Sri Venkateswara Residency, 12 SNS ' + 'Colony Extension, Behind the Water Tank, Peelamedu, Coimbatore, ' + 'Tamil Nadu 641004'; + + await openSheet( + tester, + (c) => StopDetailSheet.show( + c, + stop: long, + stopNumber: 7, + ofOrder: true, + bag: 'Bag 1', + ), + ); + + expect(tester.takeException(), isNull); + // The ceiling keeps the scrim — and the route behind it — visible at the + // top of every phone. + expect(tester.getSize(scaffold()).height, lessThanOrEqualTo(844 * 0.87)); + // And the overflow goes to the scroll view rather than off the sheet. + expect( + tester.getSize(content()).height, + greaterThan(tester.getSize(viewport()).height), + ); + expect(find.text('DELIVER TO'), findsOneWidget); + }); + }); + + group('the timeline routes the two taps to different handlers', () { + Future pumpCard( + WidgetTester tester, { + required List> stops, + void Function(Map)? onStopTap, + void Function(Map)? onOrderTap, + }) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + tester.view.physicalSize = const Size(390 * 3, 1400 * 3); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: TripCard( + trip: Trip.fromStops(id: 'T1', stops: stops), + onStopTap: onStopTap, + onOrderTap: onOrderTap, + onToggleSelect: (_) {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + /// The `Bag n ›` block — the one control on an order row that opens its + /// sheet, found by the promise it makes to a screen reader. + Finder openBlock() => find.byWidgetPredicate( + (w) => w is Semantics && w.properties.hint == 'Opens order details', + ); + + Map grouped(String id, String who) => { + 'orderid': id, + 'pickupid': int.parse(id), + 'step': int.parse(id) - 1000, + 'kitchenname': 'Vidhya Kitchen', + 'sourceid': 'K1', + 'pickupcustomer': who, + 'pickupaddress': kitchenStreet, + 'dropaddress': customerStreet, + 'orderstatus': 'accepted', + 'type': 'pickup', + 'pickuplat': 11.01, + 'pickuplon': 76.95, + }; + + testWidgets('tapping an order row calls onOrderTap, not onStopTap', ( + tester, + ) async { + final orders = >[]; + final places = >[]; + + await pumpCard( + tester, + stops: [grouped('1001', 'Joe Mathew'), grouped('1002', 'Priya Nair')], + onStopTap: places.add, + onOrderTap: orders.add, + ); + + // The group is open by default — it is the one the rider is working. + expect(find.text('Joe Mathew'), findsOneWidget); + // The name is the *selection* target; the way into an order's sheet is + // the `Bag 1 ›` block at the end of its row. One gesture, one meaning — + // see the note on `_OrderOpenBlock`. + await tester.tap(openBlock().first); + await tester.pumpAndSettle(); + + expect(orders.length, 1, reason: 'an order row is about one order'); + expect(orders.single['orderid'], '1001'); + expect(places, isEmpty, reason: 'the place has its own handler'); + }); + + testWidgets('onOrderTap falls back to onStopTap when unset', ( + tester, + ) async { + // A caller that does not care keeps the old single-callback behaviour, + // so nothing outside Home has to know this split exists. + final places = >[]; + + await pumpCard( + tester, + stops: [grouped('1001', 'Joe Mathew'), grouped('1002', 'Priya Nair')], + onStopTap: places.add, + ); + + await tester.tap(openBlock().first); + await tester.pumpAndSettle(); + + expect(places.length, 1); + expect(places.single['orderid'], '1001'); + }); + }); +} diff --git a/test/picked_does_not_release_test.dart b/test/picked_does_not_release_test.dart new file mode 100644 index 0000000..b9601c3 --- /dev/null +++ b/test/picked_does_not_release_test.dart @@ -0,0 +1,309 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/data/api_config.dart'; +import 'package:miler/data/api_status.dart'; +import 'package:miler/data/miler_api.dart'; +import 'package:miler/data/consignment_state.dart'; +import 'package:miler/data/lifecycle.dart'; +import 'package:miler/data/milk_run.dart'; +import 'package:miler/data/service_profile.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// PICKED IS NOT ACTIVE, AND ONLY THE SLIDER MOVES IT +/// +/// The production lifecycle the hub, the console and this app must all agree +/// on: +/// +/// ``` +/// Pending → Accepted → Arrived → Picked → Active → Delivered +/// ▲ ▲ +/// pickup-complete start-delivery +/// Collected_By_Miler Out_for_Delivery +/// ``` +/// +/// Two separate defects collapsed **Picked** into **Active**, and fixing either +/// alone leaves the symptom: +/// +/// 1. **Backend.** With `MILER_COLLECTED_STATE_ENABLED` off, `pickup-complete` +/// writes `Out_for_Delivery` itself. Not reachable from this app and not +/// testable here — it is the deployment's behaviour. What IS pinned below +/// is that the app never claims Picked over such a response. +/// +/// 2. **App.** `MyPickups.startPickup` released the consignment when the rider +/// merely *tapped a card* — one tap after Picked, before any slider. That +/// is invisible while the flag is off (the pivot has already released, so +/// the release costs no request) and becomes the whole bug the moment the +/// flag is flipped. Removed; pinned here. +/// +/// These tests are the reason the flag flip is safe. Read them before changing +/// any call site that reaches `MilerApi.startDelivery`. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + // ══════════════════════════════════════════════════════════════════════ + // 1 · PICKED DOES NOT CALL START-DELIVERY + // + // Enforced at the source, because this is a statement about *call sites* + // and no behavioural test can prove the absence of one. Every route to + // `MilerApi.startDelivery` is enumerated; a new one fails this test and has + // to be justified here rather than discovered in production. + // ══════════════════════════════════════════════════════════════════════ + group('only the rider setting off releases a consignment', () { + /// Every `lib/` file that mentions a symbol, with its line numbers. + Map> sitesOf(String symbol) { + final out = >{}; + for (final e in Directory('lib').listSync(recursive: true)) { + if (e is! File || !e.path.endsWith('.dart')) continue; + final lines = e.readAsLinesSync(); + for (var i = 0; i < lines.length; i++) { + final l = lines[i]; + // Prose in a doc comment is not a call. + if (l.trimLeft().startsWith('//') || l.trimLeft().startsWith('///')) { + continue; + } + if (l.contains(symbol)) (out[e.path] ??= []).add(i + 1); + } + } + return out; + } + + test('`MilerApi.startDelivery` has exactly one caller', () { + final sites = sitesOf('MilerApi.startDelivery('); + expect( + sites.keys.toSet(), + {'lib/views/Dashboard/pickups/delivery_actions.dart'}, + reason: + 'the release is `releaseForDelivery`\'s alone. A second caller is ' + 'a second lifecycle, and the console sees both.', + ); + }); + + test('`releaseForDelivery` is reached only from setting off or the door', () { + final sites = sitesOf('releaseForDelivery(') + ..remove('lib/views/Dashboard/pickups/delivery_actions.dart'); + expect( + sites.keys.toSet(), + { + // The Start-ride slider on the stop's own map. + 'lib/views/Dashboard/pickups/map.dart', + // The Start-round bar, and nothing else on that screen. This file + // held the defect: `startPickup` called `startRound` on card open. + 'lib/views/Dashboard/pickups/pickups.dart', + }, + reason: 'a new caller must be a deliberate "the rider has set off"', + ); + }); + + test('tapping a card does not release — the defect, pinned', () { + // `startPickup` opens a stop's map. It must contain no release: the + // rider tapping a card to read an address has not set off, and + // `navigatesToCustomer` is true from the instant Picked succeeds. + final src = File( + 'lib/views/Dashboard/pickups/pickups.dart', + ).readAsStringSync(); + final start = src.indexOf('Future startPickup('); + expect(start, greaterThan(0), reason: 'startPickup was renamed'); + final end = src.indexOf('Future resumePickup(', start); + expect(end, greaterThan(start)); + + final body = src + .substring(start, end) + .split('\n') + .where((l) => !l.trimLeft().startsWith('//')) + .join('\n'); + + expect( + body.contains('startRound('), + isFalse, + reason: + 'opening a stop released it, so the console read Active one tap ' + 'after Picked and before any slider was touched', + ); + expect(body.contains('releaseForDelivery('), isFalse); + }); + }); + + // ══════════════════════════════════════════════════════════════════════ + // 2 · COLLECTED_BY_MILER IS PICKED, AND STAYS PICKED + // ══════════════════════════════════════════════════════════════════════ + group('Collected_By_Miler reads as Picked', () { + test('the pivot response is read as collected, not as released', () { + final t = MilerLifecycle.pickupComplete( + ApiResult( + ok: true, + status: 200, + data: const { + 'bookingid': 78, + 'consignmentid': 34, + 'status': 'Converted_To_Consignment', + 'consignmentstatus': 'Collected_By_Miler', + 'next_action': 'start_delivery', + }, + ), + ); + + expect(t.isConfirmed, isTrue); + expect(t.bookingStatus, BookingStatus.convertedToConsignment); + expect(t.consignmentState, ConsignmentState.collectedByMiler); + expect(t.consignmentId, '34'); + expect(t.awaitsStartDelivery, isTrue); + expect( + t.isCompatibilityMode, + isFalse, + reason: 'the flag is ON in this response — the release is still to come', + ); + }); + + test('a collected stop shows the Picked rung', () { + ServiceProfile.setActive(ServiceProfile.milkMan); + final stop = { + 'orderid': 'A1', + 'orderstatus': ApiConfig.legacyStatusFromConsignment( + 'Collected_By_Miler', + ), + 'consignmentstatus': 'Collected_By_Miler', + }; + expect(stop['orderstatus'], 'picked', reason: 'adapter contract'); + expect( + MilkRun.stageOf(stop, acceptedIds: const {}, collectedIds: const {'A1'}), + StopStatus.picked, + ); + }); + + test('and it still needs releasing — the bar has work to offer', () { + expect( + consignmentStateFromRaw('Collected_By_Miler').needsRelease, + isTrue, + ); + expect( + consignmentStateFromRaw('Collected_By_Miler').isDeliverable, + isFalse, + reason: '`deliver` accepts Out_for_Delivery only', + ); + }); + }); + + // ══════════════════════════════════════════════════════════════════════ + // 3 · START RIDE IS WHAT MAKES IT ACTIVE + // ══════════════════════════════════════════════════════════════════════ + group('Start ride moves collected → out for delivery', () { + test('the released state is the Active rung, and only via the local set', () { + ServiceProfile.setActive(ServiceProfile.milkMan); + final stop = { + 'orderid': 'A1', + 'orderstatus': ApiConfig.legacyStatusFromConsignment('Out_for_Delivery'), + 'consignmentstatus': 'Out_for_Delivery', + }; + + // Before the slider: the rider's own record does not carry it, so the + // rung is Picked even though the consignment is released. + expect( + MilkRun.stageOf(stop, acceptedIds: const {}, collectedIds: const {'A1'}), + StopStatus.picked, + ); + + // After the slider: `startRound` writes the id, and only then. + expect( + MilkRun.stageOf( + stop, + acceptedIds: const {}, + collectedIds: const {'A1'}, + outForDeliveryIds: const {'A1'}, + ), + StopStatus.outForDelivery, + ); + }); + + test('a released consignment is deliverable and needs no second release', () { + final s = consignmentStateFromRaw('Out_for_Delivery'); + expect(s.isDeliverable, isTrue); + expect(s.needsRelease, isFalse); + expect(ConsignmentGate.gateFor(s), DeliverGate.deliverable); + }); + }); + + // ══════════════════════════════════════════════════════════════════════ + // 4 · A STALE OR EARLY ROW CANNOT COLLAPSE PICKED INTO ACTIVE + // + // `GET /miler/bookings` is a poll behind the rider by design. With the flag + // OFF the pivot releases the consignment itself, so the very next poll + // reports `Out_for_Delivery` for a bag still on the counter. The rider's + // card must not jump to Active on that — his own set is what says he set + // off. This is the app half of the compatibility-mode disagreement, and it + // is display only: nothing here is sent to the server. + // ══════════════════════════════════════════════════════════════════════ + group('polling cannot start the round for the rider', () { + test('compatibility mode is reported, never silently adopted', () { + final t = MilerLifecycle.pickupComplete( + ApiResult( + ok: true, + status: 200, + data: const { + 'bookingid': 78, + 'consignmentid': 34, + 'consignmentstatus': 'Out_for_Delivery', + }, + ), + ); + + expect(t.isConfirmed, isTrue); + expect(t.consignmentState, ConsignmentState.outForDelivery); + expect( + t.isCompatibilityMode, + isTrue, + reason: + 'the flag is OFF on this deployment — the pivot released it, and ' + 'the console showing Active is the SERVER\'s state, not a client ' + 'bug. Reported so the trace names the backend.', + ); + expect(t.awaitsStartDelivery, isFalse); + }); + + test('an early Out_for_Delivery row still draws Picked', () { + ServiceProfile.setActive(ServiceProfile.milkMan); + // Exactly what the next poll returns in compatibility mode. + final stop = { + 'orderid': 'A1', + 'orderstatus': 'outfordelivery', + 'consignmentstatus': 'Out_for_Delivery', + }; + expect( + MilkRun.stageOf(stop, acceptedIds: const {}, collectedIds: const {'A1'}), + StopStatus.picked, + reason: + 'the rider has not slid Start ride; the row is the consignment\'s ' + 'state, not his', + ); + }); + + test('but a genuinely finished stop is never dragged backwards', () { + ServiceProfile.setActive(ServiceProfile.milkMan); + for (final raw in const ['Delivered', 'delivered']) { + // Built the way `ApiConfig.pickupFromBooking` builds a real row: the + // consignment's word is translated into `orderstatus`, which is what + // `stopStatusOf` reads. Setting `consignmentstatus` alone would be a + // row this app never sees, and would prove nothing about the ladder. + final stop = { + 'orderid': 'A1', + 'orderstatus': ApiConfig.legacyStatusFromConsignment(raw), + 'consignmentstatus': raw, + }; + expect(stop['orderstatus'], 'delivered', reason: 'adapter contract'); + expect( + MilkRun.stageOf( + stop, + acceptedIds: const {}, + collectedIds: const {'A1'}, + ), + StopStatus.delivered, + reason: 'precedence must only ever hold a stop back, never a close', + ); + } + }); + }); +} diff --git a/test/pickup_delivery_boundary_test.dart b/test/pickup_delivery_boundary_test.dart index cf52a58..bb00a4b 100644 --- a/test/pickup_delivery_boundary_test.dart +++ b/test/pickup_delivery_boundary_test.dart @@ -196,6 +196,38 @@ void main() { ); expect(deliveries.map(MilkRun.idOf), ['A1']); }); + + test('a delivered stop leaves the count, not just the list', () { + // ── The two screens disagreed because they asked different questions ── + // + // Both call `deliveryQueue`, which is supposed to be what stops that + // happening. It cannot, when they pass different arguments: Deliveries + // passed `closedIds`, Home's pill did not. The rider finished a drop, + // watched the tab go 22 → 21, and came back to Home still holding 22. + // + // Worse on Home than a plain lag, because Home *stamps* a closed stop + // `picked` so its row cannot flicker back to LIVE while the queue + // catches up — and `picked` is squarely inside the delivery domain. The + // pill was counting the stamp that existed to hide the stop. + final day = [ + order('B1', status: 'picked'), + order('B2', status: 'picked'), + ]; + + expect(WorkBoundary.deliveryQueue(day).length, 2); + expect( + WorkBoundary.deliveryQueue(day, closedIds: const {'B1'}).length, + 1, + reason: 'the finished drop is off both the list and the count', + ); + + // And it does not reappear on Home instead — closed is closed, not + // handed back across the boundary. + expect( + WorkBoundary.pickupQueue(day, closedIds: const {'B1'}).length, + 0, + ); + }); }); group('logistics — the boundary is acceptance, and that is unchanged', () { diff --git a/test/pickup_locations_test.dart b/test/pickup_locations_test.dart new file mode 100644 index 0000000..9f7a7cd --- /dev/null +++ b/test/pickup_locations_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/data/milk_run.dart'; +import 'package:miler/data/pickup_locations.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE PICKUP IS NAMED BY THE TENANT'S LOCATION LIST +/// +/// The route card's heading — the biggest type on Home — read `Sudharsan`, +/// a contact person, above a distance and an ETA to a place that name does not +/// identify. It came from the booking row's `sourcename`, which the backend +/// fills from `providercompany` / `providerlocation`. +/// +/// The app cannot tell a person from a shop by looking at the string, so the +/// name is **joined** instead: the booking carries the location id, and +/// `GET /admin/tenants/:id/locations` carries the names. +/// +/// The half that matters most is the fallback. That route is under `/admin`, +/// and whether a rider's token is admitted to it is the backend's call — so +/// every one of these proves the app is no worse off than before when the +/// table is empty. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + setUp(PickupLocations.reset); + tearDown(PickupLocations.reset); + + Map stop({ + Object? locationId, + String sourceName = '', + }) => { + 'orderid': 'A1', + if (locationId != null) 'pickuplocationid': locationId, + 'sourcename': sourceName, + }; + + group('with the tenant list loaded', () { + test('the location name replaces the person on the row', () { + PickupLocations.seed({'7': 'Sri Balaji Stores'}); + expect( + MilkRun.sourceNameOf(stop(locationId: 7, sourceName: 'Sudharsan')), + 'Sri Balaji Stores', + ); + }); + + test('it also fills a row that names nothing at all', () { + PickupLocations.seed({'7': 'Gandhipuram Counter'}); + expect( + MilkRun.sourceNameOf(stop(locationId: 7)), + 'Gandhipuram Counter', + ); + }); + + test('`sourceid` and `kitchenid` join on the same table', () { + PickupLocations.seed({'9': 'Peelamedu Kitchen'}); + expect( + MilkRun.sourceNameOf({'orderid': 'A1', 'sourceid': 9}), + 'Peelamedu Kitchen', + ); + expect( + MilkRun.sourceNameOf({'orderid': 'A1', 'kitchenid': '9'}), + 'Peelamedu Kitchen', + ); + }); + + test('an id the tenant list does not hold falls back to the row', () { + PickupLocations.seed({'7': 'Sri Balaji Stores'}); + expect( + MilkRun.sourceNameOf(stop(locationId: 99, sourceName: 'Sudharsan')), + 'Sudharsan', + reason: 'a partial table must not blank out the names it does have', + ); + }); + }); + + group('with no tenant list — the admin route refused, or has not answered', () { + test('the row is read exactly as it was before', () { + expect( + MilkRun.sourceNameOf(stop(locationId: 7, sourceName: 'Sudharsan')), + 'Sudharsan', + ); + expect(MilkRun.sourceNameOf(stop(locationId: 7)), ''); + }); + + test('and grouping still works off the id', () { + // The group key was never the name — it is the counter's id — so a + // missing table cannot merge two kitchens into one card. + final a = stop(locationId: 7); + final b = stop(locationId: 8); + expect(MilkRun.sameSource(a, a), isTrue); + expect(MilkRun.sameSource(a, b), isFalse); + }); + }); + + group('the lookup itself', () { + test('an absent, empty or zero id is never a name', () { + PickupLocations.seed({'7': 'Sri Balaji Stores', '0': 'Nowhere'}); + expect(PickupLocations.nameFor(null), ''); + expect(PickupLocations.nameFor(''), ''); + expect(PickupLocations.nameFor(' '), ''); + expect( + PickupLocations.nameFor(0), + '', + reason: '0 is how this backend spells "unset", not a location', + ); + expect(PickupLocations.nameFor(7), 'Sri Balaji Stores'); + expect(PickupLocations.nameFor('7'), 'Sri Balaji Stores'); + }); + + test('reset empties it, so a sign-out cannot leak names across riders', () { + PickupLocations.seed({'7': 'Sri Balaji Stores'}); + expect(PickupLocations.isLoaded, isTrue); + PickupLocations.reset(); + expect(PickupLocations.isLoaded, isFalse); + expect(PickupLocations.nameFor(7), ''); + }); + }); +} diff --git a/test/profile_pages_test.dart b/test/profile_pages_test.dart index a5cb380..bc7c5d9 100644 --- a/test/profile_pages_test.dart +++ b/test/profile_pages_test.dart @@ -66,19 +66,26 @@ void main() { // still holds is the one that matters: whatever the bar's surface is, the // type on it has to clear contrast against it, and every bar in the app has // to agree on the answer. - testWidgets('is solid brand with a white title', (tester) async { + testWidgets('is the brand ground with a white title', (tester) async { await pump( tester, Scaffold(appBar: milerPageBar('Saved Addresses', onBack: () {})), ); final bar = tester.widget(find.byType(AppBar)); - expect(bar.backgroundColor, ColorConstants.primary); - expect( - bar.flexibleSpace, - isNull, - reason: 'a solid ground needs no surface layered over it', - ); + // ── The fill is transparent, and the surface paints ── + // + // It was the brand as a safety net behind [kBrandGradient]. It cannot be + // one now: the bar's bottom corners are rounded so the page shows through + // them (see [milerBrandSurface]), and an opaque rectangle behind a + // rounded surface fills in exactly the two corners the curve exists to + // open. What must hold is that the bar still *renders* the brand, which + // is the gradient assertion below. + expect(bar.backgroundColor, Colors.transparent); + // [kBrandGradient], as a `flexibleSpace` — a colour cannot be a gradient. + // The fill stays the brand red underneath, so a bar that fails to paint + // its surface comes out the right colour rather than transparent. + expect(bar.flexibleSpace, isNotNull); expect( bar.systemOverlayStyle?.statusBarIconBrightness, Brightness.light, diff --git a/test/retired_platform_test.dart b/test/retired_platform_test.dart new file mode 100644 index 0000000..5c61454 --- /dev/null +++ b/test/retired_platform_test.dart @@ -0,0 +1,65 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE RETIRED PLATFORM IS NOT NAMED ANYWHERE +/// +/// The delivery screens under `lib/xpress` are a verbatim port of a rider app +/// that belonged to a platform which has since been shut down. The port +/// brought its hostnames, its image host and its name in comments with it, and +/// those references outlived the thing they referred to. +/// +/// A dead product's name in a live repo is not a cosmetic problem. It sends the +/// next reader looking for a backend that no longer answers, and it is exactly +/// the sort of string that gets pasted into a config file by somebody trying to +/// make a screen work. +/// +/// ── Why the word is assembled rather than written ── +/// +/// This file has to know the banned string in order to look for it, and a +/// literal here would be the very thing it forbids — the ban would fail its own +/// test. Joining two halves keeps the guard real and keeps the repo clean. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + // The retired platform's name, never spelled in one piece. See above. + final banned = [ + ['nea', 'rle'].join(), + ]; + + test('no source, test or document names the retired platform', () { + final offenders = []; + + for (final dir in const ['lib', 'test']) { + for (final entity in Directory(dir).listSync(recursive: true)) { + if (entity is! File) continue; + if (!entity.path.endsWith('.dart')) continue; + // This file is the one place the string legitimately exists, in two + // halves — skip it rather than assemble a special case for it. + if (entity.path.endsWith('retired_platform_test.dart')) continue; + + final text = entity.readAsStringSync().toLowerCase(); + for (final word in banned) { + if (text.contains(word)) offenders.add(entity.path); + } + } + } + + for (final doc in Directory('.').listSync()) { + if (doc is! File || !doc.path.endsWith('.md')) continue; + final text = doc.readAsStringSync().toLowerCase(); + for (final word in banned) { + if (text.contains(word)) offenders.add(doc.path); + } + } + + expect( + offenders, + isEmpty, + reason: + 'the retired platform is named in: ${offenders.join(', ')} — it was ' + 'shut down, so a reference to it points at nothing and reads as a ' + 'live dependency to the next person through this file', + ); + }); +} diff --git a/test/rider_lifecycle_test.dart b/test/rider_lifecycle_test.dart new file mode 100644 index 0000000..c7a5936 --- /dev/null +++ b/test/rider_lifecycle_test.dart @@ -0,0 +1,357 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/data/milk_run.dart'; +import 'package:miler/data/rider_stage.dart'; +import 'package:miler/data/service_profile.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE RIDER-FACING LIFECYCLE, END TO END +/// +/// ``` +/// Pending → Accepted → Arrived → Picked → Active → Delivered +/// ``` +/// +/// Six stages against a backend lifecycle that has five and no arrival in it. +/// The gap is not an oversight on their side: arrival is an **event**, written +/// by `/reached` as a timestamp beside a status that does not move, and asking +/// them to add a rung would be asking them to model an event as a state. +/// +/// So the app owes the richer workflow, and these hold it to it. Two resolvers +/// are exercised, deliberately, because they answer different questions about +/// the same order: +/// +/// • [riderStageOf] — where is this order in the rider's day? +/// • [MilkRun.stageOf] — how far did the *pickup* get? +/// +/// A stop whose delivery is under way is `Active` on the first and `Picked` on +/// the second, and both are correct. That separation is the point of the whole +/// exercise, so it is asserted rather than assumed. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + const id = 'B1'; + Map row(String status) => { + 'orderid': id, + 'pickupid': id, + 'sourcename': 'Vidhya Kitchen', + 'orderstatus': status, + }; + + RiderStage stage( + String status, { + Set arrived = const {}, + Set collected = const {}, + Set released = const {}, + Set delivered = const {}, + }) => riderStageOf( + row(status), + arrivedIds: arrived, + collectedIds: collected, + outForDeliveryIds: released, + deliveredIds: delivered, + ); + + group('1 · the six stages, walked once in order', () { + test('Pending — the offer', () { + expect(stage('miler_assigned'), RiderStage.pending); + expect(stage('pending'), RiderStage.pending); + }); + + test('Accepted — pickup_scheduled, and nothing else', () { + // The backend's own word for "the rider took it". + expect(stage('pickup_scheduled'), RiderStage.accepted); + }); + + test( + 'Arrived — the arrival record, because no server field carries it', + () { + // `/reached` writes a timestamp; `GET /miler/bookings` does not return + // it. Until it does, the rider's own record is the only evidence there + // is — see `getArrivedOrderIds`. + expect(stage('pickup_scheduled', arrived: {id}), RiderStage.arrived); + }, + ); + + test('Picked — either backend word, or the collected record', () { + expect(stage('converted_to_consignment'), RiderStage.picked); + expect(stage('picked_up'), RiderStage.picked); + expect(stage('pickup_scheduled', collected: {id}), RiderStage.picked); + }); + + test('Active — the delivery is under way', () { + expect(stage('out_for_delivery'), RiderStage.active); + expect(stage('active'), RiderStage.active); + expect( + stage('converted_to_consignment', released: {id}), + RiderStage.active, + reason: 'the rider pressed Start delivery; the round has begun', + ); + }); + + test('Delivered — terminal', () { + expect(stage('delivered'), RiderStage.delivered); + }); + + test('Cancelled sits outside the run', () { + expect(stage('cancelled'), RiderStage.cancelled); + expect(stage('rejected'), RiderStage.cancelled); + }); + + test('the stages are ordered, and each is its own', () { + final walked = [ + stage('miler_assigned'), + stage('pickup_scheduled'), + stage('pickup_scheduled', arrived: {id}), + stage('converted_to_consignment'), + stage('out_for_delivery'), + stage('delivered'), + ]; + expect(walked.toSet(), hasLength(6), reason: 'six distinct stages'); + for (var i = 1; i < walked.length; i++) { + expect( + walked[i].rank, + greaterThan(walked[i - 1].rank), + reason: '${walked[i]} must rank above ${walked[i - 1]}', + ); + } + }); + }); + + group('2 · progress never runs backwards', () { + test('a stale arrival cannot un-pick a collected stop', () { + expect( + stage('converted_to_consignment', arrived: {id}), + RiderStage.picked, + ); + }); + + test('a stale arrival cannot un-start a round', () { + expect(stage('out_for_delivery', arrived: {id}), RiderStage.active); + }); + + test('a stale collection cannot un-deliver an order', () { + expect( + stage('delivered', arrived: {id}, collected: {id}), + RiderStage.delivered, + ); + }); + + test('no local record can revive a cancelled stop', () { + expect( + stage('cancelled', arrived: {id}, collected: {id}, released: {id}), + RiderStage.cancelled, + ); + }); + }); + + group('3 · the two resolvers answer different questions', () { + setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan)); + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + StopStatus pickupRung(String status, {Set collected = const {}}) => + MilkRun.stageOf( + row(status), + acceptedIds: const {}, + collectedIds: collected, + ); + + test('delivery under way: Active overall, Picked on the pickup card', () { + // The case the separation exists for, stated in one test. + expect(stage('active', collected: {id}), RiderStage.active); + expect(pickupRung('active', collected: {id}), StopStatus.picked); + }); + + test('and the pickup milestone is readable from the stage too', () { + expect(stage('out_for_delivery', collected: {id}).pickupComplete, isTrue); + expect(stage('delivered').pickupComplete, isTrue); + expect(stage('pickup_scheduled', arrived: {id}).pickupComplete, isFalse); + expect( + stage('cancelled').pickupComplete, + isFalse, + reason: 'a cancelled stop was never picked', + ); + }); + + test('availability is not a booking status and cannot reach either', () { + // `updateActiveStatus` posts `setAvailability('On_Pickup')` — a fact about + // the **rider**. It has no booking status to write, and the regression it + // caused was the app inventing one. Neither resolver takes availability, + // and there is no spelling of it that parses as a rung. + expect(stopStatusFromRaw('on_pickup'), StopStatus.unknown); + expect(stopStatusFromRaw('On_Pickup'), StopStatus.unknown); + }); + }); + + group('3b · the authoritative contract, field by field', () { + Map booking( + String status, { + Object? reachedAt, + String? consignment, + }) => { + 'orderid': id, + 'pickupid': id, + 'orderstatus': status, + if (reachedAt != null) 'reachedat': reachedAt, + if (consignment != null) 'consignmentstatus': consignment, + }; + + test('Pickup_Scheduled + no reachedat → Accepted', () { + expect(riderStageOf(booking('Pickup_Scheduled')), RiderStage.accepted); + expect( + riderStageOf(booking('Pickup_Scheduled', reachedAt: '')), + RiderStage.accepted, + reason: 'an empty stamp is not an arrival', + ); + expect( + riderStageOf(booking('Pickup_Scheduled', reachedAt: 'null')), + RiderStage.accepted, + reason: 'a stringified JSON null is not an arrival either', + ); + }); + + test('Pickup_Scheduled + reachedat → Arrived, from the server alone', () { + // No local record passed in: this is the rung rebuilt from server data, + // which is what retires the compatibility store. + expect( + riderStageOf( + booking('Pickup_Scheduled', reachedAt: '2026-08-25T12:33:02+05:30'), + ), + RiderStage.arrived, + ); + }); + + test('a malformed stamp is still an arrival, and does not throw', () { + // Read for presence, not for value: the backend does not write the field + // for a booking nobody arrived at, so an unparseable one is a bad clock + // rather than a bad claim. Parsing it and discarding it would lose the + // rung over a formatting detail. + for (final junk in const ['not-a-date', 0, '2026-13-45', true]) { + expect( + riderStageOf(booking('Pickup_Scheduled', reachedAt: junk)), + RiderStage.arrived, + reason: 'reachedat="$junk" threw or was dropped', + ); + } + }); + + test('Collected_By_Miler → Picked', () { + expect( + riderStageOf( + booking( + 'Converted_To_Consignment', + consignment: 'Collected_By_Miler', + ), + ), + RiderStage.picked, + ); + }); + + test('Out_for_Delivery → Active', () { + expect( + riderStageOf( + booking('Converted_To_Consignment', consignment: 'Out_for_Delivery'), + ), + RiderStage.active, + ); + }); + + test('Delivered → Delivered', () { + expect( + riderStageOf( + booking('Converted_To_Consignment', consignment: 'Delivered'), + ), + RiderStage.delivered, + ); + }); + + test('the consignment outranks a stale stamp at every rung', () { + const stamp = '2026-08-25T12:33:02+05:30'; + for (final (state, expected) in const [ + ('Collected_By_Miler', RiderStage.picked), + ('Out_for_Delivery', RiderStage.active), + ('Delivered', RiderStage.delivered), + ]) { + expect( + riderStageOf( + booking('Pickup_Scheduled', reachedAt: stamp, consignment: state), + arrivedIds: {id}, + collectedIds: {id}, + ), + expected, + reason: '$state was dragged back by older evidence', + ); + } + }); + + test('CROSS-CITY · the hub\'s rungs are not this rider being Active', () { + // Confirmed by the backend: a cross-city parcel goes + // Collected_By_Miler → Inwarded_at_Hub → Tripsheet_Loaded → In_Transit + // and is delivered by somebody else. The rider's involvement ended at + // the collection, so Picked is where he stays — and no delivery control + // may be offered for a stop he cannot act on. + for (final hub in const [ + 'Inwarded_at_Hub', + 'Tripsheet_Loaded', + 'In_Transit', + 'Created', + ]) { + final at = riderStageOf( + booking('Converted_To_Consignment', consignment: hub), + ); + expect( + at, + RiderStage.picked, + reason: '"$hub" resolved to $at — the hub half is not his round', + ); + expect(at, isNot(RiderStage.active)); + } + }); + }); + + group('4 · restored from persisted state after a restart', () { + // Reconstruction reads the same evidence the live flow writes, so a + // relaunch lands a stop exactly where the rider left it — the one thing a + // derived lifecycle has to get right, because nothing is stored. + test('pickup_scheduled with no local record → Accepted', () { + expect(stage('pickup_scheduled'), RiderStage.accepted); + }); + + test('pickup_scheduled with a surviving arrival record → Arrived', () { + expect(stage('pickup_scheduled', arrived: {id}), RiderStage.arrived); + }); + + test('converted_to_consignment → Picked, with no local record at all', () { + expect(stage('converted_to_consignment'), RiderStage.picked); + }); + + test('a released round comes back Active', () { + expect( + stage('converted_to_consignment', released: {id}), + RiderStage.active, + ); + }); + + test('a finished stop comes back Delivered', () { + expect(stage('delivered'), RiderStage.delivered); + }); + + test('nothing restores to an earlier rung than it reached', () { + // Every server word, with a full set of stale local records attached. + // None may pull the stop back. + final stale = {id}; + for (final (said, expected) in const [ + ('converted_to_consignment', RiderStage.picked), + ('picked_up', RiderStage.picked), + ('out_for_delivery', RiderStage.active), + ('active', RiderStage.active), + ('delivered', RiderStage.delivered), + ]) { + expect( + riderStageOf(row(said), arrivedIds: stale), + expected, + reason: '"$said" was dragged back by a stale arrival record', + ); + } + }); + }); +} diff --git a/test/route_order_test.dart b/test/route_order_test.dart index 1fb94d3..b19eb34 100644 --- a/test/route_order_test.dart +++ b/test/route_order_test.dart @@ -28,6 +28,8 @@ import 'package:miler/views/Dashboard/home/trip.dart'; /// says so. /// ───────────────────────────────────────────────────────────────────────── void main() { + _adapterCarriesTheStamp(); + TestWidgetsFlutterBinding.ensureInitialized(); setUp(() { @@ -198,7 +200,7 @@ void main() { }); test('each source says out loud what it is', () { - expect(RouteOrderSource.adminSequence.label, 'Hub route'); + expect(RouteOrderSource.adminSequence.label, 'Assigned route'); expect(RouteOrderSource.proximity.label, 'Nearest first'); expect( RouteOrderSource.proximity.explanation, @@ -351,3 +353,51 @@ void main() { }); }); } + +/// ── The adapter has to carry the stamp, or the contract is unreadable ── +/// +/// `RouteOrder` gates on `sequencedat` exactly as the backend specified. That +/// is worth nothing if the field never reaches it: `pickupFromBooking` mapped +/// `step`, `stoptype`, `etaminutes`, `cumulativekms` and `cumulativeeta` and +/// dropped the one that says whether any of them mean anything — so every +/// adapted row looked unsequenced and the app fell back to nearest-first on +/// routes the hub had solved. +void _adapterCarriesTheStamp() { + group('the adapter carries sequencedat', () { + Map booking(Map extra) => { + 'bookingid': 1, + 'bookingno': 'B1', + 'pickupaddress': '19 Thadagam Road', + ...extra, + }; + + test('a sequenced row arrives sequenced', () { + final row = ApiConfig.pickupFromBooking( + booking({'step': 2, 'sequencedat': '2026-08-25T09:00:00+05:30'}), + ); + expect(RouteOrder.isSequenced(row), isTrue); + expect(RouteOrder.sequenceOf(row), 2); + }); + + test('step 0 with no stamp is not a route, and that is not a bug', () { + // The backend's own words: `step: 0` + `sequencedat: null` means the + // rider holds fewer than two active stops, or the stop has no coords. + final row = ApiConfig.pickupFromBooking( + booking({'step': 0, 'sequencedat': null}), + ); + expect(RouteOrder.isSequenced(row), isFalse); + expect( + RouteOrder.hasAdminSequence([row]), + isFalse, + reason: 'nearest-first is the correct answer here, not a fallback bug', + ); + }); + + test('an empty stamp is no stamp', () { + final row = ApiConfig.pickupFromBooking( + booking({'step': 0, 'sequencedat': ' '}), + ); + expect(RouteOrder.isSequenced(row), isFalse); + }); + }); +} diff --git a/test/service_flow_test.dart b/test/service_flow_test.dart index ccf92b5..dbc41c6 100644 --- a/test/service_flow_test.dart +++ b/test/service_flow_test.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/views/helpers/widgets/slide_action.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/data/accepted_store.dart'; import 'package:miler/data/service_profile.dart'; -import 'package:slide_to_submit_button/slide_to_submit_button.dart'; import 'package:miler/Models/stop_status.dart'; import 'package:miler/data/bag_manifest.dart'; @@ -355,18 +356,21 @@ void main() { // file silently. The gesture goes dead and the sheet says why. expect(find.text('Nothing to collect'), findsOneWidget); expect( - find.text('Nothing was handed over — call the hub before you leave.'), + find.text('Nothing was handed over — call your office before you leave.'), findsOneWidget, ); - final gate = tester.widget( - find - .ancestor( - of: find.byType(SlideToSubmit), - matching: find.byType(IgnorePointer), - ) - .first, + // ── Disabled is a different shape, not a dimmed one ── + // + // It used to be the slide control wrapped in an `IgnorePointer`, which + // is a control that still looks draggable and silently is not: the rider + // drags, nothing happens, and he learns the sheet is broken. With + // nothing to collect there is no slide control at all — just an inert + // bar carrying the reason. + expect( + find.byType(MilerSlideAction), + findsNothing, + reason: 'a gesture that cannot fire must not be offered', ); - expect(gate.ignoring, isTrue); }); testWidgets('the outcome splits what he took from what he did not', ( diff --git a/test/shots/activity_record.png b/test/shots/activity_record.png index 7ed8fa4..af6feca 100644 Binary files a/test/shots/activity_record.png and b/test/shots/activity_record.png differ diff --git a/test/shots/activity_round.png b/test/shots/activity_round.png index f20b36a..8cb3b9a 100644 Binary files a/test/shots/activity_round.png and b/test/shots/activity_round.png differ diff --git a/test/shots/activity_small.png b/test/shots/activity_small.png index 312eddb..dfca2f5 100644 Binary files a/test/shots/activity_small.png and b/test/shots/activity_small.png differ diff --git a/test/shots/activity_trips.png b/test/shots/activity_trips.png index b09a2e0..0c3bd35 100644 Binary files a/test/shots/activity_trips.png and b/test/shots/activity_trips.png differ diff --git a/test/shots/deliveries_card_active.png b/test/shots/deliveries_card_active.png index 61619f7..84e9b53 100644 Binary files a/test/shots/deliveries_card_active.png and b/test/shots/deliveries_card_active.png differ diff --git a/test/shots/deliveries_card_picked.png b/test/shots/deliveries_card_picked.png index fdefcc0..761bd0f 100644 Binary files a/test/shots/deliveries_card_picked.png and b/test/shots/deliveries_card_picked.png differ diff --git a/test/shots/home_accepted.png b/test/shots/home_accepted.png index a872a83..3459e90 100644 Binary files a/test/shots/home_accepted.png and b/test/shots/home_accepted.png differ diff --git a/test/shots/home_all_selected.png b/test/shots/home_all_selected.png index 21f796a..e532e35 100644 Binary files a/test/shots/home_all_selected.png and b/test/shots/home_all_selected.png differ diff --git a/test/shots/home_arrived.png b/test/shots/home_arrived.png index 2d3fcf7..95896aa 100644 Binary files a/test/shots/home_arrived.png and b/test/shots/home_arrived.png differ diff --git a/test/shots/home_brief_open.png b/test/shots/home_brief_open.png index ab63458..969cf77 100644 Binary files a/test/shots/home_brief_open.png and b/test/shots/home_brief_open.png differ diff --git a/test/shots/home_group_done.png b/test/shots/home_group_done.png index 05b9e3d..7c72549 100644 Binary files a/test/shots/home_group_done.png and b/test/shots/home_group_done.png differ diff --git a/test/shots/home_long_text.png b/test/shots/home_long_text.png index 445bae1..32bdb47 100644 Binary files a/test/shots/home_long_text.png and b/test/shots/home_long_text.png differ diff --git a/test/shots/home_offline.png b/test/shots/home_offline.png index 7ef0a71..7251c7b 100644 Binary files a/test/shots/home_offline.png and b/test/shots/home_offline.png differ diff --git a/test/shots/home_one_kitchen.png b/test/shots/home_one_kitchen.png index ddd54f6..cb4f710 100644 Binary files a/test/shots/home_one_kitchen.png and b/test/shots/home_one_kitchen.png differ diff --git a/test/shots/home_parcel.png b/test/shots/home_parcel.png index 3598189..6e41b7d 100644 Binary files a/test/shots/home_parcel.png and b/test/shots/home_parcel.png differ diff --git a/test/shots/home_pending.png b/test/shots/home_pending.png index 6cd6582..068f960 100644 Binary files a/test/shots/home_pending.png and b/test/shots/home_pending.png differ diff --git a/test/shots/home_run_complete.png b/test/shots/home_run_complete.png index b48ac96..c0781b4 100644 Binary files a/test/shots/home_run_complete.png and b/test/shots/home_run_complete.png differ diff --git a/test/shots/home_scale_1_3x.png b/test/shots/home_scale_1_3x.png index a317ac9..8c34038 100644 Binary files a/test/shots/home_scale_1_3x.png and b/test/shots/home_scale_1_3x.png differ diff --git a/test/shots/home_scale_2x.png b/test/shots/home_scale_2x.png index 5eb0412..8f399e8 100644 Binary files a/test/shots/home_scale_2x.png and b/test/shots/home_scale_2x.png differ diff --git a/test/shots/home_selected.png b/test/shots/home_selected.png index d0d8d8f..2111996 100644 Binary files a/test/shots/home_selected.png and b/test/shots/home_selected.png differ diff --git a/test/shots/home_three_kitchens.png b/test/shots/home_three_kitchens.png index 49a7455..0c11aec 100644 Binary files a/test/shots/home_three_kitchens.png and b/test/shots/home_three_kitchens.png differ diff --git a/test/shots/home_two_kitchens.png b/test/shots/home_two_kitchens.png index 6cd6582..068f960 100644 Binary files a/test/shots/home_two_kitchens.png and b/test/shots/home_two_kitchens.png differ diff --git a/test/shots/sheet_action_arrive.png b/test/shots/sheet_action_arrive.png index 2213d01..1d35161 100644 Binary files a/test/shots/sheet_action_arrive.png and b/test/shots/sheet_action_arrive.png differ diff --git a/test/shots/sheet_action_arrive_small.png b/test/shots/sheet_action_arrive_small.png index 8c60608..5afd234 100644 Binary files a/test/shots/sheet_action_arrive_small.png and b/test/shots/sheet_action_arrive_small.png differ diff --git a/test/shots/sheet_action_picked.png b/test/shots/sheet_action_picked.png index 184064f..e2fa016 100644 Binary files a/test/shots/sheet_action_picked.png and b/test/shots/sheet_action_picked.png differ diff --git a/test/shots/sheet_detail.png b/test/shots/sheet_detail.png index 70709ec..94825c3 100644 Binary files a/test/shots/sheet_detail.png and b/test/shots/sheet_detail.png differ diff --git a/test/shots/sheet_detail_long.png b/test/shots/sheet_detail_long.png index 84fbee2..d23cb2f 100644 Binary files a/test/shots/sheet_detail_long.png and b/test/shots/sheet_detail_long.png differ diff --git a/test/shots/sheet_duty_off.png b/test/shots/sheet_duty_off.png index c7e0af4..c9f5663 100644 Binary files a/test/shots/sheet_duty_off.png and b/test/shots/sheet_duty_off.png differ diff --git a/test/shots/sheet_duty_on.png b/test/shots/sheet_duty_on.png index c1bf0af..9cd94ce 100644 Binary files a/test/shots/sheet_duty_on.png and b/test/shots/sheet_duty_on.png differ diff --git a/test/shots/sheet_preview.png b/test/shots/sheet_preview.png index a64b271..d10ef98 100644 Binary files a/test/shots/sheet_preview.png and b/test/shots/sheet_preview.png differ diff --git a/test/shots/verify_delivery.png b/test/shots/verify_delivery.png index 0b8e54b..d50af4e 100644 Binary files a/test/shots/verify_delivery.png and b/test/shots/verify_delivery.png differ diff --git a/test/shots/verify_pickup.png b/test/shots/verify_pickup.png index 1f80272..880e31b 100644 Binary files a/test/shots/verify_pickup.png and b/test/shots/verify_pickup.png differ diff --git a/test/shots/verify_shipment.png b/test/shots/verify_shipment.png index b02e35f..98f5096 100644 Binary files a/test/shots/verify_shipment.png and b/test/shots/verify_shipment.png differ diff --git a/test/state_contract_test.dart b/test/state_contract_test.dart index 8dfb7a9..b765e2e 100644 --- a/test/state_contract_test.dart +++ b/test/state_contract_test.dart @@ -98,27 +98,65 @@ void main() { ); }); - test('Arrived_At_Pickup is what confirms it', () async { + // ── What confirms an arrival changed, on the backend's word ── + // + // This demanded `status: "Arrived_At_Pickup"`. The backend team has since + // confirmed there is **no Arrived rung in the booking-status lifecycle**: + // it runs `pickup_scheduled → converted_to_consignment → …`, and `/reached` + // records the arrival beside it as a timestamp. + // + // So the old assertion demanded evidence of a transition the backend never + // claimed to make, and `reached` answered *unconfirmed* forever — logging a + // gap against a healthy endpoint on every arrival. The stamp is the proof; + // the status echoing `pickup_scheduled` is correct and expected. + test('the arrival stamp is what confirms it', () async { stub({ '/reached': jsonRes({ 'success': true, - 'data': {'bookingid': 78, 'status': 'Arrived_At_Pickup'}, + 'data': { + 'bookingid': 78, + 'status': 'pickup_scheduled', + 'reachedat': '2026-08-25T12:33:02+05:30', + }, }), }); final t = MilerLifecycle.reached(await MilerApi.reached(78)); expect(t.isConfirmed, isTrue); - expect(t.bookingStatus, BookingStatus.arrivedAtPickup); - expect(ApiConfig.legacyStatusFromNew('Arrived_At_Pickup'), 'arrived'); + expect( + t.evidence, + contains('2026-08-25T12:33:02+05:30'), + reason: 'the log line has to say what the proof actually was', + ); + // The status is untouched and unremarkable — that is the point. + expect(t.bookingStatus, isNot(BookingStatus.unknown)); }); - test('a response naming no status at all is not confirmation', () async { + test('a stamped arrival confirms whatever the status says', () async { + // Including the old spelling, if a deployment still sends it: the stamp + // is the evidence either way. + stub({ + '/reached': jsonRes({ + 'success': true, + 'data': { + 'status': 'Miler_Assigned', + 'reached_at': '2026-08-25T12:33:02Z', + }, + }), + }); + expect( + MilerLifecycle.reached(await MilerApi.reached(78)).isConfirmed, + isTrue, + ); + }); + + test('a response with no arrival stamp is not confirmation', () async { stub({ '/reached': jsonRes({'success': true, 'data': {}}), }); final t = MilerLifecycle.reached(await MilerApi.reached(78)); expect(t.isUnconfirmed, isTrue); - expect(t.evidence, contains('no status')); + expect(t.evidence, contains('no arrival stamp')); }); test('a refusal is a refusal, with its code', () async { diff --git a/test/status_contract_test.dart b/test/status_contract_test.dart index 8c31b25..cb07558 100644 --- a/test/status_contract_test.dart +++ b/test/status_contract_test.dart @@ -155,11 +155,31 @@ void main() { ); }); - test('and arriving is what puts him on the stop', () { + test('and arriving is its own rung, not "active"', () { + // ── The two used to be one, and it cost the rider his arrival ── + // + // `stopStateOf` folded `arrived` into [StopState.active], so the Home row + // had no arrived to render and the live chip printed the literal word + // **Active** the moment he slid *confirm arrival*. The rung had moved, + // `/reached` had fired and the local record was written — and the one + // word on screen said something else, which is indistinguishable from + // the arrival not registering. + // + // They are different facts: arrived is a rung on the pickup ladder, and + // active is the backend's word for a delivery under way. final stop = {'orderid': 'A1', 'orderstatus': 'arrived'}; expect( stopStateOf(stop, acceptedIds: const {}, rejectedIds: const {}), + StopState.arrived, + ); + expect( + stopStateOf( + {'orderid': 'A1', 'orderstatus': 'active'}, + acceptedIds: const {}, + rejectedIds: const {}, + ), StopState.active, + reason: 'and active still means active', ); }); diff --git a/test/surface_system_test.dart b/test/surface_system_test.dart index 1fc0fbc..cd87607 100644 --- a/test/surface_system_test.dart +++ b/test/surface_system_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/miler_surface.dart'; +import 'package:miler/views/helpers/constants/narrative.dart'; /// ───────────────────────────────────────────────────────────────────────── /// THE LAYERS HAVE TO BE VISIBLE TO BE LAYERS @@ -40,20 +41,35 @@ double contrast(Color a, Color b) { void main() { group('the ladder has real rungs', () { - test('a working surface separates from the canvas without a border', () { + // ── This rung is now bought two ways, not one ── + // + // The old contract was: the tonal step alone has to carry the separation, + // because a card had no border and no shadow — so anything under ~1.25:1 + // meant screens would start drawing their own outlines to compensate. + // + // Cards carry a hairline *and* a soft lift now (see [MilerPanel] and + // [Narrative]), which is the brief's recipe rather than this one, and the + // canvas went light to match. The step is deliberately smaller; what must + // not happen is that it goes to nothing, because then the border is doing + // the whole job and a card on a page reads as a box drawn on paper. + test('a working surface still separates from the canvas', () { final step = contrast(MilerSurface.working, MilerSurface.canvas); expect( step, - greaterThanOrEqualTo(1.25), - reason: - 'white on the canvas is $step : 1 — below ~1.25 a surface does ' - 'not read as a surface, and screens start drawing their own ' - 'outlines to compensate', + greaterThan(1.0), + reason: 'white on the canvas is $step : 1 — the ground has vanished', ); // And not so far that the page turns into a dark frame around content. expect(step, lessThan(1.6)); }); + test('a card carries the edge and the lift the ground no longer does', () { + // The other half of the trade above. If either of these is dropped, the + // canvas has to go back to carrying the step on its own. + expect(Narrative.cardShadow, isNotEmpty); + expect(Narrative.cardBorder, isNot(Narrative.card)); + }); + test('the canvas is never white', () { expect(MilerSurface.canvas, isNot(MilerSurface.working)); expect(MilerSurface.canvas, isNot(const Color(0xFFFFFFFF))); diff --git a/test/tenant_mode_test.dart b/test/tenant_mode_test.dart index 5a38ab6..f432905 100644 --- a/test/tenant_mode_test.dart +++ b/test/tenant_mode_test.dart @@ -225,6 +225,55 @@ void main() { final p = await resolveWith({'tenantname': 'nobody', 'tenantid': 4242}); expect(p.line, ServiceLine.parcel); }); + + test('a known tenant id outranks a name that contradicts it', () async { + // ── The loose end in a one-shot global flag ── + // + // Tenant 13 is the meal client, and its display name is whatever was + // typed into the tenant record — plausibly the owning company's. A name + // normalising to `doormile` matched the Logistics list, won on + // name-first, and the id was never consulted. + // + // On Logistics `MyPickups._unreleased` is empty by design, so the + // **Start round** bar does not render. With + // `MILER_COLLECTED_STATE_ENABLED` on, that rider's collected parcels + // strand at `Collected_By_Miler` with no control to release them. + for (final name in const ['Doormile', 'doormile', 'DOOR-MILE']) { + final p = await resolveWith({'tenantname': name, 'tenantid': 13}); + expect( + p.line, + ServiceLine.milkMan, + reason: 'tenant 13 is the meal client whatever it is called ($name)', + ); + } + }); + + test('a long legal name is harmless — it simply is not an answer', () async { + // The case the backend team asked about. It matches neither list, so it + // falls through to the id exactly as an empty name would. + expect( + TenantController.profileForName('Daily Grubs Foods Pvt Ltd'), + isNull, + ); + final p = await resolveWith({ + 'tenantname': 'Daily Grubs Foods Pvt Ltd', + 'tenantid': 13, + }); + expect(p.line, ServiceLine.milkMan); + }); + + test('a tenant this build has never heard of is still decided by name', + () async { + // The property name-first existed for, and it must survive the change + // above: a NEW meal client, absent from the id list, costs no release. + final p = await resolveWith({'tenantname': 'milkrun', 'tenantid': 999}); + expect(p.line, ServiceLine.milkMan); + + // And the mirror — a new logistics client is not dragged onto the meal + // line by an id nobody has listed. + final q = await resolveWith({'tenantname': 'logistics', 'tenantid': 998}); + expect(q.line, ServiceLine.parcel); + }); }); group('what each mode can and cannot do', () { diff --git a/test/tenant_refresh_test.dart b/test/tenant_refresh_test.dart new file mode 100644 index 0000000..244e1bf --- /dev/null +++ b/test/tenant_refresh_test.dart @@ -0,0 +1,121 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/miler_api.dart'; +import 'package:miler/data/service_profile.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE TENANT, REFRESHED WITHOUT A SIGN-OUT +/// +/// `tenantname` was written at verify-pin and nowhere else, so it only reached +/// a device by way of a **login**. A rider already signed in when the backend +/// began returning the field would never receive it: prefs carry no name, +/// resolution falls back to the tenant id, and an id this app has not been told +/// about lands him on logistics — no **Start delivery** button, and a parcel +/// stranded at `Collected_By_Miler` the moment collected-state is enabled. +/// +/// `GET /miler/profile` returns the same pair, so the answer is refreshable in +/// place. These pin that, and pin the three ways it must fail quietly: a launch +/// with no signal, a signed-out device, and a handler that answers without the +/// field. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + tearDown(() { + MilerApi.client = http.Client(); + ServiceProfile.setActive(ServiceProfile.parcel); + }); + + void stubProfile(Map body, {int code = 200}) { + MilerApi.client = MockClient( + (req) async => http.Response( + json.encode(body), + code, + headers: const {'content-type': 'application/json'}, + ), + ); + } + + test('a signed-in rider picks up his tenant name in place', () async { + SharedPreferences.setMockInitialValues({'authtoken': 'tok'}); + stubProfile({ + 'success': true, + 'data': {'tenantid': 41, 'tenantname': 'DailyGrubs'}, + }); + + await refreshTenantFromProfile(); + + expect( + await resolveServiceProfile(), + ServiceProfile.milkMan, + reason: 'no sign-out should be required to receive the tenant', + ); + }); + + test('the pair is read from inside `user` as well as top level', () async { + // The backend returns it both ways; a handler that later moves it must not + // silently stop resolving riders. + SharedPreferences.setMockInitialValues({'authtoken': 'tok'}); + stubProfile({ + 'success': true, + 'data': { + 'user': {'tenantid': 41, 'tenantname': 'Daily Grubs'}, + }, + }); + + await refreshTenantFromProfile(); + expect(await resolveServiceProfile(), ServiceProfile.milkMan); + }); + + test('a signed-out device asks for nothing', () async { + var called = false; + MilerApi.client = MockClient((req) async { + called = true; + return http.Response('{}', 200); + }); + + SharedPreferences.setMockInitialValues(const {}); + await refreshTenantFromProfile(); + + expect(called, isFalse, reason: 'no token, no request'); + }); + + test('a launch with no signal keeps what it already had', () async { + SharedPreferences.setMockInitialValues({ + 'authtoken': 'tok', + TenantController.kTenantName: 'DailyGrubs', + }); + MilerApi.client = MockClient((req) async => throw const SocketishFailure()); + + // Must not throw: a launch offline lands on the same screen it always did. + await refreshTenantFromProfile(); + expect(await resolveServiceProfile(), ServiceProfile.milkMan); + }); + + test('a response without the field does not erase the stored one', () async { + SharedPreferences.setMockInitialValues({ + 'authtoken': 'tok', + TenantController.kTenantName: 'DailyGrubs', + }); + stubProfile({ + 'success': true, + 'data': {'displayname': 'Rajan A'}, + }); + + await refreshTenantFromProfile(); + // Backgrounded for a known rider, so let it land before asserting. + await Future.delayed(const Duration(milliseconds: 50)); + expect(await resolveServiceProfile(), ServiceProfile.milkMan); + }); +} + +/// A stand-in for a dead network, so the test does not depend on `dart:io` +/// error construction details. +class SocketishFailure implements Exception { + const SocketishFailure(); +} diff --git a/test/tenantname_unblocks_test.dart b/test/tenantname_unblocks_test.dart new file mode 100644 index 0000000..95c410b --- /dev/null +++ b/test/tenantname_unblocks_test.dart @@ -0,0 +1,101 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/service_profile.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// WHAT `tenantname` ON THE LOGIN IS WORTH +/// +/// The backend gates `MILER_COLLECTED_STATE_ENABLED` on every hyperlocal rider +/// having a **Start delivery** button, because with the flag on a parcel with +/// nobody to release it sits at `Collected_By_Miler` forever. +/// +/// That button is not a build flag. It is [ServiceProfile.deliversToCustomer], +/// resolved per-login from the rider's tenant — so the question is not *which +/// build is installed* but *does this rider resolve to the milk-run profile*. +/// +/// ── Two ways to answer, and only one of them scales ── +/// +/// ``` +/// by id a hardcoded set, {13} today. A client whose id we were never +/// told falls through to logistics and gets no button. +/// by name matched against `tenantname`, checked FIRST. `dailygrubs` is +/// already on the list, so the client resolves with no release. +/// ``` +/// +/// `tenantname` is not deployed yet, which is the whole of the risk: every +/// hyperlocal tenant that is not id 13 is currently a strand waiting for the +/// flag to be turned on. These pin both halves — that the name is sufficient +/// on its own, and what happens without it. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + test( + 'the name alone puts a DailyGrubs rider on the milk-run profile', + () async { + // Deliberately no tenant id: this is a client whose id this app has never + // been told, which is exactly the case the id set cannot cover. + SharedPreferences.setMockInitialValues({ + TenantController.kTenantName: 'DailyGrubs', + }); + + final profile = await resolveServiceProfile(); + expect(profile, ServiceProfile.milkMan); + expect( + profile.deliversToCustomer, + isTrue, + reason: 'this is the flag that renders Start delivery', + ); + }, + ); + + test('case and spacing do not decide a rider\'s day', () async { + for (final spelling in const [ + 'dailygrubs', + 'DAILYGRUBS', + ' DailyGrubs ', + // How a human actually types a client name into a tenant record. Each of + // these used to miss, and a miss is a rider with no Start delivery button + // and a parcel stranded at `Collected_By_Miler`. + 'Daily Grubs', + 'daily-grubs', + 'DAILY_GRUBS', + ]) { + SharedPreferences.setMockInitialValues({ + TenantController.kTenantName: spelling, + }); + expect( + await resolveServiceProfile(), + ServiceProfile.milkMan, + reason: '"$spelling" did not resolve', + ); + } + }); + + test('without it, an unrecognised tenant falls back to logistics', () async { + // The strand: flag on, newest build installed, no button to press, and the + // parcel stays at `Collected_By_Miler` because nothing can release it. + SharedPreferences.setMockInitialValues({TenantController.kTenantId: 17}); + + final profile = await resolveServiceProfile(); + expect(profile, ServiceProfile.parcel); + expect(profile.deliversToCustomer, isFalse); + }); + + test('the name decides when the id says nothing', () async { + // Belt and braces: a hyperlocal client sitting on an id this app has not + // been told about must still resolve from its name. + // + // Note 17 is *unlisted*, not contradictory — an id this build knows wins, + // see `tenant_mode_test.dart`. This is the fall-through, which is the whole + // reason a new client needs no release. + SharedPreferences.setMockInitialValues({ + TenantController.kTenantName: 'DailyGrubs', + TenantController.kTenantId: 17, + }); + + expect(await resolveServiceProfile(), ServiceProfile.milkMan); + }); +} diff --git a/test/trip_progress_test.dart b/test/trip_progress_test.dart index 9c0c888..0cc5950 100644 --- a/test/trip_progress_test.dart +++ b/test/trip_progress_test.dart @@ -78,14 +78,14 @@ void main() { await pump(tester, route(4, 2, 11)); // 18 stops, current is #5 expect(find.text('4 of 18 done'), findsOneWidget); expect( - find.text('On stop 5 of 18 · 2 skipped · 2h 10m left, then hub'), + find.text('On stop 5 of 18 · 2 skipped · 2h 10m left, then base'), findsOneWidget, ); }); testWidgets('drops the skip clause when nothing was skipped', (tester) async { await pump(tester, route(3, 0, 5)); - expect(find.text('On stop 4 of 9 · 2h 10m left, then hub'), findsOneWidget); + expect(find.text('On stop 4 of 9 · 2h 10m left, then base'), findsOneWidget); }); // The card, its border, its shadow and its padding are gone — the rail sits @@ -153,10 +153,10 @@ void main() { expect(find.byIcon(LucideIcons.warehouse), findsNothing); }); - testWidgets('a finished route says head back to the hub', (tester) async { + testWidgets('a finished route says head back to base', (tester) async { await pump(tester, route(6, 0, 0, current: false)); expect(find.text('All stops done'), findsOneWidget); - expect(find.text('Head back to the hub'), findsOneWidget); + expect(find.text('Head back to base'), findsOneWidget); }); // The rail numbers the stops the same way the list below it does, and the diff --git a/test/trip_tabs_pinned_test.dart b/test/trip_tabs_pinned_test.dart index f87c7ce..2e6d9d7 100644 --- a/test/trip_tabs_pinned_test.dart +++ b/test/trip_tabs_pinned_test.dart @@ -5,60 +5,46 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:miler/views/Dashboard/home/trip.dart'; import 'package:miler/views/Dashboard/home/trip_tabs.dart'; -/// The Trip 1 / 2 / 3 bar is a **pinned** sliver on Home: it scrolls up with the -/// page, then holds at the top while the stops pass underneath, so switching -/// trips never costs a scroll back up. +/// ───────────────────────────────────────────────────────────────────────── +/// THE SELECTOR STAYS PUT, AND IT STAYS ONE HEIGHT /// -/// Two things here are worth a test. +/// This file used to guard a pinned `SliverPersistentHeader`: the delegate had +/// to declare a fixed extent, [TripSelector]'s content was type that grows with +/// the system font, and the two disagreed by a few pixels at large settings — +/// so `extentFor` derived an over-estimate and these tests measured it from +/// 1.0× to 2.0× to prove it was never short. /// -/// **The extent is computed, and computed height is the classic way to clip a -/// control.** A pinned sliver must declare a fixed height, and [TripTabs] -/// deliberately has none — its own comment records that a hard `54.h` overflowed -/// by a few pixels at large font settings. `extentFor` therefore derives the -/// height from the text scale and over-estimates on purpose. If that estimate is -/// ever short, the tabs are clipped inside the header, silently. So it is -/// measured here from 1.0× to 2.0×. +/// **None of that exists any more.** The selector moved into the brand header, +/// which is fixed by being the Column's first child rather than by being a +/// sliver, so there is no extent to declare and nothing to under-estimate. /// -/// **Pinning itself.** That the bar holds position while content scrolls under -/// it is the entire point of the change, and a one-word edit (`pinned: false`) -/// undoes it invisibly. +/// What survives is the property the arithmetic was protecting, and it is now +/// the widget's own: the control is a **fixed height** that does not move with +/// the text scale. A header that grows a few points at 1.5× pushes the whole +/// page down and re-lays the route out under the rider's thumb. It cannot, +/// because nothing in the control is free-flowing type — the numeral is inside +/// a `FittedBox` that scales it down into the space it has. +/// ───────────────────────────────────────────────────────────────────────── void main() { - // The view is forced to 390x844 logical below, so gestures must aim inside - // that box — x=400 would land outside the viewport and hit nothing. - const inViewport = Offset(195, 500); + Map stop(String id) => { + 'orderid': id, + 'pickupid': id, + 'pickupcustomer': 'Sri Balaji Stores', + 'pickupaddress': '19, Thadagam Road, Coimbatore', + 'orderstatus': 'pending', + }; - const aboveKey = ValueKey('above'); - const belowKey = ValueKey('below'); - - final trips = [ - Trip.fromStops( - id: 't1', - stops: [ - {'orderid': 'A', 'type': 'pickup', 'pickupcustomer': 'Sri Balaji'}, - ], - ), - Trip.fromStops( - id: 't2', - stops: [ - {'orderid': 'B', 'type': 'delivery', 'pickupcustomer': 'Anand'}, - ], - ), - ]; - - /// Pumps the real delegate inside a scroll view, at a given text scale, and - /// hands back the extent it reserved. - Future pump( + Future pump( WidgetTester tester, { + required int trips, + int selected = 0, double textScale = 1.0, - ValueChanged? onSelect, }) async { - tester.view.physicalSize = const Size(1170, 2532); + tester.view.physicalSize = const Size(390 * 3, 844 * 3); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - double? captured; - await tester.pumpWidget( ScreenUtilInit( designSize: const Size(390, 844), @@ -70,142 +56,123 @@ void main() { child: child!, ), home: Scaffold( - body: Builder( - builder: (context) { - captured = TripTabsHeader.extentFor(context); - return CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: Container( - key: aboveKey, - height: 300, - color: Colors.amber, - ), - ), - SliverPersistentHeader( - pinned: true, - delegate: TripTabsHeader( - extent: captured!, - trips: trips, - selectedIndex: 1, - onSelect: onSelect ?? (_) {}, - ), - ), - SliverToBoxAdapter( - child: Container( - key: belowKey, - height: 2000, - color: Colors.grey, - ), - ), - ], - ); - }, + body: Padding( + padding: const EdgeInsets.all(16), + child: TripSelector( + trips: [ + for (var i = 0; i < trips; i++) + Trip.fromStops(id: 't$i', stops: [stop('$i')]), + ], + selectedIndex: selected, + onSelect: (_) {}, + ), ), ), ), ), ); - await tester.pumpAndSettle(); - return captured!; + await tester.pump(); } - group('reserved extent fits the tabs', () { - // 2.0 is beyond Android's slider but within what a determined user can set, - // and it is the case the original 54.h bug was reported against. - for (final scale in [1.0, 1.15, 1.3, 1.5, 1.75, 2.0]) { - testWidgets('at ${scale}x text scale', (tester) async { - final extent = await pump(tester, textScale: scale); + group('the control is one height at every text scale', () { + for (final scale in const [1.0, 1.3, 1.5, 2.0]) { + testWidgets('at ${scale}x', (tester) async { + await pump(tester, trips: 2, textScale: scale); + await tester.pump(const Duration(milliseconds: 400)); expect( - tester.takeException(), - isNull, - reason: 'the tabs must not overflow the reserved extent', - ); - - final tabs = tester.getSize(find.byType(TripTabs)); - expect( - tabs.height, - lessThanOrEqualTo(extent + 0.01), + tester.getSize(find.byType(TripSelector)).height, + TripSelector.height, reason: - 'tabs are ${tabs.height} tall but only $extent was ' - 'reserved — the bar would be clipped', + 'the header must not change height with the font, or the whole ' + 'route below it moves under the rider\'s thumb', ); - - // Over-reserving is the safe direction, but not by a wasteful amount. - expect( - extent - tabs.height, - lessThan(14), - reason: - 'reserving $extent for ${tabs.height} of content is ' - 'more dead space than the safety margin intends', - ); - - // All three slots are still rendered and readable at this scale. - for (final label in ['Trip 1', 'Trip 2', 'Trip 3']) { - expect(find.text(label), findsOneWidget); - } + expect(tester.takeException(), isNull); }); } }); - group('pinning', () { - testWidgets('the bar holds at the top while content scrolls under it', ( - tester, - ) async { - await pump(tester); - final atRest = tester.getRect(find.byType(TripTabs)); - // It starts below the 300pt block above it, not stuck to the top. - expect(atRest.top, greaterThan(200)); + testWidgets('three slots render before the hub has filled them', ( + tester, + ) async { + await pump(tester, trips: 1); + for (final n in ['Trip 1', 'Trip 2', 'Trip 3']) { + expect(find.text(n), findsOneWidget); + } + }); - await tester.dragFrom(inViewport, const Offset(0, -600)); - await tester.pumpAndSettle(); + testWidgets('exactly one segment is marked, and it is the selected one', ( + tester, + ) async { + await pump(tester, trips: 2, selected: 1); + await tester.pump(const Duration(milliseconds: 400)); - final pinned = tester.getRect(find.byType(TripTabs)); - expect( - pinned.top, - lessThan(atRest.top), - reason: 'it should travel up with the page first', - ); - expect( - pinned.top, - lessThan(40), - reason: 'and then hold near the top of the viewport', - ); + final marks = find.byKey(TripSelector.markerKey); + expect(marks, findsOneWidget, reason: 'one trip is current, not two'); - // Scrolling further does not move it again. - await tester.dragFrom(inViewport, const Offset(0, -600)); - await tester.pumpAndSettle(); - expect(tester.getRect(find.byType(TripTabs)), pinned); - }); + // The rule is a short centred mark under its pill rather than a full-width + // underline, so what has to hold is that it is centred on the *right* + // pill — not that it spans it. + final marker = tester.getRect(marks); + final second = tester.getRect(find.text('Trip 2')); + expect( + marker.center.dx, + closeTo(second.center.dx, 6), + reason: 'the rule sits under the pill it belongs to', + ); + expect( + marker.top, + greaterThan(second.bottom), + reason: 'and under it, not through it', + ); + }); - testWidgets('what sits above it does scroll away', (tester) async { - await pump(tester); - expect(find.byKey(aboveKey), findsOneWidget); + testWidgets('the indicator is a rule, not a filled tab', (tester) async { + // The tabs sit straight on the brand ground: no card, no wash, no outline. + // Selection is the travelling rule and the ink weight, and nothing else. + await pump(tester, trips: 2); + await tester.pump(const Duration(milliseconds: 400)); - await tester.dragFrom(inViewport, const Offset(0, -900)); - await tester.pumpAndSettle(); + final marker = tester.getSize(find.byKey(TripSelector.markerKey)); + expect( + marker.height, + lessThanOrEqualTo(4), + reason: 'a 2-3dp rule, not a segment-sized fill', + ); + }); - // The summary / shift / metrics block is meant to leave; only the control - // stays. A sliver that has left the viewport and its cache is unmounted. - expect(find.byKey(aboveKey), findsNothing); - expect(find.byType(TripTabs), findsOneWidget); - }); + testWidgets('a segment is tappable and reports its own index', ( + tester, + ) async { + int? chosen; + tester.view.physicalSize = const Size(390 * 3, 844 * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); - testWidgets('a tab is still tappable once pinned', (tester) async { - var picked = -1; - await pump(tester, onSelect: (i) => picked = i); + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold( + body: Padding( + padding: const EdgeInsets.all(16), + child: TripSelector( + trips: [ + Trip.fromStops(id: 't0', stops: [stop('0')]), + Trip.fromStops(id: 't1', stops: [stop('1')]), + ], + selectedIndex: 0, + onSelect: (i) => chosen = i, + ), + ), + ), + ), + ), + ); + await tester.pump(); - await tester.dragFrom(inViewport, const Offset(0, -900)); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Trip 1')); - await tester.pumpAndSettle(); - expect( - picked, - 0, - reason: 'switching trips from deep in the list is the whole point', - ); - }); + await tester.tap(find.text('Trip 2')); + expect(chosen, 1); }); } diff --git a/test/trip_test.dart b/test/trip_test.dart index c850411..75d00c3 100644 --- a/test/trip_test.dart +++ b/test/trip_test.dart @@ -458,7 +458,9 @@ void main() { acceptedIds: const {}, rejectedIds: const {}, ), - StopState.active, + // Its own rung since the live chip stopped printing `Active` over it — + // see [StopState.arrived]. + StopState.arrived, ); expect( stopStateOf( @@ -657,7 +659,9 @@ void main() { acceptedIds: const {}, rejectedIds: {'a'}, ), - StopState.active, + // Arrived is its own rung now — see [StopState.arrived]. What the + // test is about is unchanged: a server rung outranks a local decision. + StopState.arrived, ); }); diff --git a/test/two_lines_test.dart b/test/two_lines_test.dart index 64822e3..3bd7d89 100644 --- a/test/two_lines_test.dart +++ b/test/two_lines_test.dart @@ -83,7 +83,7 @@ void main() { group('a rider who logs in on a meal tenant', () { setUp(() => ServiceProfile.setActive(ServiceProfile.meals)); - testWidgets('starts at a kitchen, and never comes back to a hub', ( + testWidgets('starts at a kitchen, and never comes back to base', ( tester, ) async { await pumpRoute( @@ -104,8 +104,8 @@ void main() { // His day ends at the last subscriber's door. A return leg to a warehouse // he has never seen is an un-completable step at the end of a route he // has in fact finished. - expect(find.text('RETURN · HUB'), findsNothing); - expect(find.text('START · HUB'), findsNothing); + expect(find.text('RETURN · BASE'), findsNothing); + expect(find.text('START · BASE'), findsNothing); }); testWidgets('sees his route grouped under the kitchen it comes from', ( @@ -172,7 +172,7 @@ void main() { group('a rider who logs in without a meal tenant', () { setUp(() => ServiceProfile.setActive(ServiceProfile.parcel)); - testWidgets('gets the hub-to-hub route he has always had', (tester) async { + testWidgets('gets the base-to-base route he has always had', (tester) async { await pumpRoute( tester, stops: [ @@ -185,7 +185,7 @@ void main() { // customer doors and comes back. The head is shared with the meal run; // the closing leg is what tells the two apart. expect(find.text('TODAY’S RUN'), findsOneWidget); - expect(find.text('RETURN · HUB'), findsOneWidget); + expect(find.text('RETURN · BASE'), findsOneWidget); expect(find.text('START · KITCHEN'), findsNothing); }); @@ -324,7 +324,7 @@ void main() { testWidgets('a parcel day is a loop, and says so', (tester) async { ServiceProfile.setActive(ServiceProfile.parcel); final line = await subtitle(tester); - expect(line, contains('then hub')); + expect(line, contains('then base')); }); }); }