diff --git a/BOTTOM_SHEETS.md b/BOTTOM_SHEETS.md deleted file mode 100644 index 0fa436f..0000000 --- a/BOTTOM_SHEETS.md +++ /dev/null @@ -1,330 +0,0 @@ -# Miler — Bottom Sheets - -Every modal surface that rises from the bottom of the Miler rider app: what it -is, when it appears, what it looks like, and the rules it must obey. - -**Scope.** `lib/` only. `lib/xpress` is the ported Xpress-rider lane and keeps -its own surfaces; it is not covered here and must not be used as a reference. - ---- - -## 1. The one presenter - -Every sheet in this app is opened through **`showMilerSheet()`** -(`lib/views/helpers/widgets/miler_sheet_kit.dart`). No screen calls -`showModalBottomSheet` and configures a route itself. - -```dart -showMilerSheet( - context, - builder: (ctx) => MilerSheetScaffold(child: …), - large: false, // slower entrance for sheets covering most of the screen - isDismissible: true, - enableDrag: true, -); -``` - -It fixes four things so no caller can get them wrong: - -| Setting | Value | Why | -|---|---|---| -| `backgroundColor` | `transparent` | The scaffold paints the glass. An opaque route background shows as a square white corner behind the rounded one. | -| `isScrollControlled` | `true`, always | A sheet that cannot grow past half height is a sheet whose text field the keyboard covers. | -| `sheetAnimationStyle` | `kMilerSheetStyle` / `kMilerLargeSheetStyle` | One entrance curve, two durations. | -| Shape | Owned by `milerGlassSheet` | No caller re-declares a radius. | - -### What this replaced - -An audit found **six** presentation styles for one kind of surface: frosted -glass at radius 28; opaque `pureSurface` at 24; default white at 16 with **no** -`isScrollControlled` (so the keyboard covered the reason field); an ad-hoc -`Container` with its own shadow and a 50pt top margin; `Get.bottomSheet` flat at -16; and a floating card with margins. Five of them drew their own drag handle — -four at 40×4 in `borderSubtle`, which on frosted glass is close to invisible — -and each applied `SafeArea` its own way. - -A rider crossing one shift met most of these. That is what makes an app feel -assembled rather than designed: the same gesture arriving on different-looking -surfaces. - ---- - -## 2. The structure inside - -**`MilerSheetScaffold`** owns the fabric, the handle, the content padding and -the bottom inset. A sheet author writes content and nothing else. - -``` -┌───────────────────────────────────┐ -│ ▬▬▬▬ │ MilerSheetHandle — 40×4, borderStrong -│ │ 10 top / 14 bottom margin -│ ◐ Title │ MilerSheetHeader — 18sp/w700 -│ One supporting line. │ subtitle 13.5sp/w600, badge tinted 21sp -│ │ -│ ───────────────────────────── │ content — the sheet's own -│ │ -│ [ Primary action ] │ -└───────────────────────────────────┘ - 20dp gutter, matching the page beneath -``` - -### The inset rule — one line, and it has bitten this app before - -```dart -bottomInset = max(viewInsets.bottom, viewPadding.bottom) -``` - -The keyboard **replaces** the gesture bar; whichever is up pays the inset. -**Summing them is the double-SafeArea bug** — it floats the CTA a gesture bar's -height above the keyboard. Never add a `SafeArea` inside a sheet. - -The padding is animated (`DesignConstants.motionState`, `easeOutCubic`) because -a sheet that jumps its full keyboard height in one frame reads as a glitch -rather than a response. - -### The handle - -`borderStrong`, **not** `borderSubtle`. At 7% glass over a dimmed page, subtle -grey measures under 1.15:1 against its own ground — so the sheets most worth -flicking away looked least like they could be. - -Hide it (`handle: false`) only for a sheet that must not be casually dismissed. - -### The header badge - -Optional, **tinted, never filled** — a filled disc at this size outweighs the -title it introduces. Pass the accent the subject already wears elsewhere (duty -green, brand red, a stop-kind accent) so the sheet is visibly about the thing -that opened it. - -### `MilerSheetChoiceRow` - -The standard row for a list of answers: label, optional icon, accent, selected -state, optional rule beneath. Label 14.5sp. Used wherever a sheet asks the rider -to pick one of several things. - ---- - -## 3. The sheets - -### Home - -#### Duty — `duty_sheet.dart` -**Opens:** tapping the duty control in the app bar. -**Asks:** `Go on duty?` / `Go off duty?` -**Body:** one sentence of consequence — on duty, the hub can add you to the next -slot and live tracking runs; off duty, the hub stops assigning and tracking -pauses, and any booking you are holding has to be finished first. -**Commits with:** a button. - -> **Why not a slide.** This had accumulated a handle, an icon disc, a title, a -> subtitle, a *drawn diagram of the switch moving*, three icon-and-text -> consequence rows and a slide-to-confirm — eight blocks for a question with two -> answers. The slide was borrowed from the payment flows, and this app's rule is -> that **slides are reserved for money and for custody**. Ending a shift is -> neither. - -#### Stop detail — `stop_detail_sheet.dart` -**Opens:** tapping a stop on the trip card, or a queue row on Deliveries. -**Type:** `DraggableScrollableSheet` — `0.78` initial, `0.5` min, `0.95` max. -**Answers:** *where actually is this* (a small non-interactive map with the hub, -the stop and the leg between them) and *what exactly is here* (full address, -contact, parcel counts, cash, notes, OTP). - -**Layout:** number chip + customer + status → map → `620 m away · 1 min ride` → -`DELIVER TO` + address → `WHAT TO DO HERE` + the task and its requirement → -a two-up **fact grid** (From / Bag / Order / Contact) on one white surface cut -by hairlines → Call and Navigate. - -> The fact grid was a `label ── value` table with a fixed 110pt label column. The -> values — the half a rider needs — sat in a ragged right column and a long one -> wrapped under a wide empty label, putting a hole in the middle of the block. -> Stacked two to a row, the whole set is scanned in a couple of saccades. - -This sheet is **why the card above it can afford to truncate**. The list is for -scanning; everything it drops lives here, one tap away. - -#### Pickup preview — `pickup_preview_sheet.dart` -**Opens:** the `i` / details control on a kitchen, and the kitchen card's own tap. -**Answers:** *where am I going, and what am I collecting there* — the leg drawn -from where the rider is standing, plus the load waiting at the counter. -**Primary action:** `Start navigation` — and **only then** does Miler hand off to -Google Maps. - -> Handing off means the rider leaves Miler: his route, bag counts and manifest -> all go behind another app. Committing to that on the strength of a kitchen's -> name and a straight-line distance is one tap too few. - -#### Rung sheet — `stop_action_sheet.dart` -**Opens:** advancing a stop from Home. -**Asks:** `Arrived at Vidhya Kitchen` / the collection equivalent, with the count -of orders at that counter and the **named manifest**. -**Commits with:** `Slide to confirm arrival` / `Slide to confirm pickup`. - -> Arriving and collecting are claims about the physical world — *I am at this -> counter*, *this bag is in my box*. They are written to the hub, they move food -> and money, and a stray thumb on a moving bike must not be able to make them. - -#### Reject reasons — `homepage.dart` -**Opens:** rejecting one or more assigned stops. -**Asks:** `Why are you rejecting this stop?` (or `…these N stops?`) -**Subtitle:** *The hub needs a reason to re-route it to someone else.* -**Body:** a `MilerSheetChoiceRow` list — too far from my route; shop/customer -closed today; parcel too large for my box; not enough time left in my shift; -address looks wrong. - -#### Turn on location — `homepage.dart` -**Opens:** when a permission or a fix is missing. -**Asks:** `Turn on location` -**Subtitle:** *Bookings and live tracking need your position. Nothing is assigned -to a rider the hub cannot see.* - -#### Product details — `homepage.dart` -**Opens:** from an order row. -**Title:** `Product details`, with an inventory badge. -**Body:** a plain ruled list of whatever the payload actually carries. An empty -payload is **one quiet sentence**, not a padded red box. - ---- - -### Deliveries and the live map - -#### Live stop sheet — `map.dart` -**Type:** `DraggableScrollableSheet`, and the **only** persistent (non-modal) -sheet in the app. Three snap points, no more: - -| Name | Extent | Purpose | -|---|---|---| -| Glance | `0.35` | Map-dominant; ETA still readable | -| **Working** | **`0.45`** | Default. ETA, identity, address and the action, without scrolling | -| Detail | `0.75` | Everything, map still legible | - -**Hierarchy — how long → where → who → what action:** - -``` - ▬▬▬▬ - 6 min ← the dominant operational figure - 1.8 km away · arrive ~10:42 - ───────────────────────── ← one hairline, the sheet's only rule - Joe Mathew ☎ ← identity + Call - 12 SNS Colony, Peelamedu - Bag 8 - [ Navigate ] [ I've arrived ] pickup leg - ⟶ Slide to finish stop delivery leg -``` - -One continuous surface. **No cards inside it** — the hierarchy is typography, -whitespace and that single hairline. - -> The camera's bottom padding is derived from `_sheetWorking` and the real safe -> areas, not from a remembered pixel value. A hardcoded `300.h` agreed with 45% -> only on the device it was measured on: on a 320×568 phone it framed the route -> into a sliver, and at large text the stop pin ended up under the sheet. - -**Call** uses `StopContact.forLeg()` — the kitchen's number on a pickup leg, the -customer's on a delivery leg, and a label that says which. It is omitted -entirely when there is no number rather than offered dead. - -#### How did this stop end? — `map.dart` -**Opens:** *only* after the rider completes the slide on the live sheet. **The -slide itself commits nothing.** -**Title:** `How did this stop end?` -**Subtitle:** *This is recorded with the office straight away.* -**Choices — deliberately unequal:** - -| Choice | Weight | -|---|---| -| **Delivered** | Filled primary — the forward/success path | -| Skipped | Warning/amber — an exception | -| Cancelled | Destructive | - -The slider carries `Semantics(button, label: 'Finish this stop', hint: 'Slide, -or double tap, to choose how the stop ended', onTap: …)` — a drag-only control -is unusable with a screen reader, and with gloves on a cold morning. The -accessible path opens the **same** question, so deliberateness is preserved. - -#### Skip — `skip_sheet.dart` -**Opens:** choosing Skip. -**Body:** the reason list — customer unreachable; customer not at the location; -wrong address; access problem; customer refused; delivery paused; quantity -issue; other. Renders through `MilerSheetChoiceRow`. -**Guard:** at the limit it shows `Skip Limit Reached` — *You have exceeded the -limit of 2 skips within 3 hours.* - -#### Stop brief — `sheet.dart` (`_StopBrief`) -Not a sheet itself: the shared **identity block** used by the map sheet, Update -Status and Skip, so all three name the stop the same way. Before it, the skip -sheet asked "Skip this pickup" without ever saying *which* pickup. - ---- - -### Bookings / orders - -#### Reject booking — `orderstaus_button.dart` -**Title:** `Reject booking` · **Subtitle:** *The hub re-routes it once it knows why.* - -#### Cancel this booking? — `orderstaus_button.dart` -**Title:** `Cancel this booking?` · **Subtitle:** *It returns to pending and the -hub reassigns it.* - -#### Payment confirm — `collect_payment.dart` -A confirm sheet before money is taken. **Slide**, per the money rule. - ---- - -## 4. Rules for a new sheet - -1. **Open it with `showMilerSheet`.** Never configure `showModalBottomSheet` - yourself. -2. **Wrap the content in `MilerSheetScaffold`.** It is the handle, the gutter and - the inset. -3. **No `SafeArea` inside a sheet.** The scaffold already paid the inset. Adding - one is the double-inset bug. -4. **No card inside a sheet.** The sheet *is* the surface. A bordered white panel - on frosted white is a line the rider reads past to reach what he came for. -5. **One header idiom** — `MilerSheetHeader`, badge tinted not filled. -6. **No close button.** The handle and the barrier dismiss. An `X` duplicates - both, and this app had exactly one sheet with one. -7. **Slide is reserved** for custody and money — arrival, collection, delivery - outcome, payment. Everything else commits with a button. -8. **A drag-only control needs `Semantics` with an `onTap` alternative** that - reaches the same confirmation. -9. **Unequal choices look unequal.** Forward path filled, exception amber, - destructive distinct. Never three identical buttons. -10. **Absent, not empty.** A row with no value is not drawn. A group with nothing - in it is not offered. - ---- - -## 5. Known gaps - -**Every modal sheet in `lib/` now goes through `showMilerSheet` + -`MilerSheetScaffold`.** The four direct callers listed in the first version of -this document have been migrated: - -| File | Was | Now | -|---|---|---| -| `skip_sheet.dart` | own `SafeArea` **wrapping** a hand-rolled glass + gutter + handle, plus a ✕ | kit presenter + scaffold + `MilerSheetHeader` | -| `collect_payment.dart` | right route flags written by hand | kit presenter (content already used the scaffold) | -| `multi_map.dart` | own radius 20, **no glass**, `SafeArea` **plus** `viewInsets` padding, 24 gutter, 22sp red title, ✕ | kit presenter + scaffold + header | -| `map.dart` `_PickupBottomSheet` | a `shape: …circular(24)` that clipped nothing behind a transparent route | kit presenter | - -### What remains - -- **`auth.dart` uses `Get.bottomSheet`** for its eight error notices. It is a - controller with no `BuildContext`, which is why it was left; migrating it - means giving the controller a navigator key or moving the notices to the - screens. Until then, auth errors are the one surface that does not speak the - system's language. -- **The empty-state artwork cannot be verified in a test.** `Image.asset` - resolves against a bundle the test binding does not carry, so no finder sees - it. The Activity empty state is asserted by its remaining control instead; - the picture itself needs a device check. -- **The live map sheet has not been visually QA'd** across the device and - text-scale matrix. `_PickupMapScreen` is private and needs Get, - SharedPreferences, Geolocator and a live map to pump; that harness does not - exist yet. -- **`milerGlassSheet`'s blur has not been profiled** on a mid-range Android with - the route animating. Readability and GPU cost outrank glassmorphism on an - outdoor screen; if it measures badly, a high-opacity surface is the answer. - **No profiling was run, so no blur change was made.** diff --git a/DESIGN_SYSTEM.md b/DESIGN_SYSTEM.md deleted file mode 100644 index ca4b030..0000000 --- a/DESIGN_SYSTEM.md +++ /dev/null @@ -1,263 +0,0 @@ -# Miler — Surface & Elevation System - -Why the app looked flat, what replaced it, and the rules that keep it fixed. - -**Scope.** `lib/` only. `lib/xpress` is the ported Xpress-rider lane and is not -part of this system. - ---- - -## 1. The diagnosis, with numbers - -The app did not look flat because it lacked decoration. It looked flat because -**the ground did nothing**, and every screen compensated privately. - -Measured against the page ground: - -| Token | Hex | Contrast vs canvas | Verdict | -|---|---|---|---| -| `neutralLight` | `#F2F4F8` | **1.021 : 1** | indistinguishable | -| `cardSurface` | `#EDEFF4` | **1.023 : 1** | indistinguishable | -| `surface` | `#FCF9F8` | **1.073 : 1** | invisible, and *warm* unlike everything else | -| `pureSurface` | `#FFFFFF` | **1.124 : 1** | the one that mattered — and too close | - -A tonal difference does not begin to read as a *different surface* until roughly -**1.25 : 1**, and on the budget LCD this app ships to it needs the top of that -band. So a white card on a white page was not a card; it was a rectangle you had -to be told about. Every screen that wanted its content to separate therefore grew -its own border or its own shadow — and once one screen does that, they all do, -each in its own way. That is the mechanism that turns one product into a -collection of independently-built Flutter screens. - -Five near-identical light tokens were also five names for about two decisions. - -### The second half of the diagnosis - -`MilerSheet` — the shared body under Home, Deliveries and Activity — painted -**white**. The `daylightSurface` canvas token existed but only two pages used it. -So the app's three main screens were a white page carrying white surfaces: a -1.0 : 1 step, which is not a step at all. - ---- - -## 2. The fix: four layers - -``` - 0 canvas the page itself. Never white. - 1 working a region the rider reads or works in. White, no border. - 2 raised something that acts. Same fill, plus lift. - 3 floating over everything, with a scrim: sheets, dialogs. -``` - -Declared once in `lib/views/helpers/constants/miler_surface.dart` as -`MilerSurface`. Read that ladder when choosing a ground — not the raw colour -constants, which is what let five of them drift into meaning the same thing. - -### Canvas — `#EEF2F7` → `#DEE3EA` - -White now separates at **1.290 : 1** instead of 1.124. Surfaces separate on -their own, so the borders and shadows screens grew to compensate can come off -rather than being piled higher. - -Nothing was traded for it: body text still clears **13.8 : 1**, and every -operational ink clears AA body on both grounds (see §4). - -### Which screen stands on which rung - -The rung is chosen by **what the screen actually draws**, not by preference: - -| Screen | Ground | Because | -|---|---|---| -| Home | working (white) | rows on a spine — **no cards by design** | -| Activity | working (white) | same — a ruled record, not a stack of objects | -| Deliveries | **canvas** | its content genuinely *is* raised objects: the NOW card is a layer-2 command surface and needs a ground to sit on | -| Record / details | canvas | one white sheet plus folded groups | -| All modal sheets | floating | scrim + glass | - -> **The trap, recorded because it was walked into.** Painting Home with the -> canvas produced a uniformly grey screen — it has nothing white on it to -> separate *from*, so the same flatness came back in a different colour. A -> darker ground only helps where something white actually stands on it. - ---- - -## 3. Elevation - -| Layer | Fill | Lift | -|---|---|---| -| working | `pureSurface` | none — the canvas step does the work | -| raised | `pureSurface` | `MilerSurface.raisedShadow` (`shadowSm`) | -| floating | `glassCard` | `MilerSurface.floatingShadow` + **scrim** | - -`MilerSurface.scrim` (`0x66000000`) replaced Flutter's default `black54` on -every sheet. **The scrim is the separation; the shadow is not.** A sheet that -needs a heavier shadow to be legible is a sheet whose scrim is too weak — the -scrim is the knob. - -### The one nesting the system allows - -`MilerSurface.inset` — a quiet tonal block *inside* layer 1, for a fact grid or -a folded group. Only when the block is a different **kind** of thing from what -surrounds it. **Never for grouping**: grouping is whitespace, type and the -timeline, which is why a route reads as a route without a box around each stop. - ---- - -## 4. Colour corrections that fell out of the canvas move - -Two state inks landed in "AA large only" on the deeper ground and were darkened -by ~2% — the hue is indistinguishable at this distance, the arithmetic is not: - -| Token | Was | Now | On canvas | On white | -|---|---|---|---|---| -| `acceptGreen` | `#047857` | `#047354` | 4.25 → **4.54** | 5.86 | -| `warning` | `#AE5008` | `#A44B08` | 4.12 → **4.54** | 5.85 | - -Every operational ink now clears **AA body (4.5:1)** on both grounds. Pinned in -`test/surface_system_test.dart` — a contrast rule that lives only in a comment -is a rule the next hex nudge undoes. - -### What the colours mean - -| Colour | Means | -|---|---| -| Brand red `#960019` | identity, navigation, primary action — **not** error | -| Green | confirmed success, completion | -| Amber | genuine attention/exception only — **never** normal pending | -| Error red | failure, and semantically distinct from brand red | -| Neutral secondary | pending, waiting, metadata | - -> Pending was amber once. On a fresh morning every kitchen is pending, so the -> screen opened in warning and left nothing to say with when a stop actually -> went wrong. - ---- - -## 5. Rules - -1. **Choose a ground from the ladder**, not from the colour constants. -2. **A container is justified when it is a layer** — a modal sheet, a floating - command bar, a grouped operational workspace. Not because the content inside - it is a group. -3. **No card inside a card**, and no white card on a white surface. -4. **If a surface needs a border to be seen, the ground is wrong** — fix the - ground, not the surface. -5. **Never introduce a one-off colour, radius, shadow or spacing number in a - page.** Five near-duplicate tokens is how this started. -6. **Measure.** Any new surface pair needs ≥1.25 : 1 to read as two surfaces, - and any ink needs ≥4.5 : 1 on the darkest ground it can land on. - ---- - -## 6. The second pass: the ladder made visible - -The foundation pass fixed the ground; a device screenshot showed the result -still *read* flat — the silhouette of every page was unchanged. The second -pass gave the system its visible vocabulary, applied identically on Home, -Deliveries, Activity, Account and the sheets: - -- **Hero heads.** A screen's key figure is a numeral (28sp w800, tabular), - captioned small — never a sentence in body type. Home: `15 · stops left` - with a 6pt determinate progress bar (canvas track, brand fill, green when - done) and `9 of 24 done · ≈4h · 38.9 km` under it. Activity: `4 · delivered - today`. Figures clamp at 1.3× text scale; captions ellipsise, numerals never. -- **Wells.** Anything that opens — Home's kitchens, Activity's trips — opens - into a canvas-toned inset (`radiusLg`, small padding). The well is the - ladder's one permitted nesting, and it is the *canvas* tone because that is - the only grey that stands on white (1.290:1). -- **Tags.** A label a rider matches against the physical world (`Bag 3`) is a - small filled tag: white on a well or tinted panel, canvas on white, brand - 10% when selected, error 8% for `Missing`. Same shape everywhere — Home - rows, the pickup sheet, the confirm sheet, the Deliveries queue. -- **Placeholders recede.** An unassigned trip tab drops to `disabledFill` and - w600 and says `None yet`; empty slots never compete with real work. -- **Account joined the ladder**: canvas ground, the identity + day-figures as - one panel, each settings group a panel, labels on the canvas between them — - scoped to the Account tab so the eight other `settings_ui` screens are - untouched. -- **`neutralLight` is not a fill on white.** It measures 1.02:1 there; every - such use found on these pages now takes the canvas tone. The token keeps - its other jobs. - -Pinned by: `home_gutter_test` (hero and route share an edge), -`stop_row_alignment_test` (the well's indent is bounded), -`home_structure_test` (one well, tags under 120pt, no borders or shadows in a -group), `activity_list_test` (a sheet is `working` or `canvas`, never the -warm off-white). - -## 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.**~~ 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. The blur question is moot: - §6b removed every `BackdropFilter` in `lib/`, so there is nothing left to - profile. diff --git a/MILER_FOR_STITCH.md b/MILER_FOR_STITCH.md deleted file mode 100644 index da519db..0000000 --- a/MILER_FOR_STITCH.md +++ /dev/null @@ -1,227 +0,0 @@ -# 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/analysis_options.yaml b/analysis_options.yaml index b79e1e0..742a501 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -11,6 +11,14 @@ analyzer: errors: file_names: ignore unused_field: ignore + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml linter: diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..1923f3d Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a4108d2 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..458bb3b Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..843246c Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..7c6094e Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c0be12a --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 9d5fb44..3ce4b91 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 3a7ce16..9d67f0a 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 41bc885..ab303cf 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 7f1ba86..28f944e 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 7c0a9b8..c233f5d 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index dcc7e10..db3f453 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 5643f9c..f6e47cf 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -19,8 +19,8 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.9.1" apply false - id("org.jetbrains.kotlin.android") version "2.1.0" apply false + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false id("com.google.gms.google-services") version "4.4.2" apply false } diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 2b38540..61f636e 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -34,7 +34,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/vibration/ios" SPEC CHECKSUMS: - Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47 flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89 flutter_tts: b88dbc8655d3dc961bc4a796e4e16a4cc1795833 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index fa05c1b..b8e21bc 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/app-check.git", "state" : { - "revision" : "bb4002485ff867768dec13bf904a2ddb050bd1b1", - "version" : "11.3.0" + "revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902", + "version" : "11.3.1" } }, { @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/firebase-ios-sdk", "state" : { - "revision" : "8d5b4189f1f482df8d5c58c9985ea70491ef5382", - "version" : "12.14.0" + "revision" : "346daa9f46316aa372b35b317e18224acc2e9063", + "version" : "12.18.0" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", "state" : { - "revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64", - "version" : "3.6.0" + "revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55", + "version" : "3.6.1" } }, { @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleAppMeasurement.git", "state" : { - "revision" : "219e564a8510e983e675c94f77f7f7c50049f22d", - "version" : "12.14.0" + "revision" : "f04760d460296cc0fa430935a7be212e5bd67fc5", + "version" : "12.18.0" } }, { @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleDataTransport.git", "state" : { - "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", - "version" : "10.1.0" + "revision" : "ba3358d3c3dbae8ef230b58a46b97ad65e84e974", + "version" : "10.1.1" } }, { @@ -68,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleUtilities.git", "state" : { - "revision" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822", - "version" : "8.1.1" + "revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3", + "version" : "8.1.3" } }, { @@ -86,8 +86,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/gtm-session-fetcher.git", "state" : { - "revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a", - "version" : "5.3.0" + "revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc", + "version" : "5.3.1" } }, { diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index 679455f..44d5d69 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index eb64b4d..316c401 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index e0b9c32..f01f613 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index b68dc73..5044d0c 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index e00820f..99a7076 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index a0ef890..3171156 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 8f92d1c..5dd8e63 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index e0b9c32..f01f613 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 8b95e25..e3ef591 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 0a8d6ad..afaef05 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png index be12c5b..3e922d4 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png index 38d8a9e..687410b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png index 4f0a120..4cb5cc9 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png index 9165490..fc00254 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 0a8d6ad..afaef05 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index e429636..5709b01 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png index eb24f9f..3300d96 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png index 72d94bd..ea776fa 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 9483bcb..67378d9 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 35ad5b1..91c5d09 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 69e5811..59e0c50 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/launcher_icon/doormile_icon_foreground.png b/launcher_icon/doormile_icon_foreground.png new file mode 100644 index 0000000..801f54e Binary files /dev/null and b/launcher_icon/doormile_icon_foreground.png differ diff --git a/launcher_icon/doormile_icon_legacy.png b/launcher_icon/doormile_icon_legacy.png new file mode 100644 index 0000000..d761ab8 Binary files /dev/null and b/launcher_icon/doormile_icon_legacy.png differ diff --git a/lib/Models/stop_status.dart b/lib/Models/stop_status.dart index 86ee77e..c53516b 100644 --- a/lib/Models/stop_status.dart +++ b/lib/Models/stop_status.dart @@ -92,15 +92,28 @@ StopStatus stopStatusFromRaw(dynamic raw) { // 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. + // ── `At_Customer` — settled, and it means arrived at the PICKUP ── + // + // This word was read two ways: `ApiConfig` folded it to *arrived at + // pickup*, and the delivery case further down this switch folded it to + // [StopStatus.deliveryArrived]. Both could not be right, and a wrong guess + // moves a stop between the two halves of the rider's day — so it was left + // unparsed rather than settled on a hunch. + // + // The backend team answered it: `At_Customer` is a + // `milerprofiles.availabilitystatus` value, not a booking or consignment + // state, so it says where the **rider** is and not where the parcel is. The + // only thing that writes it is `POST /miler/bookings/:id/reached` — the + // pickup-arrival action. Nothing sets it on a delivery leg; a rider heading + // to a receiver goes `On_Delivery`. The name is misleading and predates the + // current lifecycle. + // + // So it belongs here, with the other spellings of the pickup arrival. + // `ApiConfig` was right and this file was wrong. case 'arrived_at_pickup': case 'arrivedatpickup': + case 'at customer': + case 'at_customer': return StopStatus.arrived; // ── The two words that mean the pickup is done ── // @@ -131,8 +144,6 @@ StopStatus stopStatusFromRaw(dynamic raw) { return StopStatus.outForDelivery; case 'deliveryarrived': case 'delivery_arrived': - case 'at customer': - case 'at_customer': return StopStatus.deliveryArrived; case 'delivered': return StopStatus.delivered; diff --git a/lib/data/accepted_store.dart b/lib/data/accepted_store.dart index 1e5353f..9371ca7 100644 --- a/lib/data/accepted_store.dart +++ b/lib/data/accepted_store.dart @@ -69,6 +69,7 @@ Future migrateLegacyStores() async { _kArrivedOrderIdsKeyBase, _kCollectedOrderIdsKeyBase, _kConsignmentIdsKeyBase, + _kPivotNextActionKeyBase, _kOutForDeliveryKeyBase, _kNotLoadedKeyBase, _kOrderLabelsKeyBase, @@ -115,6 +116,7 @@ Future clearScopedStores() async { _kArrivedOrderIdsKeyBase, _kCollectedOrderIdsKeyBase, _kConsignmentIdsKeyBase, + _kPivotNextActionKeyBase, _kOutForDeliveryKeyBase, _kNotLoadedKeyBase, _kOrderLabelsKeyBase, @@ -714,6 +716,89 @@ Future forgetConsignmentIds(List orderIds) async { ); } +// ───────────────────────────────────────────────────────────────────────── +// THE PIVOT'S ROUTING ANSWER — which way this parcel went at pickup-complete +// +// `next_action` is the server naming the next leg: `start_delivery` for a +// parcel this rider delivers himself, `inward_at_hub` for one that enters the +// network. It is returned **once**, by the pivot, and it was being logged and +// thrown away — the only trace of it was `lastPivotNextAction`, a single +// in-memory string on a controller, which is not per order and does not +// survive a rebuild. +// +// So a hub-routed parcel was indistinguishable from a hyperlocal one on the +// very next poll, and `NextLegResolver` had nothing to fall back on when the +// consignment read `Created` — the state that cannot decide anything by itself. +// +// ── This is continuity, not authority ── +// +// It is rung 4 of four, below every live server reading, deliberately: it says +// what the server decided *at the pickup*, and if the server has since said +// something different the server wins. See [NextLegResolver] for the full +// precedence table. +// +// ── It should not have to exist ── +// +// The right home for this fact is the queue row. `GET /miler/bookings` does not +// carry `next_action`, which is the logged backend gap this store works around. +// When that field lands, `NextLegResolver` prefers it automatically and this +// becomes a cache nothing reads. +const String _kPivotNextActionKeyBase = 'pivot_next_action_by_order'; + +/// Records the `next_action` the pivot returned for [orderId]. +/// +/// Ignores an empty action rather than storing a blank: an absent instruction +/// and a recorded "no instruction" are different facts, and only the first is +/// true when the server said nothing. +Future rememberPivotNextAction(String orderId, String action) async { + final id = orderId.trim(); + final value = action.trim().toLowerCase(); + if (id.isEmpty || value.isEmpty) return; + final prefs = await SharedPreferences.getInstance(); + final map = await getPivotNextActions(); + map[id] = value; + await prefs.setString( + await _scopedKey(_kPivotNextActionKeyBase), + jsonEncode(map), + ); + debugPrint('[NEXTLEG] recorded $id → $value'); +} + +/// Every pivot instruction this device recorded, by order id. +Future> getPivotNextActions() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(await _scopedKey(_kPivotNextActionKeyBase)); + if (raw == null || raw.isEmpty) return {}; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return {}; + return { + for (final e in decoded.entries) + e.key.toString(): (e.value?.toString() ?? '').toLowerCase(), + }..removeWhere((_, v) => v.isEmpty); + } catch (_) { + return {}; + } +} + +/// Drops instructions for orders that are finished, so the map does not grow +/// for the life of the install. +Future forgetPivotNextActions(List orderIds) async { + if (orderIds.isEmpty) return; + final prefs = await SharedPreferences.getInstance(); + final map = await getPivotNextActions(); + var changed = false; + for (final id in orderIds) { + if (map.remove(id) != null) changed = true; + } + if (changed) { + await prefs.setString( + await _scopedKey(_kPivotNextActionKeyBase), + jsonEncode(map), + ); + } +} + /// Clears ids once they are delivered — or once a short pick says they were /// never in the box to begin with. Without this the set grows for the life of /// the install and yesterday's run keeps today's cards off Home. diff --git a/lib/data/api_config.dart b/lib/data/api_config.dart index 1119eb1..ccce2b4 100644 --- a/lib/data/api_config.dart +++ b/lib/data/api_config.dart @@ -444,6 +444,59 @@ class ApiConfig { pick(['consignmentid', 'consignmentId', 'consignment_id']), ), + // ── Where this parcel goes next, in the server's own word ── + // + // Shipped with request 27. Until it existed, `next_action` was returned + // **once** — by `pickup-complete` — and the app had to keep the pivot's + // answer on the handset to survive a poll, because `Created` is both the + // state a hub-routed parcel settles on *and* the state one holds while + // the pivot is still routing it. That cache is now the fallback rather + // than the source: [NextLegResolver] prefers this field over it, so a + // reinstall, a second device or a routing the office changed mid-day all + // read the live answer. + // + // Values: `pickup`, `start_delivery`, `inward_at_hub`, `deliver`, + // `handed_to_hub`, `none`. Carried verbatim — the resolver owns the + // reading of them, and an unrecognised word must reach it intact so it + // can fall through rather than being flattened here. + 'next_action': s( + pick(['next_action', 'nextaction', 'nextAction']), + ), + + // ── The base this parcel is to be handed in at ── + // + // A nested object, carried whole: id, name, address, pincode, latitude, + // longitude. Null on a hyperlocal parcel, which has no base leg. See + // [HandoverHub], which is the only thing that reads it. + // + // This is the field that makes a handover navigable. Before it the app + // knew a parcel was hub-routed and had no idea which building — every + // `hubLat`/`hubLng` in the codebase was the rider's own position standing + // in for one. + 'next_hub': booking['next_hub'] ?? booking['nexthub'], + + // ── What kind of place this is collected FROM ── + // + // Shipped with request 28. `hub` / `customer` / `merchant` / `store`, on + // the row rather than against a location master — a customer-door pickup + // has no location id at all, so a type held against locations could never + // classify one. + // + // Home titled every logistics pickup group with the rider's own base name + // before this, because the per-booking source was unreliable and a + // constant was the safer wrong answer. This is what retires that. + 'pickup_source_type': s( + pick(['pickup_source_type', 'pickupsourcetype', 'pickupSourceType']), + ), + + // The counter's own name, as the hub holds it. Distinct from + // `sourcename`, which the backend fills from `providercompany` / + // `providerlocation` and which is a contact person on some rows — that is + // how the biggest type on Home once read `Sudharsan`. + 'pickup_source_name': s( + pick(['pickup_source_name', 'pickupsourcename', 'pickupSourceName']), + ), + // ── The hub's solved position in the route ── // // Verified live 21 Aug 2026: `GET /miler/bookings` carries `step` on diff --git a/lib/data/lifecycle.dart b/lib/data/lifecycle.dart index 30f6e2b..f4188b1 100644 --- a/lib/data/lifecycle.dart +++ b/lib/data/lifecycle.dart @@ -279,6 +279,65 @@ abstract final class MilerLifecycle { ); } + /// `POST /miler/consignments/:id/inward-at-hub`. + /// + /// Confirmed when the response says the parcel is now the base's — + /// `Inwarded_at_Hub`, or `already_inwarded: true` for a retry whose first + /// attempt landed and whose answer was lost. + /// + /// ── Why `already_inwarded` is a success and not an error ── + /// + /// A rider hands a parcel over at a loading bay, the reply is dropped, and he + /// presses again. The server has already done the work; refusing the second + /// press would tell him the hand-over failed for a parcel now sitting on the + /// base's counter, and he has no way to prove otherwise from where he is + /// standing. So the second answer confirms the first — which is exactly what + /// the backend's 200-with-a-flag shape is for, and the app must not turn it + /// back into a failure. + static StateTransition inwardAtHub(ApiResult res) { + if (!res.ok) { + return StateTransition( + outcome: TransitionOutcome.refused, + bookingStatus: BookingStatus.unknown, + consignmentState: ConsignmentState.unknown, + code: res.code, + message: res.message, + evidence: 'HTTP ${res.status} ${res.code} ${res.message}', + ); + } + + final raw = _str(res.data, const [ + 'consignmentstatus', + 'consignment_status', + 'status', + ]); + final state = consignmentStateFromRaw(raw); + final stamp = _str(res.data, const ['inwardedat', 'inwarded_at']); + final next = _str(res.data, const ['next_action', 'nextaction']).toLowerCase(); + + // `_str` skips `false`-ish values by design, so the flag is read straight. + final already = + res.data['already_inwarded'] == true || + res.data['alreadyInwarded'] == true; + + final confirmed = + state == ConsignmentState.inwardedAtHub || + already || + next == 'handed_to_hub'; + + return StateTransition( + outcome: confirmed + ? TransitionOutcome.confirmed + : TransitionOutcome.unconfirmed, + bookingStatus: BookingStatus.unknown, + consignmentState: state, + nextAction: next, + evidence: + 'consignment="$raw" inwardedat="$stamp" next="$next" ' + 'already=$already', + ); + } + /// One line, in the shape a backend ticket wants. static void report(String verb, StateTransition t) { switch (t.outcome) { diff --git a/lib/data/miler_api.dart b/lib/data/miler_api.dart index 5d43151..cb5d245 100644 --- a/lib/data/miler_api.dart +++ b/lib/data/miler_api.dart @@ -719,6 +719,77 @@ class MilerApi { ), ); + // ═══════════════════════════════════════════════════════════════════════ + // THE HANDOVER — the rider gives a hub-routed parcel to a base + // ═══════════════════════════════════════════════════════════════════════ + + /// Hands a `Created` consignment in at a base, ending this rider's part. + /// + /// ── The leg that had no route ── + /// + /// A parcel bound for another district is collected at a customer's door and + /// carried to a base; the network takes it from there. The rider could do the + /// first half and had no way to record the second, so an intercity parcel sat + /// in his queue until somebody inwarded it in the console — and every one of + /// those jobs reported zero distance and zero value on `/miler/earnings`, + /// because the assignment was never closed against him. + /// + /// ── The body is optional, all of it ── + /// + /// Sent with nothing, the parcel is handed into the base it was already + /// routed to. [hubId] is worth sending when the app has one — it is what the + /// server reconciles against — and the coordinates are stamped onto the + /// history row as evidence of where the hand-over happened. + /// + /// ── Idempotent twice over ── + /// + /// The shared `Idempotency-Key` middleware covers a retry after a dropped + /// response, and a parcel already inwarded answers **200 with + /// `already_inwarded: true`** rather than a 4xx — so a rider on bad signal at + /// a loading bay who presses again is confirmed, not refused. Read the result + /// through [MilerLifecycle.inwardAtHub], which treats that reply as the + /// success it is. + /// + /// Refusals carry their own reason: `CONSIGNMENT_NOT_FOUND`, + /// `CONSIGNMENT_NOT_ASSIGNED`, `HUB_REQUIRED`, `HUB_NOT_FOUND`, + /// `INVALID_STATE` for a parcel already past this leg. + static Future inwardAtHub( + Object consignmentId, { + Object? hubId, + double? lat, + double? lon, + }) => _guarded( + 'inward-at-hub:$consignmentId', + () => _send( + 'POST', + '/miler/consignments/$consignmentId/inward-at-hub', + idempotencyKey: _idempotencyKey('inward-at-hub', consignmentId), + body: { + if (hubId != null) 'hub_id': hubId, + if (lat != null) 'latitude': lat, + if (lon != null) 'longitude': lon, + }, + ), + ); + + /// The bases this rider may hand a parcel in at. + /// + /// ── Why this is not the tenant locations route ── + /// + /// `GET /admin/tenants/:id/locations` is a **different dataset** — a client's + /// own sites, not bases — and `/admin/*` requires roles 1/3/4 while a rider is + /// role 5. The 401 this app has been logging as a gap on that route was by + /// design, not an oversight. This is the rider-readable one. + /// + /// Returns `{id, name, address, pincode, latitude, longitude}` per base, plus + /// `distance_km` and nearest-first ordering once the rider has reported a + /// position. + static Future bases({String status = 'Active'}) => _send( + 'GET', + '/miler/bases', + query: {if (status.isNotEmpty) 'status': status}, + ); + /// The consignment's current state and what may be done to it. /// /// Returns `status` plus the backend's own derived flags — `collected`, diff --git a/lib/data/milk_run.dart b/lib/data/milk_run.dart index 26b008e..4b9c9ba 100644 --- a/lib/data/milk_run.dart +++ b/lib/data/milk_run.dart @@ -1,6 +1,7 @@ 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/next_leg.dart'; import 'package:miler/data/service_profile.dart'; /// ───────────────────────────────────────────────────────────────────────── @@ -227,6 +228,20 @@ class MilkRun { ); if (resolved.isNotEmpty) return resolved; + // ── The counter's own name, ahead of the account's ── + // + // `pickup_source_name` ships with request 28 and is what the hub holds for + // the place. `sourcename` is filled from `providercompany` / + // `providerlocation`, and what lands there is whoever is on the account + // rather than the counter — which is how the biggest type on Home once read + // `Sudharsan`, a person, above a distance and an ETA to a place that name + // does not identify. + // + // Below the locations table, which is still the canonical name for a base, + // and above every legacy spelling, which are guesses by comparison. + final named = (stop['pickup_source_name'] ?? '').toString().trim(); + if (named.isNotEmpty) return named; + return (stop['sourcename'] ?? stop['SourceName'] ?? stop['kitchenname'] ?? @@ -281,7 +296,35 @@ class MilkRun { static bool navigatesToCustomer( Map stop, { Set collectedIds = const {}, + + /// `next_action` per order, from the pivot. See [NextLegResolver]. + Map pivotActions = const {}, }) { + // ── The order's own routing outranks the line's shape ── + // + // The two lines below are the whole logistics fix, and they are checked + // first because a per-order fact must not be overruled by a per-line one. + // + // • **hub** is the safety case. A Gandhipuram → Chennai parcel is in this + // rider's hands and its receiver is 500km away; pointing a Navigate + // button at that address sends a Coimbatore rider down a national + // highway. He is going to a hub, and the customer is context, not a + // destination. + // • **customer** is the case the line-level test could never reach. + // `deliversToCustomer` is false on logistics, so a hyperlocal parcel the + // pivot had already released into this rider's own hands could not get a + // customer destination out of this function at all. + final leg = NextLegResolver.resolve( + stop, + pivotAction: pivotActions[idOf(stop)] ?? '', + ); + if (leg.isHub) return false; + if (leg.isCustomer) return true; + + // No per-order answer — [NextLeg.unknown], or a stop that has not been + // collected yet. Fall back to the line's own shape, which is exactly what + // this function did before and is still right for a milk run, whose rows + // often carry no consignment state at all. if (!ServiceProfile.active.deliversToCustomer) return false; if (collectedIds.contains(idOf(stop))) return true; final status = stopStatusOf(stop); diff --git a/lib/data/next_leg.dart b/lib/data/next_leg.dart new file mode 100644 index 0000000..45e3659 --- /dev/null +++ b/lib/data/next_leg.dart @@ -0,0 +1,521 @@ +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/data/consignment_state.dart'; +import 'package:miler/data/milk_run.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// WHERE THIS PARCEL GOES NEXT — asked of the order, never of the line +/// +/// Once a booking has been collected, exactly one question decides what the +/// rider does with it: **carry it to the receiver, or carry it to a hub?** +/// +/// ── Why this is not [ServiceProfile.endsAtHub] ── +/// +/// It used to be. `endsAtHub` is a property of the *line* — one constant for +/// every order a logistics rider will ever touch — and it was wired straight +/// into ownership: `work_domain.dart` closed a stop the moment it read +/// `isPicked && endsAtHub`. On the parcel line that is always true, so **every +/// collected parcel was closed at the pickup**. +/// +/// That is right for one of the two routings and wrong for the other, and the +/// backend has always known which is which: +/// +/// ``` +/// hyperlocal pickup-complete → Out_for_Delivery (compatibility mode) +/// → Collected_By_Miler (flag on) +/// hub-routed pickup-complete → Created + next_action: inward_at_hub +/// ``` +/// +/// So a Gandhipuram → Hopes parcel — collected, released by the pivot, sitting +/// in the rider's own bag with the server calling it `Out_for_Delivery` — was +/// dropped from his screen into Activity before he left the sender's door. He +/// was carrying a parcel the app had already filed as finished. +/// +/// A line-level constant cannot answer a per-order question. This can. +/// +/// ── The one rule for using this ── +/// +/// **The routing decision is the backend's.** Nothing in this file compares +/// pincodes, districts or city names, and nothing may be added that does. +/// Coimbatore-vs-Chennai is a question about a tariff zone and a line-haul +/// schedule, and the app knows neither. It reads the answer and executes it. +/// ───────────────────────────────────────────────────────────────────────── +enum NextLeg { + /// Carry it to the receiver. The rider releases it with **Start delivery** + /// and finishes at a customer's door. + customer, + + /// Carry it to a hub and hand it over. The network takes it from there — + /// line-haul, then somebody else's last mile. + hub, + + /// Nothing left for this rider: delivered, cancelled, withdrawn, or already + /// taken into hub custody by somebody else. + closed, + + /// Collected, and the server has not said where it goes. + /// + /// ── Why this is a state and not a default ── + /// + /// It would be convenient to fold this into [customer] (the common case) or + /// [closed] (the old behaviour). Both are unsafe, and in opposite directions: + /// + /// • As [customer], a Chennai parcel offers **Start delivery** and a + /// Navigate button pointed at a receiver 500km away. + /// • As [closed], a parcel in the rider's bag disappears from his screen — + /// the exact bug this file exists to fix. + /// + /// So it is neither. The parcel stays in the rider's custody, on Deliveries, + /// with no destination offered and an escalation route instead. That is an + /// honest screen: he is holding something, and the app does not yet know + /// where it goes. + unknown, +} + +/// One order's next leg, and the evidence it was decided on. +/// +/// The evidence is carried rather than discarded because "why is this parcel +/// showing hub handover?" is a question the office will ask, and answering it +/// from a log line beats re-deriving it from four sources by hand. +class NextLegDecision { + const NextLegDecision(this.leg, {required this.source, this.evidence = ''}); + + final NextLeg leg; + + /// Which rung of [NextLegResolver]'s precedence answered. See that class. + final String source; + + /// What was actually read, in the server's own words. + final String evidence; + + bool get isCustomer => leg == NextLeg.customer; + bool get isHub => leg == NextLeg.hub; + bool get isClosed => leg == NextLeg.closed; + bool get isUnknown => leg == NextLeg.unknown; + + /// True while the parcel is physically the rider's: he is holding it and + /// owes it a journey, whether or not the destination is known yet. + /// + /// This is what puts a row on Deliveries. [NextLeg.unknown] is deliberately + /// in it — see the note on that value. + bool get isInCustody => leg == NextLeg.customer || leg == NextLeg.hub || leg == NextLeg.unknown; + + @override + String toString() => 'NextLeg.${leg.name} (via $source: $evidence)'; +} + +/// The server's own instruction words, exactly as the pivot sends them. +/// +/// Not an enum: these are the backend's vocabulary, they are compared against +/// raw response text, and a word this app has not been told about must fall +/// through to [NextLeg.unknown] rather than fail to parse into something. +abstract final class NextActionWord { + /// Nothing collected yet — the rider is still going to fetch it. + static const String pickup = 'pickup'; + + /// The rider releases it and delivers it himself. + static const String startDelivery = 'start_delivery'; + + /// Released already; he is carrying it to the receiver. + static const String deliver = 'deliver'; + + /// The rider carries it to a hub and hands it in. + static const String inwardAtHub = 'inward_at_hub'; + + /// The hub has taken it in. Off this rider's hands. + static const String handedToHub = 'handed_to_hub'; + + /// Nothing further for anyone — the row is terminal. + static const String none = 'none'; +} + +/// Resolves [NextLeg] for one order. +/// +/// ── PRECEDENCE ── +/// +/// Four sources can speak, they disagree, and which one wins is the whole +/// contract of this file. In order: +/// +/// **1. A terminal consignment state on the row.** `Delivered`, `Cancelled`, +/// `Returned_to_Sender` — and `Inwarded_at_Hub` / `Tripsheet_Loaded` / +/// `In_Transit`, which mean the parcel is in the network's hands and out of +/// this rider's. Nothing outranks the parcel being gone. +/// +/// **2. Live `next_action` on the row.** The server naming the leg on the row +/// itself is the most direct answer there is. **The backend does not send +/// this on `GET /miler/bookings` today** — it is read here so that the day +/// it does, this resolver already prefers it over everything below. See the +/// backend gap note in [rowNextAction]. +/// +/// **3. Live `consignmentstatus` on the row, where it is unambiguous.** +/// `Out_for_Delivery` and `Collected_By_Miler` can only mean the rider is +/// carrying it to a customer. **`Created` cannot decide anything** — see +/// below. +/// +/// **4. The persisted pivot result.** What `next_action` said at +/// `pickup-complete`, recorded per order. Continuity only: it is what keeps +/// a hub-routed parcel labelled correctly across a rebuild, a poll and a +/// cold start. It is deliberately *below* every live server reading, because +/// a local record must never outrank the server changing its mind. +/// +/// Nothing answers → [NextLeg.unknown]. +/// +/// ── Why `Created` is not allowed to mean "hub" ── +/// +/// It is tempting: the documented contract says a hub-routed pivot lands on +/// `Created`, so `Created` looks like a hub signal. It is not, because it is +/// also the state a consignment holds **while the pivot is still routing it** +/// — `delivery_actions.dart` already carries that finding and re-reads the +/// consignment because of it. A freshly-collected hyperlocal parcel and a +/// settled hub-routed one can both read `Created` on the same poll. +/// +/// So `Created` is treated as *no answer* and falls through to the persisted +/// pivot result, which knows which of the two actually happened. If there is no +/// record either, the honest answer is [NextLeg.unknown] — not a guess in +/// whichever direction is cheaper to render. +abstract final class NextLegResolver { + /// `next_action` as it appears on a queue row, or `''`. + /// + /// ── BACKEND GAP ── + /// + /// `GET /miler/bookings` does not carry this. `next_action` is returned + /// **once**, by `POST /miler/bookings/:id/pickup-complete`, and the booking + /// adapter in `ApiConfig.pickupFromBooking` has no key for it — so a poll or + /// a restart cannot re-read the leg from the server at all, and rung 4 (the + /// persisted pivot) is doing work that a server field should be doing. + /// + /// This reader exists anyway. It costs one map lookup, and on the day the + /// field is added the resolver starts preferring the live answer over the + /// local record with no further change. + static String rowNextAction(Map row) { + for (final k in const [ + 'next_action', + 'nextaction', + 'nextAction', + 'next_leg', + 'nextleg', + ]) { + final v = row[k]; + if (v == null) continue; + final s = v.toString().trim().toLowerCase(); + if (s.isNotEmpty && s != 'null') return s; + } + return ''; + } + + /// Maps one of the server's instruction words to a leg, or null when the + /// word is absent or not one this app has been told about. + /// ── Every word the server sends, mapped on purpose ── + /// + /// Four of these used to fall through to `null` and land on the right answer + /// by accident: `deliver` was caught by `Out_for_Delivery` at rung 3, + /// `handed_to_hub` by `Inwarded_at_Hub` at rung 1. That worked, and it worked + /// for reasons that had nothing to do with the word — so a state field that + /// went missing took the correct answer with it. + /// + /// Two stay deliberately unmapped: + /// + /// • **`pickup`** is *not yet collected*, which is not a leg. There is + /// nothing in this rider's hands to route. [WorkBoundary.domainOf] gates + /// on the collection having happened for the same reason. + /// • **`none`** means the row is terminal — and rung 1 reads that off the + /// consignment's own state, which is the stronger evidence. Letting a bare + /// word close a parcel whose state does not agree is how a stop in + /// somebody's hands gets written off; the word alone is not enough. + static NextLeg? _fromWord(String word) => switch (word) { + NextActionWord.startDelivery || NextActionWord.deliver => NextLeg.customer, + NextActionWord.inwardAtHub => NextLeg.hub, + NextActionWord.handedToHub => NextLeg.closed, + _ => null, + }; + + /// Resolves the leg for [row]. + /// + /// [pivotAction] is the persisted `next_action` recorded for this order at + /// `pickup-complete` — rung 4. Pass `''` when there is none. + /// + /// This is a pure function of its arguments: no I/O, no profile read, no + /// clock. Everything it needs is handed to it, which is what makes the whole + /// precedence table testable in one file. + static NextLegDecision resolve( + Map row, { + String pivotAction = '', + }) { + final consignment = consignmentStateFromRaw(row['consignmentstatus']); + final status = stopStatusOf(row); + + // ── 1. Terminal, from either vocabulary ── + if (consignment.isClosed || status == StopStatus.delivered || status.isCancelled) { + return NextLegDecision( + NextLeg.closed, + source: 'terminal', + evidence: 'consignment="${consignment.name}" status="${status.name}"', + ); + } + // The network has it. `awaitsHub` is exactly the set of states that mean + // hub custody — inwarded, loaded onto a tripsheet, in transit — and none of + // them are this rider's problem any more. This is also the **only** way a + // hub-routed parcel currently leaves his queue: hub staff inward it in + // their console and the next poll retires it here. See the backend gap on + // the missing rider-side handover mutation. + if (consignment.awaitsHub) { + return NextLegDecision( + NextLeg.closed, + source: 'network-custody', + evidence: 'consignment="${consignment.name}"', + ); + } + + // ── 2. Live next_action on the row (not sent today; see rowNextAction) ── + final live = rowNextAction(row); + final fromLive = _fromWord(live); + if (fromLive != null) { + return NextLegDecision( + fromLive, + source: 'row.next_action', + evidence: 'next_action="$live"', + ); + } + + // ── 3. Live consignmentstatus, where it is unambiguous ── + // + // Both of these mean the parcel is in the rider's hands bound for a + // customer: `Collected_By_Miler` awaits his Start delivery, + // `Out_for_Delivery` is the same journey already released (compatibility + // mode releases it inside the pivot). `Created` is NOT here — see the class + // note. + if (consignment == ConsignmentState.outForDelivery || + consignment == ConsignmentState.collectedByMiler) { + return NextLegDecision( + NextLeg.customer, + source: 'row.consignmentstatus', + evidence: 'consignment="${consignment.name}"', + ); + } + + // ── 4. The persisted pivot result — continuity only ── + final fromPivot = _fromWord(pivotAction.trim().toLowerCase()); + if (fromPivot != null) { + return NextLegDecision( + fromPivot, + source: 'persisted-pivot', + evidence: + 'pivot next_action="$pivotAction" ' + '(consignment="${consignment.name}")', + ); + } + + // ── Nothing answered ── + return NextLegDecision( + NextLeg.unknown, + source: 'none', + evidence: + 'consignment="${consignment.name}" status="${status.name}" ' + 'row.next_action="$live" pivot="$pivotAction"', + ); + } + + /// Convenience: the leg for a row whose pivot record is already in hand as a + /// map of order id → action, which is the shape the store returns. + static NextLegDecision forStop( + Map row, { + Map pivotActions = const {}, + }) => resolve(row, pivotAction: pivotActions[MilkRun.idOf(row)] ?? ''); +} + +/// ───────────────────────────────────────────────────────────────────────── +/// HOW THE LEG IS WORDED, IN ONE PLACE +/// +/// Four surfaces have to say what the rider does next — the Deliveries row, the +/// map sheet, the stop detail sheet and Activity's record — and the last time a +/// lifecycle vocabulary was left to the widgets they invented three of them for +/// one state (see [StopStatusX.label]). A hub handover worded as a delivery is +/// worse than untidy: "Order delivered" over a parcel handed across a warehouse +/// counter is a false record of custody. +/// +/// So the words live next to the decision that produces them. +/// ───────────────────────────────────────────────────────────────────────── +extension NextLegPresentation on NextLeg { + /// The eyebrow above the destination — what kind of journey this is. + String get eyebrow => switch (this) { + NextLeg.customer => 'CUSTOMER DELIVERY', + NextLeg.hub => 'BASE HANDOVER', + NextLeg.unknown => 'AWAITING ROUTING', + NextLeg.closed => '', + }; + + /// The act, in the rider's own words, for a completed record on Activity. + /// + /// Deliberately different verbs: he *delivered* to a person and he *handed + /// over* to a building, and Activity is the one place those two must not read + /// the same. + String get completionVerb => switch (this) { + NextLeg.customer => 'Delivered to customer', + NextLeg.hub => 'Handed over at base', + NextLeg.unknown => 'Collected', + NextLeg.closed => 'Closed', + }; + + /// Whether the rider may release this parcel into a customer round. + /// + /// **The one gate that matters.** `Start delivery` on a hub-routed parcel + /// would move a Chennai consignment to `Out_for_Delivery` against a receiver + /// this rider is never going to reach, and there is no way back from that + /// state on the handset. + bool get allowsCustomerDelivery => this == NextLeg.customer; + + /// Whether this leg needs the rider to go to a hub. + bool get needsHubHandover => this == NextLeg.hub; +} + +/// ───────────────────────────────────────────────────────────────────────── +/// THE BASE A PARCEL IS TO BE HANDED IN AT +/// +/// `next_hub` on the booking row and on the `pickup-complete` response, read +/// once here so no screen picks its own key spellings. +/// +/// ── Why the coordinates are the point ── +/// +/// The app knew a parcel was hub-routed long before it knew where the base +/// was, and "hand this in somewhere" is not an instruction a rider can follow. +/// Every `hubLat`/`hubLng` in the codebase was the rider's own position +/// standing in for a building, which is fine for anchoring a route estimate and +/// useless for navigating to a gate. This is the real one. +/// ───────────────────────────────────────────────────────────────────────── +class HandoverHub { + const HandoverHub({ + required this.id, + required this.name, + this.address = '', + this.pincode = '', + this.latitude = 0, + this.longitude = 0, + }); + + final String id; + final String name; + final String address; + final String pincode; + final double latitude; + final double longitude; + + /// True when this base can actually be navigated to. + /// + /// A base with a name and no coordinates is a caption, not a destination — + /// and the screen has to say so rather than opening a map on `0, 0` in the + /// Gulf of Guinea. + bool get isNavigable => latitude != 0 && longitude != 0; + + /// Reads `next_hub` off a row, or null when there is no base leg. + /// + /// Tolerant of key spellings for the same reason the booking adapter is: a + /// naming mismatch here is a rider with no destination, and the cost of + /// accepting three spellings is three map lookups. + static HandoverHub? from(dynamic raw) { + if (raw is! Map) return null; + final m = raw.map((k, v) => MapEntry(k.toString(), v)); + + String str(List keys) { + for (final k in keys) { + final v = m[k]; + if (v == null) continue; + final s = v.toString().trim(); + if (s.isNotEmpty && s != 'null') return s; + } + return ''; + } + + double num_(List keys) { + for (final k in keys) { + final v = m[k]; + if (v == null) continue; + final d = v is num ? v.toDouble() : double.tryParse(v.toString()); + if (d != null && d != 0) return d; + } + return 0; + } + + final id = str(const ['id', 'hubid', 'hub_id']); + final name = str(const ['name', 'hubname', 'hub_name']); + // Neither an id nor a name is not a base — it is an empty object, and + // returning a blank one would put an untitled destination on the card. + if (id.isEmpty && name.isEmpty) return null; + + return HandoverHub( + id: id, + name: name, + address: str(const ['address', 'hubaddress', 'hub_address']), + pincode: str(const ['pincode', 'hubpincode', 'hub_pincode']), + latitude: num_(const ['latitude', 'lat', 'hublatitude']), + longitude: num_(const ['longitude', 'lon', 'lng', 'hublongitude']), + ); + } +} + +/// What kind of place a stop is collected from — request 28's `pickup_source_type`. +/// +/// ── Why this is read and never inferred ── +/// +/// The app has no way to work it out. A base and a shop are both just names in +/// the same field, and a missing `pickuplocationid` means either "collected at +/// a person's front door" or "the field was not filled in" — two facts one +/// absence cannot tell apart. So this is the server's word or it is +/// [PickupSource.unknown], and Home draws a plain pickup for the latter rather +/// than guessing a kind and putting the wrong noun on the biggest type on the +/// screen. +enum PickupSource { + hub, + customer, + merchant, + store, + unknown; + + /// Parses `pickup_source_type` off a row. + /// + /// ── The value names match the wire, so the wire is not spelled twice ── + /// + /// Each case's own [name] *is* the word the backend sends, so the primary + /// match is a name comparison rather than a table of string literals that + /// could drift from the enum beside it. The switch below holds only the + /// aliases, which are the spellings that genuinely differ. + static PickupSource of(Map row) { + final raw = (row['pickup_source_type'] ?? '') + .toString() + .trim() + .toLowerCase(); + if (raw.isEmpty) return PickupSource.unknown; + + for (final value in PickupSource.values) { + if (value != PickupSource.unknown && value.name == raw) return value; + } + + return switch (raw) { + 'base' => PickupSource.hub, + 'sender' => PickupSource.customer, + 'kitchen' => PickupSource.merchant, + 'shop' => PickupSource.store, + // A word this build has not been told about is a generic pickup, never a + // failure and never a guess. New types can ship server-side without + // waiting on an app release. + _ => PickupSource.unknown, + }; + } + + /// The eyebrow over the place's name on Home. + /// + /// "BASE", never "HUB" — hub is internal vocabulary and no rider-facing + /// string may carry it. See `no_hub_in_rider_copy_test.dart`. + String get eyebrow => switch (this) { + PickupSource.hub => 'BASE PICKUP', + PickupSource.customer => 'CUSTOMER PICKUP', + PickupSource.merchant => 'MERCHANT PICKUP', + PickupSource.store => 'STORE PICKUP', + PickupSource.unknown => 'PICKUP', + }; + + /// True where the rider collects a stack at one counter, rather than one + /// parcel from one person. Drives the bulk-collect control. + bool get isCounter => + this == PickupSource.hub || + this == PickupSource.merchant || + this == PickupSource.store; +} diff --git a/lib/data/pickup_locations.dart b/lib/data/pickup_locations.dart index 6db1c64..29225d1 100644 --- a/lib/data/pickup_locations.dart +++ b/lib/data/pickup_locations.dart @@ -3,6 +3,7 @@ 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/next_leg.dart'; import 'package:miler/data/service_profile.dart'; /// ───────────────────────────────────────────────────────────────────────── @@ -49,6 +50,21 @@ abstract final class PickupLocations { /// [ensureLoaded] has run and the backend has answered. static Map _byId = const {}; + /// The full base record by id — name, address and the coordinates a hand-over + /// is navigated to. Empty until [ensureLoaded] has answered. + static Map _bases = const {}; + + /// The base for an id, or null when this build has none. + /// + /// Read by the hand-over card: a base it cannot resolve is drawn with the + /// name the server put on `next_hub` and no Navigate button, which is honest. + /// Guessing a nearby base would be worse than saying nothing. + static HandoverHub? baseFor(Object? id) { + final key = id?.toString().trim() ?? ''; + if (key.isEmpty || key == '0') return null; + return _bases[key]; + } + /// 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; @@ -68,6 +84,18 @@ abstract final class PickupLocations { return _byId[id] ?? ''; } + /// Fills the table directly, for tests and for nothing else. + /// + /// The real load goes through `/admin/tenant/:id/locations`, which a test has + /// no business standing up. Marked settled so [ensureLoaded] does not then + /// overwrite the seed with a live answer. + @visibleForTesting + static void seedForTest(Map byId) { + _byId = Map.from(byId); + _settled = byId.isNotEmpty; + _loading = null; + } + /// True when the table holds anything at all. static bool get isLoaded => _byId.isNotEmpty; @@ -96,23 +124,31 @@ abstract final class PickupLocations { return; } - final res = await MilerApi.tenantLocations(tenantId); + // ── The rider-readable route, at last ── + // + // This called `GET /admin/tenants/:id/locations` and logged a gap every + // time it came back 401. The backend team settled it: that route is a + // **different dataset** — a client's own sites, not bases — and `/admin/*` + // requires roles 1/3/4 while a rider is role 5. The refusal was by design + // and the gap report was wrong. + // + // `GET /miler/bases` is the one a rider may read, and it carries the + // address and coordinates the old route never did. Naming a base is no + // longer the whole job: a hand-over has to be navigated to. + final res = await MilerApi.bases(); 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.', + 'miler/bases', + 'bases came back ${res.status} ${res.message}. Pickup headings will ' + 'fall back to the booking row\'s own name, and a base hand-over ' + 'will have no coordinates to navigate to.', ); _settled = true; return; } final table = {}; + final bases = {}; for (final row in res.list) { if (row is! Map) continue; final m = row.map((k, v) => MapEntry(k.toString(), v)); @@ -124,6 +160,10 @@ abstract final class PickupLocations { 'location_id', 'id', ]); + // The whole base, kept beside the name — [HandoverHub] is what the + // hand-over card and its Navigate button read. + final base = HandoverHub.from(m); + if (base != null && id.isNotEmpty) bases[id] = base; final name = _first(m, const [ 'locationname', 'locationName', @@ -142,6 +182,7 @@ abstract final class PickupLocations { } _byId = table; + _bases = bases; _settled = true; debugPrint('[LOCATIONS] tenant $tenantId → ${table.length} named'); if (table.isEmpty && kDebugMode) { @@ -172,6 +213,7 @@ abstract final class PickupLocations { @visibleForTesting static void reset() { _byId = const {}; + _bases = const {}; _settled = false; _loading = null; } @@ -183,3 +225,59 @@ abstract final class PickupLocations { _settled = true; } } + +/// ───────────────────────────────────────────────────────────────────────── +/// THE RIDER'S OWN HUB +/// +/// A logistics day starts and ends at one building. The hub assigns the day's +/// bookings — collections, drops and both — hands the rider the route, and he +/// accepts the lot before he leaves. So on Home the whole run belongs to that +/// building, and the card at the head of it is named after it. +/// +/// ── Why this is not read off a booking ── +/// +/// The obvious source is the stop's own `pickuplocationid`, and it is right for +/// exactly half the run. A **drop** is loaded at the hub, so its pickup +/// location *is* the hub; a **collection** is picked up at the customer's door, +/// so its pickup location is the customer. Naming the head card from the +/// bookings therefore titled it with whichever address happened to sort first — +/// a customer's street on a run that starts at a depot. +/// +/// The hub is a fact about the **rider**, not about any one booking. It is his +/// `locationid`, written at login from `applocationid` falling back to `hubid`, +/// and resolved to something he can read off a sign through [PickupLocations]. +/// +/// ── Everything here degrades to a name, never to a blocked screen ── +/// +/// The id may be missing, the locations table may be empty or refused, and the +/// name may simply not be in it. Each of those yields `''`, and the caller +/// falls back to what it drew before. Nothing on this path may stop a rider +/// working — the same rule [PickupLocations] is built on. +abstract final class RiderHub { + static int _id = 0; + + /// The hub's location id, or 0 before login has landed. + static int get id => _id; + + /// The name a rider can read off the building, or `''` when this build has + /// none. Synchronous: it is read from `build`. + static String get name => PickupLocations.nameFor(_id); + + /// Reads the id the login persisted. Cheap, and safe on every queue fetch. + static Future ensureLoaded() async { + if (_id > 0) return; + try { + final prefs = await SharedPreferences.getInstance(); + // `locationid` is written as `applocationid ?? hubid` — see + // `auth_provider.dart`. Both name the building this rider works out of. + _id = prefs.getInt('locationid') ?? 0; + } catch (_) { + // A device that cannot read its own prefs is a device with no hub name, + // which is a caption, not a capability. + _id = 0; + } + } + + /// For tests, and for a rider who changes hub without reinstalling. + static void resetForTest([int id = 0]) => _id = id; +} diff --git a/lib/data/service_profile.dart b/lib/data/service_profile.dart index 763e026..e34dc19 100644 --- a/lib/data/service_profile.dart +++ b/lib/data/service_profile.dart @@ -659,6 +659,38 @@ class TenantController extends GetxController { /// /// **If tenant 13 is not the milk-run client**, this line is the whole fix: /// remove the 13 and a rider on it goes back to logistics. + /// ══ THE APP IS PINNED TO THE MILK MAN LINE ══ + /// + /// **This is a temporary operating decision, not a permanent one.** While it + /// is `true`, [resolveServiceProfile] returns [ServiceProfile.milkMan] before + /// it consults the rider's tenant, the token claim or the build flag — so + /// every signed-in rider works the milk round and the logistics + /// customer-pickup flow is unreachable from the UI. + /// + /// ── Why it is a pin and not a deletion ── + /// + /// The logistics screens are not removed, and nothing about them is edited. + /// The whole line is gated behind [ServiceProfile.handsOffAtCollection] and + /// its siblings already, so pinning the profile is enough to take it off the + /// screen — and turning it back on is this one word. Deleting the code would + /// have been a one-way door for a decision described as "as of now". + /// + /// ── What it fixes today ── + /// + /// Tenant resolution is name-first, and the milk-run rider's tenant is + /// spelled `Doormile` — which [logisticsTenantNames] maps, correctly and + /// deliberately, to **logistics**. So a genuine milk-man rider resolved to + /// the parcel line, and every symptom followed from that one answer: accept + /// handed off immediately and jumped him to the work tab, the Bookings page + /// offered **Start Pickup** for orders that belong on Home, and the leg the + /// distance measured to was the kitchen rather than the door. + /// + /// ── Turning it off ── + /// + /// Set to `false` and the app resolves per rider again, exactly as before. + /// Do that before shipping any build a logistics rider will sign in to. + static const bool pinToMilkManLine = true; + static const Set milkManTenantIds = {13}; /// The old name for [milkManTenantIds]. @@ -800,7 +832,19 @@ class TenantController extends GetxController { /// seeing a payment prompt he can dismiss; the other direction takes the /// cash-collection screen away from a live logistics rider at a door. Future load() async { - final resolved = await resolveServiceProfile(); + // ── The pin sits here, not inside the resolver ── + // + // `resolveServiceProfile()` is a pure function over the rider's account and + // it is what the tenant tests exercise directly; short-circuiting *it* made + // 24 of them assert against a function that no longer answered the question + // they were asking. This method is the app's only caller and the single + // place the answer is published, so pinning here takes the whole app onto + // the milk round while leaving the resolution rules intact and testable. + // + // See [pinToMilkManLine] for what this is for and how to lift it. + final resolved = pinToMilkManLine + ? ServiceProfile.milkMan + : await resolveServiceProfile(); ServiceProfile.setActive(resolved); profile.value = resolved; debugPrint('[TENANT] resolved ${resolved.label}'); diff --git a/lib/data/work_domain.dart b/lib/data/work_domain.dart index 6f9fdab..2f22b36 100644 --- a/lib/data/work_domain.dart +++ b/lib/data/work_domain.dart @@ -1,5 +1,6 @@ import 'package:miler/Models/stop_status.dart'; import 'package:miler/data/milk_run.dart'; +import 'package:miler/data/next_leg.dart'; import 'package:miler/data/service_profile.dart'; /// ───────────────────────────────────────────────────────────────────────── @@ -91,10 +92,10 @@ abstract final class WorkBoundary { /// True when this order is finished or withdrawn, whatever screen it was on. /// - /// [StopStatus.picked] is terminal on a line that ends at the hub and is the - /// middle of the morning on one that does not — so it is asked of the line, - /// through the same knob everything else here reads. See - /// [ServiceProfile.endsAtHub]. + /// [StopStatus.picked] is terminal for some orders and the middle of the + /// morning for others, and **which one is a property of the order, not of the + /// line**. It is asked of [NextLegResolver], which reads the backend's own + /// routing answer. See the note on the picked branch below. /// /// ── Skipped closes, and why it has to close *here* ── /// @@ -128,6 +129,9 @@ abstract final class WorkBoundary { static bool isClosed( Map stop, { Set closedIds = const {}, + + /// `next_action` recorded at each order's pivot — see [NextLegResolver]. + Map pivotActions = const {}, }) { if (closedIds.isNotEmpty && closedIds.contains(MilkRun.idOf(stop))) { return true; @@ -138,7 +142,31 @@ abstract final class WorkBoundary { // 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; + + // ── After the pickup, ownership is a question about the ORDER ── + // + // This read `status.isPicked && ServiceProfile.active.endsAtHub`, and that + // is a line-level constant deciding a per-order fact. On logistics + // `endsAtHub` is always true, so **every collected parcel was closed at the + // pickup** — including the hyperlocal ones the pivot had just released + // `Out_for_Delivery` into this rider's own hands. He walked away from the + // sender's door carrying a parcel his app had already filed as finished. + // + // The backend decides where a parcel goes next and always has; the app now + // asks. [NextLeg.closed] is the only answer that retires the stop — + // `customer` and `hub` are both journeys the rider still owes, and + // `unknown` is a parcel in his bag whose destination has not been named, + // which must never be silently written off. See [NextLegResolver] for the + // precedence between the pivot, the row and the local record. + // + // [ServiceProfile.endsAtHub] survives as a line-level capability hint — it + // still tells the route card whether the day ends at a depot — but it no + // longer decides whether one order is finished. + if (!status.isPicked && !status.isDeliveryLeg) return false; + return NextLegResolver.resolve( + stop, + pivotAction: pivotActions[MilkRun.idOf(stop)] ?? '', + ).isClosed; } /// Which screen owns this order. @@ -154,8 +182,42 @@ abstract final class WorkBoundary { /// Orders written off today — delivered, cancelled or skipped — read back /// from the completed and skipped stores. See [isClosed]. Set closedIds = const {}, + + /// `next_action` recorded at each order's pivot — see [NextLegResolver]. + Map pivotActions = const {}, }) { - if (isClosed(stop, closedIds: closedIds)) return WorkDomain.closed; + if (isClosed(stop, closedIds: closedIds, pivotActions: pivotActions)) { + return WorkDomain.closed; + } + + // ── A collected parcel is the work tab's, whatever the line says ── + // + // Below [isClosed] and above the handoff knob, deliberately. Once the + // backend confirms the collection the parcel is physically the rider's, and + // "in my hands, still owed a journey" is the definition of the delivery + // domain on every line. Reading it here rather than from the per-line + // handoff point is what lets a hyperlocal logistics parcel reach Deliveries + // at all — its own line hands off at *acceptance*, which says nothing about + // custody. + // + // ── Gated on the collection having happened ── + // + // [NextLegResolver] answers [NextLeg.unknown] for any row it cannot place, + // and an uncollected booking is one of those: a pending assignment carries + // no consignment state and no pivot record, exactly like a collected parcel + // the server has not routed yet. Asking without this gate handed every + // pending stop to Deliveries — the dual-ownership failure in reverse. + // + // "Is it in his hands?" is [pickupComplete]'s question and it has a real + // answer: the backend's own status, or the collected record written after + // the pivot came back OK. Only then is there a leg to resolve. + if (pickupComplete(stop, collectedIds: collectedIds) && + NextLegResolver.resolve( + stop, + pivotAction: pivotActions[MilkRun.idOf(stop)] ?? '', + ).isInCustody) { + return WorkDomain.delivery; + } final handedOver = switch (ServiceProfile.active.handoffAt) { HandoffPoint.collected => pickupComplete( @@ -184,6 +246,7 @@ abstract final class WorkBoundary { Set collectedIds = const {}, Set acceptedIds = const {}, Set closedIds = const {}, + Map pivotActions = const {}, }) => [ for (final stop in day) if (domainOf( @@ -191,6 +254,7 @@ abstract final class WorkBoundary { collectedIds: collectedIds, acceptedIds: acceptedIds, closedIds: closedIds, + pivotActions: pivotActions, ) == WorkDomain.delivery) stop, @@ -202,6 +266,7 @@ abstract final class WorkBoundary { Set collectedIds = const {}, Set acceptedIds = const {}, Set closedIds = const {}, + Map pivotActions = const {}, }) => [ for (final stop in day) if (domainOf( @@ -209,6 +274,7 @@ abstract final class WorkBoundary { collectedIds: collectedIds, acceptedIds: acceptedIds, closedIds: closedIds, + pivotActions: pivotActions, ) == WorkDomain.pickup) stop, diff --git a/lib/providers/notifications/notificationservce.dart b/lib/providers/notifications/notificationservce.dart index 4527359..1a934b0 100644 --- a/lib/providers/notifications/notificationservce.dart +++ b/lib/providers/notifications/notificationservce.dart @@ -368,7 +368,7 @@ class NotificationServce { Text( title, style: const TextStyle( - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, ), ), diff --git a/lib/providers/pickuplog/pickuplog_provider.dart b/lib/providers/pickuplog/pickuplog_provider.dart index 652ae2c..98988da 100644 --- a/lib/providers/pickuplog/pickuplog_provider.dart +++ b/lib/providers/pickuplog/pickuplog_provider.dart @@ -281,18 +281,37 @@ class UpdatePickupProvider { } if (picked.ok) { final newId = _consignmentIdFrom(picked); + final orderKey = (data['orderid'] ?? id ?? '').toString().trim(); + + // ── What the pivot said about what happens next, RECORDED ── + // + // Since 21 Aug 2026 the response carries `status` and `next_action`: + // a hyperlocal booking lands on `Collected_By_Miler` with + // `start_delivery`, and a hub-routed one on `Created` with + // `inward_at_hub`. + // + // This used to be logged and dropped, on the reasoning that the + // states it produces are read back from the consignment itself. That + // holds for the *release*, and not for the **routing**: the pivot is + // the only place the server ever names the leg, `GET /miler/bookings` + // does not carry `next_action`, and `Created` — the state a + // hub-routed parcel sits on — is also the state a consignment holds + // while the pivot is still routing it. So on the next poll the two + // routings are indistinguishable, and the app either strands a + // hyperlocal parcel or offers a customer delivery for a parcel bound + // for Chennai. + // + // Recorded per order, and read as the **lowest** rung of + // [NextLegResolver]'s precedence — continuity across a rebuild, never + // an override of live server state. + // + // Written outside the `newId` branch on purpose: the routing answer + // is worth keeping even on a response that named no consignment, + // which is a real shape (see the gap logged below). + await rememberPivotNextAction(orderKey, pivot.nextAction); + if (newId.isNotEmpty) { - final orderKey = (data['orderid'] ?? id ?? '').toString().trim(); await rememberConsignmentId(orderKey, newId); - // ── What the pivot now says about what happens next ── - // - // Since 21 Aug 2026 the response also carries `status` and - // `next_action`: a hyperlocal booking lands on - // `Collected_By_Miler` with `start_delivery`, and a hub-routed one - // on `Created` with `inward_at_hub`. Logged rather than acted on — - // the release is the rider's press, and the states it produces are - // read back from the consignment itself, never remembered from - // here. See `MyPickups.startRound`. debugPrint( '[PICKUP] booking $orderKey → consignment $newId ' '(${_field(picked, 'status')}, next: ' diff --git a/lib/views/Dashboard/activity/activity_page.dart b/lib/views/Dashboard/activity/activity_page.dart index d75c528..fa3bca5 100644 --- a/lib/views/Dashboard/activity/activity_page.dart +++ b/lib/views/Dashboard/activity/activity_page.dart @@ -734,7 +734,7 @@ class _ActivityPageState extends State 'Today', style: MilerType.body.copyWith( fontSize: 19.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.4, ), ), @@ -799,7 +799,7 @@ class _ActivityPageState extends State // 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, + fontWeight: FontWeight.w900, letterSpacing: -1, height: 1.0, fontFeatures: const [FontFeature.tabularFigures()], @@ -816,7 +816,7 @@ class _ActivityPageState extends State style: TextStyle( fontSize: 13.sp, height: 1.0, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: Narrative.inkSoft, fontFamily: FontConstants.fontFamily, ), @@ -925,7 +925,7 @@ class _ActivityPageState extends State ), style: MilerType.body.copyWith( fontSize: 13.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, ), ), ], @@ -1042,7 +1042,7 @@ class _ActivityPageState extends State // 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, + fontWeight: selected ? FontWeight.w800 : FontWeight.w700, letterSpacing: -0.1, color: selected ? Colors.white : Narrative.inkMuted, ), @@ -1060,7 +1060,7 @@ class _ActivityPageState extends State '$_attentionCount', style: MilerType.body.copyWith( fontSize: 13.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, color: selected ? Colors.white : Narrative.amber, ), ), @@ -1279,7 +1279,7 @@ class _ActivityPageState extends State 'Trip ${trip.tripNumber == 0 ? slot + 1 : trip.tripNumber}', style: MilerType.body.copyWith( fontSize: 16.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.3, ), ), @@ -1381,7 +1381,7 @@ class _ActivityPageState extends State Widget _dotted(List<(String, Color)> parts, {double size = 13}) { final style = MilerType.micro.copyWith( fontSize: size.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, ); return Wrap( crossAxisAlignment: WrapCrossAlignment.center, @@ -1419,7 +1419,7 @@ class _ActivityPageState extends State textAlign: TextAlign.center, style: MilerType.body.copyWith( fontSize: 15.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.secondaryText, ), ), @@ -1778,7 +1778,7 @@ class _ActivityPageState extends State style: MilerType.body.copyWith( // `title-lg`: the card's own heading. fontSize: 16.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, letterSpacing: -0.2, color: Narrative.ink, ), @@ -1815,7 +1815,7 @@ class _ActivityPageState extends State color: ColorConstants.moneyGreen, ).copyWith( - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, ), ), ], @@ -1829,7 +1829,7 @@ class _ActivityPageState extends State 12.5, color: Narrative.inkMuted, ).copyWith( - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, ), ), ], @@ -1886,7 +1886,7 @@ class _ActivityPageState extends State overflow: TextOverflow.ellipsis, style: MilerType.micro.copyWith( fontSize: 13.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.warning, ), ), @@ -1962,7 +1962,7 @@ class _ActivityPageState extends State 'Resume stop', style: MilerType.body.copyWith( fontSize: 13.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: ColorConstants.primary, ), ), diff --git a/lib/views/Dashboard/activity/delivery_details_page.dart b/lib/views/Dashboard/activity/delivery_details_page.dart index c257920..84d5af1 100644 --- a/lib/views/Dashboard/activity/delivery_details_page.dart +++ b/lib/views/Dashboard/activity/delivery_details_page.dart @@ -117,7 +117,7 @@ class DeliveryDetailsPage extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, letterSpacing: -0.2, color: ColorConstants.onAccent.withValues(alpha: 0.72), fontFamily: FontConstants.fontFamily, @@ -254,7 +254,7 @@ class DeliveryDetailsPage extends StatelessWidget { overflow: TextOverflow.ellipsis, style: MilerType.body.copyWith( fontSize: 23.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.6, height: 1.15, color: mark, @@ -274,7 +274,7 @@ class DeliveryDetailsPage extends StatelessWidget { overflow: TextOverflow.ellipsis, style: MilerType.micro.copyWith( fontSize: 13.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, ), ), ], @@ -321,7 +321,7 @@ class DeliveryDetailsPage extends StatelessWidget { style: MilerType.figure( 13.sp, color: ColorConstants.slateText, - ).copyWith(fontWeight: FontWeight.w700), + ).copyWith(fontWeight: FontWeight.w800), ), SizedBox(height: 2.h), Text( @@ -382,7 +382,7 @@ class DeliveryDetailsPage extends StatelessWidget { note ?? 'Arrived later than planned', style: MilerType.micro.copyWith( fontSize: 13.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: late ? ColorConstants.warning : ColorConstants.secondaryText, @@ -536,7 +536,7 @@ class DeliveryDetailsPage extends StatelessWidget { style: MilerType.figure( 13.5, color: ColorConstants.slateText, - ).copyWith(fontWeight: FontWeight.w700), + ).copyWith(fontWeight: FontWeight.w800), ), ), ), @@ -577,7 +577,7 @@ class DeliveryDetailsPage extends StatelessWidget { style: MilerType.figure( 18, color: tint ?? ColorConstants.slateText, - ).copyWith(fontWeight: FontWeight.w700, letterSpacing: -0.4), + ).copyWith(fontWeight: FontWeight.w800, letterSpacing: -0.4), ), ), ], @@ -662,7 +662,7 @@ class DeliveryDetailsPage extends StatelessWidget { 'Proof of delivery', style: MilerType.body.copyWith( fontSize: 16.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.3, ), ), @@ -717,7 +717,7 @@ class DeliveryDetailsPage extends StatelessWidget { 'View full', style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: ColorConstants.onAccent, fontFamily: FontConstants.fontFamily, ), @@ -748,7 +748,7 @@ class DeliveryDetailsPage extends StatelessWidget { overflow: TextOverflow.ellipsis, style: MilerType.micro.copyWith( fontSize: 12.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, ), ), ), @@ -1259,7 +1259,7 @@ class DeliveryDetailsPage extends StatelessWidget { overflow: TextOverflow.ellipsis, style: MilerType.micro.copyWith( fontSize: 14.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: ColorConstants.slateText, ), ), @@ -1461,8 +1461,8 @@ class _InfoRow extends StatelessWidget { // ETA or a missed one. Those keep the weight; the // ordinary rows are medium. fontWeight: good == null - ? FontWeight.w500 - : FontWeight.w600, + ? FontWeight.w600 + : FontWeight.w700, color: valueColor, ), ), @@ -1582,7 +1582,7 @@ class _DisclosureRowState extends State<_DisclosureRow> { widget.title, style: MilerType.body.copyWith( fontSize: 15.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, letterSpacing: -0.2, ), ), @@ -1680,7 +1680,7 @@ class _JourneyPanelState extends State<_JourneyPanel> { overflow: TextOverflow.ellipsis, style: MilerType.body.copyWith( fontSize: 16.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.3, ), ), @@ -1744,7 +1744,7 @@ class _JourneyPanelState extends State<_JourneyPanel> { maxLines: 1, style: MilerType.body.copyWith( fontSize: 12.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.1, color: ColorConstants.primary, ), @@ -1836,7 +1836,7 @@ class _JourneyPanelState extends State<_JourneyPanel> { overflow: TextOverflow.ellipsis, style: MilerType.body.copyWith( fontSize: 14.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.2, ), ), @@ -1869,7 +1869,7 @@ class _JourneyPanelState extends State<_JourneyPanel> { overflow: TextOverflow.ellipsis, style: MilerType.micro.copyWith( fontSize: 12.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: Narrative.emerald, ), ), @@ -1921,7 +1921,7 @@ class _JourneyPanelState extends State<_JourneyPanel> { ? ColorConstants.slateText : ColorConstants.secondaryText, ).copyWith( - fontWeight: lead ? FontWeight.w700 : FontWeight.w600, + fontWeight: lead ? FontWeight.w800 : FontWeight.w700, letterSpacing: 0, ), ), @@ -2030,7 +2030,7 @@ class _JourneyPanelState extends State<_JourneyPanel> { overflow: TextOverflow.ellipsis, style: MilerType.body.copyWith( fontSize: 16.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, letterSpacing: -0.3, ), ), @@ -2061,8 +2061,8 @@ class _JourneyPanelState extends State<_JourneyPanel> { ? 14.sp : (lead ? 16.sp : 15.sp), fontWeight: point.place != null - ? FontWeight.w500 - : FontWeight.w600, + ? FontWeight.w600 + : FontWeight.w700, letterSpacing: -0.2, color: reached ? (point.place != null @@ -2222,7 +2222,7 @@ class _Stepper extends StatelessWidget { color: lead ? ColorConstants.slateText : ColorConstants.secondaryText, - ).copyWith(fontWeight: lead ? FontWeight.w700 : FontWeight.w600), + ).copyWith(fontWeight: lead ? FontWeight.w800 : FontWeight.w700), ), ], ); diff --git a/lib/views/Dashboard/home/accepted_pill.dart b/lib/views/Dashboard/home/accepted_pill.dart index 45707ad..84c6b50 100644 --- a/lib/views/Dashboard/home/accepted_pill.dart +++ b/lib/views/Dashboard/home/accepted_pill.dart @@ -128,7 +128,7 @@ class AcceptedPill extends StatelessWidget { '$count', style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, height: 1, letterSpacing: -0.3, color: ColorConstants.onAccent, @@ -152,7 +152,7 @@ class AcceptedPill extends StatelessWidget { _waiting(profile, count), style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.1, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, diff --git a/lib/views/Dashboard/home/duty_sheet.dart b/lib/views/Dashboard/home/duty_sheet.dart index b81b956..b17cc8d 100644 --- a/lib/views/Dashboard/home/duty_sheet.dart +++ b/lib/views/Dashboard/home/duty_sheet.dart @@ -96,7 +96,7 @@ class DutySheet extends StatelessWidget { goingOnline ? 'Go on duty?' : 'Go off duty?', style: TextStyle( fontSize: 21.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.5, height: 1.2, fontFamily: FontConstants.fontFamily, @@ -123,7 +123,7 @@ class DutySheet extends StatelessWidget { style: TextStyle( fontSize: 14.5.sp, height: 1.45, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, color: ColorConstants.secondaryText, ), @@ -161,7 +161,7 @@ class DutySheet extends StatelessWidget { goingOnline ? 'Go on duty' : 'Go off duty', style: TextStyle( fontSize: 16.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.3, fontFamily: FontConstants.fontFamily, color: ColorConstants.onAccent, @@ -189,7 +189,7 @@ class DutySheet extends StatelessWidget { goingOnline ? 'Not yet' : 'Stay on duty', style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, color: ColorConstants.secondaryText, ), diff --git a/lib/views/Dashboard/home/home_header.dart b/lib/views/Dashboard/home/home_header.dart index ecc7842..8f25690 100644 --- a/lib/views/Dashboard/home/home_header.dart +++ b/lib/views/Dashboard/home/home_header.dart @@ -226,7 +226,7 @@ class HomeHeaderDelegate extends SliverPersistentHeaderDelegate { gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [Color(0xFFA51C1C), Color(0xFF8B1A1A)], + colors: [ColorConstants.brandBarTop, ColorConstants.brandBarBottom], ), ), child: MediaQuery.withClampedTextScaling( @@ -370,7 +370,7 @@ class _ExpandedBody extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, letterSpacing: 1.6, color: Colors.white.withValues(alpha: 0.72), fontFamily: FontConstants.fontFamily, @@ -400,7 +400,7 @@ class _ExpandedBody extends StatelessWidget { style: TextStyle( fontSize: 24.sp, height: 1.15, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.6, color: Colors.white, fontFamily: FontConstants.fontFamily, @@ -463,7 +463,7 @@ class _CollapsedRow extends StatelessWidget { maxLines: 1, style: TextStyle( fontSize: 14.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, letterSpacing: -0.2, color: Colors.white, fontFamily: FontConstants.fontFamily, @@ -598,7 +598,7 @@ class DutyPill extends StatelessWidget { maxLines: 1, style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, 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 a7a5d5a..d7677e3 100644 --- a/lib/views/Dashboard/home/homepage.dart +++ b/lib/views/Dashboard/home/homepage.dart @@ -63,7 +63,6 @@ import 'package:miler/data/consignment_state.dart'; import 'package:miler/views/Dashboard/pickups/collect_payment.dart'; import 'package:miler/views/Dashboard/pickups/stop_type.dart'; import 'package:miler/widget/Bottom_page.dart'; -import 'package:miler/data/meal_run_mock.dart'; import 'package:miler/data/order_manifest.dart'; import 'package:miler/data/milk_run.dart'; import 'package:miler/data/order_events.dart'; @@ -233,6 +232,11 @@ class _HomepageState extends State /// "picked", and `picked` is the delivery. See the collected store. Set _collectedIds = {}; + /// `next_action` per order, as the pivot returned it. Home reads it only to + /// count what is waiting on the other tab — the two screens must hand + /// [WorkBoundary] the same arguments or their counts drift apart. + Map _pivotActions = {}; + /// Orders the rider has written off today — delivered, cancelled or skipped. /// /// The union of the completed and skipped stores, both of which record a @@ -291,6 +295,9 @@ class _HomepageState extends State // booking already carries. One request, cached, and every failure resolves // to "use the row's own name" — see [PickupLocations]. unawaited(PickupLocations.ensureLoaded()); + // The building the logistics run is headed by — see [RiderHub]. Read from + // prefs the login wrote, so it costs nothing and cannot fail the fetch. + unawaited(RiderHub.ensureLoaded()); BottomPage.currentTab.addListener(_onTabChanged); WidgetsBinding.instance.addPostFrameCallback((_) { _fetchQueues(); @@ -677,7 +684,7 @@ class _HomepageState extends State 'Not at Pickup Location', style: TextStyle( fontSize: FontConstants.xLarge(context), - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, color: _textPrimary, ), @@ -704,7 +711,7 @@ class _HomepageState extends State 'OK', style: TextStyle( fontSize: FontConstants.regular(context), - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, ), ), @@ -764,7 +771,7 @@ class _HomepageState extends State style: TextStyle( fontFamily: FontConstants.fontFamily, color: _textPrimary, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, ), ), content: Text( @@ -1180,6 +1187,7 @@ class _HomepageState extends State final acceptedIds = await getAcceptedOrderIds(); final rejectedIds = await getRejectedOrderIds(); final collectedIds = await getCollectedOrderIds(); + final pivotActions = await getPivotNextActions(); // Stops parked for a return visit. They are Activity's now — see the // stamping loop below and `TripCard._showsOnHome`. final skippedIds = await getSkippedOrderIds(); @@ -1330,6 +1338,7 @@ class _HomepageState extends State _acceptedIds = acceptedIds; _rejectedIds = rejectedIds; _collectedIds = collectedIds; + _pivotActions = pivotActions; _ordersMap = newOrdersMap; _orderIds = newOrderIds; _selectedOrders = newSelection; @@ -1670,14 +1679,6 @@ class _HomepageState extends State } if (base == null) return; - // A telemetry log about a booking the backend has never issued is a row - // nobody can join to anything. The meal line's stops exist only on this - // phone until the endpoints land — see [MealRunMock]. - if (MealRunMock.active) { - debugPrint('[MEAL_MOCK] pickup log not posted for $orderId'); - return; - } - final coords = await _getValidCoordinates(); final now = DateTime.now(); final logdate = @@ -1719,7 +1720,7 @@ class _HomepageState extends State if (selectedOrderIds.isEmpty) return "Accept Bookings"; final statuses = selectedOrderIds - .map((id) => _getStatusFromOrder(_ordersMap[id] ?? {})) + .map((id) => _getStatusFromOrder(_bulkStopFor(id) ?? const {})) .toSet(); if (statuses.length == 1 && statuses.first == 'ACCEPT') { @@ -1794,6 +1795,7 @@ class _HomepageState extends State _tripStops, collectedIds: _collectedIds, acceptedIds: _acceptedIds, + pivotActions: _pivotActions, // ── The set the other screen was passing and this one was not ── // // Both screens call [WorkBoundary.deliveryQueue], which was supposed to be @@ -1911,17 +1913,29 @@ class _HomepageState extends State /// The middle ground between one-at-a-time and all-at-once: on a long trip /// he can scan the list, tick the four addresses he knows are open, and take /// them in a single action instead of eight taps. + /// ── Both filters here were keyed on the undecided list ── + /// + /// `_ordersMap.containsKey` dropped every already-accepted id before the + /// action ran, and `_selectedOrders` was then rebuilt over `_orderIds` — + /// also undecided-only — so an accepted id could not survive into + /// [_handleBulkAction] by either route. The selection bar could accept and + /// nothing else, whatever its label said. + /// + /// Both now resolve through [_bulkStopFor], so a ticked set that has already + /// been accepted arrives intact and the action advances it to arrived, and + /// then to picked. Which rung it is remains [_handleBulkAction]'s decision, + /// read off the stops themselves. Future _acceptSelectedStops(List orderIds) async { - final ids = orderIds.where((id) => _ordersMap.containsKey(id)).toList(); + final ids = orderIds.where((id) => _bulkStopFor(id) != null).toList(); if (ids.isEmpty || _acceptingTripId != null) return; setState(() { _acceptingTripId = 'selection'; - _selectedOrders = {for (final id in _orderIds) id: ids.contains(id)}; + _selectedOrders = {for (final id in ids) id: true}; }); try { - await _handleBulkAction(navigateToBookings: false); + await _handleBulkAction(); } finally { if (mounted) { setState(() { @@ -2331,6 +2345,24 @@ class _HomepageState extends State return null; } + /// The stop a bulk selection refers to, wherever it currently lives. + /// + /// ── Why [_ordersMap] on its own could only ever accept ── + /// + /// `_ordersMap` is built from `validOrders`, which is deliberately the + /// *undecided* stops only — so a stop leaves it the moment it is accepted. + /// Every rung past ACCEPT looked its stop up there, got null and `continue`d, + /// and the label is derived from the same map: a selection of accepted stops + /// read back as `ACCEPT`, so the button said **Accept Bookings** and posted + /// another accept. `Move to Arrived` and `Move to Picked` were unreachable + /// from the selection bar even though [_handleBulkAction] implements both. + /// + /// [_allTripStops] keeps every stop in every state, which is exactly what the + /// rungs past acceptance need. On a milk run the order stays on Home to be + /// walked accepted → arrived → picked — see [MilkRun]. + Map? _bulkStopFor(String orderId) => + _ordersMap[orderId] ?? _stopById(orderId); + /// Walks every ticked stop up one rung, in one gesture. /// /// This is the shape of the job at a kitchen: five parcels come off one @@ -2794,7 +2826,7 @@ class _HomepageState extends State style: TextStyle( fontFamily: FontConstants.fontFamily, fontSize: 14.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: Colors.white, ), ), @@ -2835,7 +2867,7 @@ class _HomepageState extends State actionLabel, style: TextStyle( fontFamily: FontConstants.fontFamily, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: Colors.white, ), ), @@ -2854,7 +2886,27 @@ class _HomepageState extends State /// checks each stop is still accepted before touching it. List> _lastAcceptedBatch = const []; - Future _handleBulkAction({bool navigateToBookings = true}) async { + /// Walks the ticked stops up one rung: ACCEPT → ACCEPTED → ARRIVED → PICKED. + /// + /// ── Where the rider ends up is the line's decision, not the caller's ── + /// + /// This took a `navigateToBookings` flag, defaulted to `true`, and the two + /// branches it selected disagreed about the thing [ServiceProfile] already + /// answers. The tick-based path passed `false` and got the profile-aware + /// branch; the bulk action button passed nothing, got the default, and hit an + /// unconditional `Get.offAll(BottomPage(initialIndex: 1))`. + /// + /// On a **milk run** that is the wrong move and it broke the whole ladder. + /// The rider accepts at a kitchen, and the order has to stay on Home to be + /// walked to arrived and then picked — see [MilkRun]. Throwing him at the + /// work tab on accept sent him to a tab that was correctly empty, made the + /// accept look like it had filed his order somewhere else, and left the two + /// rungs that follow it unreachable from where he was standing. + /// + /// So the flag is gone. `handsOffAtCollection` is the one rule and it is read + /// in one place: the line that hands off at *acceptance* (logistics) moves + /// him, the line that hands off at *collection* (milk man) keeps him on Home. + Future _handleBulkAction() async { final selectedOrderIds = _selectedOrders.entries .where((e) => e.value) .map((e) => e.key) @@ -2863,7 +2915,7 @@ class _HomepageState extends State if (selectedOrderIds.isEmpty) return; final statuses = selectedOrderIds - .map((id) => _getStatusFromOrder(_ordersMap[id] ?? {})) + .map((id) => _getStatusFromOrder(_bulkStopFor(id) ?? const {})) .toSet(); String targetStatus; @@ -2984,8 +3036,15 @@ class _HomepageState extends State final List> acceptedBookings = []; + /// Orders this batch confirmed as collected, mirrored into [_collectedIds] + /// once the loop is done so the cards re-read their leg immediately rather + /// than at the next poll. + final Set bulkCollected = {}; + for (final orderId in selectedOrderIds) { - final Booking = _ordersMap[orderId]; + // Not `_ordersMap` — a stop past ACCEPT is no longer in it, and every + // rung after the first was silently skipped here. See [_bulkStopFor]. + final Booking = _bulkStopFor(orderId); if (Booking == null) continue; final pickupId = int.tryParse('${Booking['pickupid'] ?? 0}') ?? 0; @@ -3071,12 +3130,45 @@ class _HomepageState extends State if (success) { _startPickupPosting(Booking); _hiddenOrderIds.add(orderId); - // Picked → remove from the accepted store so it stops counting as - // an open booking on the Bookings tab, and record it as finished - // so it lands on Activity. Both stores, or the stop falls out of - // the app entirely. - await removeAcceptedBookings([orderId]); - await addCompletedBookings([Booking]); + + // ── The write that says the load is in his hands ── + // + // `_advanceStop` records this rung in the collected store and + // this branch did not, so a bag picked from the **selection bar** + // was never marked as carried. That one omission is what put + // **Start Pickup** on a delivery: [MilkRun.workingKind] reads + // exactly this set to decide which leg a stop is on, and the + // booking adapter stamps `type: pickup` on every row, so with an + // empty set a bag already in the box still described itself as a + // collection. The same answer feeds `toDrop` on the queue's + // distance, so the card also measured to the kitchen he had just + // left instead of to the customer's door — which is the "km and + // time are wrong" half of it. + // + // Recorded only after the backend confirmed, same rule as accept. + await addCollectedOrderIds([orderId]); + bulkCollected.add(orderId); + // One copy of the day, and it is now stale — Deliveries must not + // render this order from a response that predates the hand-over. + unawaited(WorkRepository.instance.invalidate()); + + // ── Picked is a hand-over on one line and a finish on the other ── + // + // This branch closed the booking outright. That is right for + // **logistics**, where what he collects goes to the hub and the + // booking's own story ends at the counter. + // + // On a **milk run** it is wrong and it loses the order: collection + // is the *middle* of the day — every bag is followed by a round of + // drops he makes himself — so filing it as completed sent it to + // Activity instead of to Deliveries, and the delivery leg had + // nothing to work. See [ServiceProfile.handsOffAtCollection]. + if (ServiceProfile.active.handsOffAtCollection) { + await addAcceptedBookings([Booking]); + } else { + await removeAcceptedBookings([orderId]); + await addCompletedBookings([Booking]); + } // Off the arrived rung — it has been collected. await removeArrivedOrderIds([orderId]); } @@ -3093,6 +3185,12 @@ class _HomepageState extends State } } + // The leg every card reads is derived from this set, so it has to land + // before the next build — not at the next poll three seconds later. + if (bulkCollected.isNotEmpty && mounted) { + setState(() => _collectedIds = {..._collectedIds, ...bulkCollected}); + } + // Persist accepted bookings so the Bookings tab can show them and Home // keeps them filtered out. if (acceptedBookings.isNotEmpty) { @@ -3183,7 +3281,7 @@ class _HomepageState extends State // the spot rather than at the next tick of its poller, and the switch is an // IndexedStack index change — the same ScaffoldMessenger, so the UNDO // snackbar below travels with him and is still there to be pressed. - if (bulkSucceeded && targetStatus == 'ACCEPTED' && !navigateToBookings) { + if (bulkSucceeded && targetStatus == 'ACCEPTED') { final taken = _lastAcceptedBatch; final n = taken.length; @@ -3234,12 +3332,6 @@ class _HomepageState extends State return; } - if (bulkSucceeded && targetStatus == 'ACCEPTED' && navigateToBookings) { - AppFeedback.successGlobal( - 'Moved to your ${ServiceProfile.active.workTabLabel}', - ); - Get.offAll(() => const BottomPage(initialIndex: 1)); - } } /// The whole route in one object: load, distance, time windows, shift. @@ -3256,6 +3348,20 @@ class _HomepageState extends State riderLng: _lastGeocodedPosition?.longitude ?? _riderLng, ); + /// A trip with nothing in it, for a slot the hub has not filled. + /// + /// Every figure the brief draws is derived from the stops, so an empty list + /// gives `0 stops left`, a progress track at zero and an em dash under + /// duration, distance and payment on its own — there is no "empty" variant of + /// the brief to keep in step with the real one, because this *is* the real + /// one with nothing in it. + Trip _unassignedTrip() => Trip.fromStops( + id: 'unassigned-$_selectedTripIndex', + stops: const [], + hubLat: _lastGeocodedPosition?.latitude ?? _riderLat, + hubLng: _lastGeocodedPosition?.longitude ?? _riderLng, + ); + /// One configured [TripCard] for the selected trip. /// /// Home does not render the card as a single widget any more: its summary, its @@ -3556,7 +3662,7 @@ class _HomepageState extends State textAlign: TextAlign.center, style: TextStyle( fontSize: 18.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.4, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -3564,13 +3670,13 @@ class _HomepageState extends State ), SizedBox(height: 6.h), Text( - 'Stay on duty — new orders appear here the moment ' - 'your office assigns them.', + 'Stay on duty new bookings appear here the moment ' + 'your office assigns you.', textAlign: TextAlign.center, style: TextStyle( fontSize: 13.5.sp, height: 1.45, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -3601,7 +3707,7 @@ class _HomepageState extends State textAlign: TextAlign.center, style: TextStyle( fontSize: 18.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, color: _textPrimary, ), @@ -3624,7 +3730,7 @@ class _HomepageState extends State "Go Online", style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, ), ), @@ -3717,7 +3823,7 @@ class _HomepageState extends State '${_getBulkButtonLabel()} (${selectedCount})', style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, color: Colors.white, letterSpacing: 0.3, @@ -3763,7 +3869,7 @@ class _HomepageState extends State style: TextStyle( fontFamily: FontConstants.fontFamily, color: _textPrimary, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, ), ), content: Text( @@ -3868,32 +3974,9 @@ class _HomepageState extends State // 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) { - // His choice from here on, even if it is an empty - // slot — see [_settleTripTab]. - _tripTabChosen = true; - if (index == _selectedTripIndex) return; - setState(() { - _selectedTripIndex = index; - _selectedStopIds.clear(); - }); - }, - ), - ), - ), + // The tabs are no longer a sliver of their own. They are + // the head of the panel that holds the trip, and that panel + // is built with the work below — see the `else` branch. // ══════════════════════════════════════════════════════════ // READING ORDER @@ -3989,15 +4072,119 @@ class _HomepageState extends State ), ), ) - else + // ── The panel is the page ── + // + // Tabs and trip used to be two slivers: a control in one + // box, the work it governs in the next, with nothing but + // adjacency to say they were related. The panel encloses + // the run now — the selected tab is the top edge of the + // brand surface the brief, the route and the action bar all + // sit inside, so which trip is on screen is said once, by + // the shape holding it. + // + // Still not pinned. The header keeps + // `Trip 1 · 17 left · 33/50` when it collapses, which is + // what a rider scrolling needs; a tab strip held + // permanently would be the same information twice, in the + // space the work is supposed to be in. + // ── The tabs are attached to the trip's figures ── + // + // Tabs and brief used to be two boxes: a control in one + // sliver, the figures it governs in the next, with nothing + // but adjacency to say they were related. They are one + // panel now — the selected tab is the top edge of the glass + // the brief sits on, so which trip is on screen is said + // once, by the shape holding it. + // + // The route and the action bar stay outside it, on the + // canvas with their own white panel. A version of this put + // the whole page inside the panel and it was too much: the + // ground a rider reads a route off should be the quietest + // thing on the screen, not the loudest. + // + // Still not pinned. The header keeps + // `Trip 1 · 17 left · 33/50` when it collapses, which is + // what a rider scrolling needs; a tab strip held + // permanently would be the same information twice, in the + // space the work is supposed to be in. + else ...[ + SliverToBoxAdapter( + // Air above, and the panel takes the same gutter as + // every other layer-1 surface on Home. The inset inside + // it is [TripSelector.contentInset], so the brief's copy + // lands on the same vertical as `TODAY'S RUN` below. + child: Padding( + padding: EdgeInsets.fromLTRB( + MilerSurface.panelGutter, + 12.h, + MilerSurface.panelGutter, + 0, + ), + 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) { + // His choice from here on, even if it is an empty + // slot — see [_settleTripTab]. + _tripTabChosen = true; + if (index == _selectedTripIndex) return; + setState(() { + _selectedTripIndex = index; + _selectedStopIds.clear(); + }); + }, + // ── An unfilled slot gets the same panel ── + // + // It used to get a different one: a line of its own + // reading `Not assigned yet`, which made the panel + // a 32pt stub under a tab twice its height, and + // changed the shape of the screen depending on + // whether the hub had got to this slot yet. + // + // The brief of an empty slot is a real brief — it + // is just all zeroes. `0 stops left`, an empty + // track, and an em dash under every figure that has + // nothing to report. So the panel is the same panel + // at the same height, and when the hub assigns the + // trip the figures fill in where the dashes were + // instead of the page changing shape around the + // rider. + body: SmoothSwap( + child: Reveal( + // Keyed on the trip, so switching tabs plays + // the entrance once and the 5s poll never + // replays it. The empty slot keys on its own + // index for the same reason. + key: ValueKey( + 'brief_${trip?.id ?? 'unassigned-$_selectedTripIndex'}', + ), + // The panel above is the surface and it is the + // brand bar's red, so the brief drops its own + // card and inverts its ink. + child: _tripCardFor( + trip ?? _unassignedTrip(), + ).buildSummary(onBrand: true), + ), + ), + ), + ), + ), + + // The route and the action bar, on the canvas. 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. + // 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( @@ -4008,37 +4195,21 @@ class _HomepageState extends State ), ) : 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. + // 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(), ], @@ -4046,6 +4217,7 @@ class _HomepageState extends State ), ), ), + ], // ── Room for everything that floats over this list ── // @@ -4238,7 +4410,7 @@ Widget _buildDetailRow(String label, String value) { label, style: TextStyle( color: ColorConstants.onSurfaceVariant, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, fontSize: 14.sp, fontFamily: FontConstants.fontFamily, ), @@ -4258,7 +4430,7 @@ Widget _buildDetailRow(String label, String value) { value.contains('Accepted') ? primaryRed : ColorConstants.onSurface, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, fontSize: 14.sp, fontFamily: FontConstants.fontFamily, height: 1.35, @@ -4336,7 +4508,7 @@ void _showProductDetailsSheet( textAlign: TextAlign.center, style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/Dashboard/home/homepage_banner.dart b/lib/views/Dashboard/home/homepage_banner.dart index 972bc5e..ada5848 100644 --- a/lib/views/Dashboard/home/homepage_banner.dart +++ b/lib/views/Dashboard/home/homepage_banner.dart @@ -203,7 +203,7 @@ class _Card extends StatelessWidget { style: TextStyle( color: Colors.white, fontSize: 15.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.3, fontFamily: FontConstants.fontFamily, ), @@ -232,7 +232,7 @@ class _Card extends StatelessWidget { style: TextStyle( color: Colors.white.withValues(alpha: 0.82), fontSize: 12.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, ), ), @@ -323,7 +323,7 @@ class _LiveChip extends StatelessWidget { style: TextStyle( color: Colors.white, fontSize: 9.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: 0.7, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/Dashboard/home/pickup_preview_sheet.dart b/lib/views/Dashboard/home/pickup_preview_sheet.dart index c5099d6..6477592 100644 --- a/lib/views/Dashboard/home/pickup_preview_sheet.dart +++ b/lib/views/Dashboard/home/pickup_preview_sheet.dart @@ -153,8 +153,7 @@ class PickupPreviewSheet extends StatelessWidget { /// route card behind this sheet — the card had no address at all while this /// one did, and a counter that is named on the sheet and anonymous on the /// card is the same payload answering two different questions. - String get _address => - lines.isEmpty ? '' : pickupAddressOf(lines.first.stop); + String get _address => lines.isEmpty ? '' : pickupAddressOf(lines.first.stop); static double _d(dynamic v) { if (v == null) return 0; @@ -228,7 +227,7 @@ class PickupPreviewSheet extends StatelessWidget { overflow: TextOverflow.ellipsis, style: MilerType.body.copyWith( fontSize: 19.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.5, height: 1.15, color: Narrative.ink, @@ -340,7 +339,7 @@ class PickupPreviewSheet extends StatelessWidget { overflow: TextOverflow.ellipsis, style: MilerType.body.copyWith( fontSize: 12.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: Narrative.ink, ), ), @@ -427,7 +426,7 @@ class PickupPreviewSheet extends StatelessWidget { overflow: TextOverflow.ellipsis, style: MilerType.body.copyWith( fontSize: 15.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.onSurfaceVariant, ), ), @@ -457,7 +456,7 @@ class PickupPreviewSheet extends StatelessWidget { maxLines: 1, style: MilerType.label.copyWith( fontSize: 12.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.secondaryText, ), ), @@ -476,7 +475,7 @@ class PickupPreviewSheet extends StatelessWidget { OrderManifest.countLabel(lines.length), style: MilerType.micro.copyWith( fontSize: 12.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: Narrative.inkMuted, ), ), diff --git a/lib/views/Dashboard/home/route_brief.dart b/lib/views/Dashboard/home/route_brief.dart index 2e56461..2cc877c 100644 --- a/lib/views/Dashboard/home/route_brief.dart +++ b/lib/views/Dashboard/home/route_brief.dart @@ -363,7 +363,7 @@ class ShiftBanner extends StatelessWidget { style: TextStyle( color: ColorConstants.slateText, fontSize: 12.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, ), ), @@ -378,7 +378,7 @@ class ShiftBanner extends StatelessWidget { ? ColorConstants.warning : ColorConstants.secondaryText), fontSize: 11.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, ), ), diff --git a/lib/views/Dashboard/home/route_timeline.dart b/lib/views/Dashboard/home/route_timeline.dart index 25defe3..a1c7f96 100644 --- a/lib/views/Dashboard/home/route_timeline.dart +++ b/lib/views/Dashboard/home/route_timeline.dart @@ -8,6 +8,7 @@ import 'package:miler/data/service_profile.dart'; import 'package:miler/data/stop_area.dart'; import 'package:miler/views/Dashboard/home/stop_card.dart' show LiveMark; import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/views/Dashboard/pickups/stop_type.dart'; import 'package:miler/views/Dashboard/pickups/route_metrics.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; @@ -209,9 +210,7 @@ class RouteGroup { if (allStopStates.length != allStops.length) { return allStops.isNotEmpty ? allStops.length : stops.length; } - final carried = allStopStates - .where((s) => s == StopState.collected) - .length; + final carried = allStopStates.where((s) => s == StopState.collected).length; return stops.length + carried; } @@ -495,14 +494,32 @@ class _RouteTimelineState extends State { } /// Every order on the run the rider may tick right now, across the flat - /// groups — a parcel route, where each stop is its own place. + /// groups — the stops that are their own place. /// /// One rung only, and only when there are at least two: with one stop its own /// row already is the select-all. + /// + /// ── Why this is not "every group is flat" ── + /// + /// It was, and the guard was right while a route was one shape or the other: + /// a parcel run of single-stop nodes got a run-level select-all, a kitchen + /// run got one per counter, and neither had the other's. + /// + /// A logistics route is now **both** — its drops gather under the hub they + /// were loaded at, its collections stay their own doors — and `every` made + /// the whole control vanish the moment one group was not flat. The rider lost + /// the only way to take his collections in one go, on exactly the routes that + /// have the most of them. + /// + /// So it gathers from the flat groups and ignores the rest. A grouped place + /// already carries its own select-all *inside* it, over its own orders, which + /// is the control that belongs to it; this one is for the stops that have no + /// place above them to hold it. A run with no flat groups at all — a milk + /// round — still gets nothing here, exactly as before. List get _flatSelectIds { - if (!widget.groups.every((g) => g.flat)) return const []; final ids = []; for (final g in widget.groups) { + if (!g.flat) continue; if (g.state != StopState.pending) continue; for (final (i, s) in g.stops.indexed) { if (g.stopStates[i] != StopState.pending) continue; @@ -783,7 +800,7 @@ class SelectAllAction extends StatelessWidget { overflow: TextOverflow.ellipsis, textAlign: scoped ? TextAlign.right : TextAlign.left, style: MilerType.label.copyWith( - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: accent, ), ), @@ -1006,7 +1023,7 @@ class PickupTimelineGroup extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: MilerType.label.copyWith( - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: ColorConstants.primary, ), ), @@ -1488,7 +1505,7 @@ class PickupTimelineGroup extends StatelessWidget { ? MilerType.cardTitle : MilerType.body) .copyWith( - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: group.isDone || rejected ? ColorConstants.secondaryText : ColorConstants.slateText, @@ -1571,7 +1588,7 @@ class PickupTimelineGroup extends StatelessWidget { // pale grey reserved for things nobody has // to act on. style: MilerType.label.copyWith( - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: chosen > 0 ? ColorConstants.primary : ColorConstants.onSurfaceVariant, @@ -1608,7 +1625,18 @@ 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. + // ── And on a flat stop, which has no rail under it ── + // + // A place with one order draws no list, so there is no + // rung for [_KindTag] to sit on — and a logistics route + // is mostly flat stops, which is exactly where "am I + // collecting or dropping here" is asked. The tag goes on + // the header instead, in the same corner and at the same + // size, so the answer is in one place whichever shape the + // route happens to take. if (group.flat) ...[ + if (group.stops.isNotEmpty) + _KindTag(kind: stopKindOf(group.stops.first)), SizedBox(width: 4.w), Icon( LucideIcons.chevronRight, @@ -2151,8 +2179,8 @@ class PickupTimelineStep extends StatelessWidget { style: MilerType.body.copyWith( fontSize: 15.sp, fontWeight: selected || accepted - ? FontWeight.w600 - : FontWeight.w500, + ? FontWeight.w700 + : FontWeight.w600, color: rejected ? ColorConstants.secondaryText : selected || accepted @@ -2190,6 +2218,11 @@ class PickupTimelineStep extends StatelessWidget { // [OrderManifest.labelFor]. label: rejected ? 'Rejected' : line.label, orderId: line.orderId, + // What the rider is doing at this door — see + // [_KindTag]. Read from the stop itself, so a route + // that mixes collections and drops says which is which + // on every rung. + kind: stopKindOf(line.stop), selected: selected, onTap: canOpen ? onOpen : null, ), @@ -2228,12 +2261,17 @@ class _OrderOpenBlock extends StatelessWidget { final String label; final String orderId; + + /// Collection, drop, or both — stamped beside the chevron. + final StopKind kind; + final bool selected; final VoidCallback? onTap; const _OrderOpenBlock({ required this.label, required this.orderId, + required this.kind, required this.selected, required this.onTap, }); @@ -2310,8 +2348,8 @@ class _OrderOpenBlock extends StatelessWidget { style: MilerType.label.copyWith( fontSize: 12.sp, fontWeight: selected - ? FontWeight.w700 - : FontWeight.w600, + ? FontWeight.w800 + : FontWeight.w700, color: selected ? ColorConstants.primary : ColorConstants.secondaryText, @@ -2327,12 +2365,22 @@ class _OrderOpenBlock extends StatelessWidget { // the list. The id is on the detail sheet this block opens, and // on the Activity record — both places the rare question is // actually asked. - if (onTap != null) + // ── What he is doing here, in one or two characters ── + // + // A mixed route runs collections and drops down the same rail, + // and until now the only way to tell one rung from another was + // to open it. The tag sits inside the block the chevron closes, + // so the row reads `name · area · P ›` — identity, place, job, + // and the way in. + _KindTag(kind: kind), + if (onTap != null) ...[ + SizedBox(width: 4.w), Icon( LucideIcons.chevronRight, size: 18.sp, color: ColorConstants.borderStrong, ), + ], ], ), ), @@ -2342,6 +2390,60 @@ class _OrderOpenBlock extends StatelessWidget { } } +/// `P`, `D` or `P&D` on a rail rung. +/// +/// ── A mark, not a word ── +/// +/// The full names are [StopKindUi.badgeLabel], and they are what the stop's own +/// card and sheet carry — there is room there, and the rider is reading one +/// stop. The rail is a *column*: he runs down it deciding which doors are +/// collections and which are drops, and `PICKUP & DELIVERY` at a size that fits +/// beside a chevron is a word he has to read on every rung. One or two +/// characters is a mark he recognises. See [StopKindUi.shortLabel]. +/// +/// ── And it is grey ── +/// +/// The obvious version tints it with the kind's own accent — collections one +/// colour, drops another. `stop_card_neutral_test.dart` forbids exactly that, +/// and the reason survives this tag: the route carries **one** accent, the +/// brand, on the live node and the journey action, and a per-stop hue down the +/// rail competes with the one mark that says *where you are*. Three hues on +/// every rung is a second colour system on the screen that most needs one. +/// +/// What was asked for here is the *information* — which of these is a +/// collection and which is a drop — and a letter carries that on its own. So +/// the tag is the same quiet grey as the metric tiles, and the accents stay +/// where that test put them: on the stop's own sheet, one tap away, where the +/// rider is reading one stop instead of scanning forty. +class _KindTag extends StatelessWidget { + final StopKind kind; + + const _KindTag({required this.kind}); + + @override + Widget build(BuildContext context) { + return Container( + margin: EdgeInsets.only(left: 6.w), + padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 2.h), + decoration: BoxDecoration( + color: ColorConstants.tint(ColorConstants.slateText, 0.055), + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + ), + child: Text( + kind.shortLabel, + maxLines: 1, + style: MilerType.label.copyWith( + fontSize: 10.5.sp, + fontWeight: FontWeight.w900, + letterSpacing: 0.2, + height: 1.1, + color: ColorConstants.secondaryText, + ), + ), + ); + } +} + /// The order id, as every screen reads it. String stopIdOf(Map stop) => (stop['orderid'] ?? '').toString(); @@ -2893,7 +2995,7 @@ class _JourneyColumn extends StatelessWidget { // it: same column, one step down in size and weight. style: MilerType.label.copyWith( fontSize: prominent ? 13.sp : 12.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.secondaryText, ), ), @@ -2981,7 +3083,7 @@ class _StateWord extends StatelessWidget { overflow: TextOverflow.ellipsis, style: MilerType.label.copyWith( fontSize: 13.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: color, ), ), @@ -3182,7 +3284,7 @@ class _TextAction extends StatelessWidget { Text( label, style: MilerType.label.copyWith( - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.primary, ), ), diff --git a/lib/views/Dashboard/home/selection_bar.dart b/lib/views/Dashboard/home/selection_bar.dart index 6ad69e2..713255a 100644 --- a/lib/views/Dashboard/home/selection_bar.dart +++ b/lib/views/Dashboard/home/selection_bar.dart @@ -245,7 +245,7 @@ class SelectionBar extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.2, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, diff --git a/lib/views/Dashboard/home/stop_action_sheet.dart b/lib/views/Dashboard/home/stop_action_sheet.dart index a2cc3b8..8447d51 100644 --- a/lib/views/Dashboard/home/stop_action_sheet.dart +++ b/lib/views/Dashboard/home/stop_action_sheet.dart @@ -219,7 +219,10 @@ class _StopActionSheetState extends State { (String, bool)? get _instruction { if (_isArrival || widget.lines.isEmpty) return null; if (!_canSlide) { - return ('Nothing was handed over — call your office before you leave.', true); + return ( + 'Nothing was handed over — call your office before you leave.', + true, + ); } if (_missing.isEmpty) { return ( @@ -317,7 +320,7 @@ class _StopActionSheetState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 19.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, letterSpacing: -0.5, height: 1.2, color: ColorConstants.slateText, @@ -329,7 +332,7 @@ class _StopActionSheetState extends State { _subtitle, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -345,7 +348,7 @@ class _StopActionSheetState extends State { text, style: TextStyle( fontSize: 13.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, height: 1.35, color: exceptional ? ColorConstants.warning @@ -429,7 +432,7 @@ class _StopActionSheetState extends State { 'Report a missing order', style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: ColorConstants.errorRed, fontFamily: FontConstants.fontFamily, ), @@ -447,7 +450,7 @@ class _StopActionSheetState extends State { : '${_missing.length} marked missing', style: TextStyle( fontSize: 13.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -464,7 +467,7 @@ class _StopActionSheetState extends State { 'Cancel', style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -559,7 +562,7 @@ class _StopActionSheetState extends State { '${i + 1}', style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -575,7 +578,7 @@ class _StopActionSheetState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.2, color: gone ? ColorConstants.secondaryText @@ -598,30 +601,33 @@ class _StopActionSheetState extends State { // only on the exception, which is the one time it carries a // fact the row does not already state. if (gone || line.label.isNotEmpty) - Container( - padding: EdgeInsets.symmetric(horizontal: 7.w, vertical: 3.h), - decoration: BoxDecoration( - color: gone - ? ColorConstants.errorRed.withValues(alpha: 0.08) - : MilerSurface.working, - borderRadius: BorderRadius.circular( - DesignConstants.radiusLg, + Container( + padding: EdgeInsets.symmetric( + horizontal: 7.w, + vertical: 3.h, ), - ), - child: Text( - gone ? 'Missing' : line.label, - maxLines: 1, - style: TextStyle( - fontSize: 12.5.sp, - fontWeight: FontWeight.w600, - letterSpacing: -0.1, + decoration: BoxDecoration( color: gone - ? ColorConstants.errorRed - : ColorConstants.secondaryText, - fontFamily: FontConstants.fontFamily, + ? ColorConstants.errorRed.withValues(alpha: 0.08) + : MilerSurface.working, + borderRadius: BorderRadius.circular( + DesignConstants.radiusLg, + ), + ), + child: Text( + gone ? 'Missing' : line.label, + maxLines: 1, + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.1, + color: gone + ? ColorConstants.errorRed + : ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), ), ), - ), ], ), ); @@ -668,7 +674,7 @@ class _StopActionSheetState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.2, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -783,7 +789,7 @@ class _CratePhotoRow extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.1, fontFamily: FontConstants.fontFamily, color: taken @@ -800,7 +806,7 @@ class _CratePhotoRow extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, color: ColorConstants.secondaryText, ), diff --git a/lib/views/Dashboard/home/stop_card.dart b/lib/views/Dashboard/home/stop_card.dart index 28c970c..8e48cce 100644 --- a/lib/views/Dashboard/home/stop_card.dart +++ b/lib/views/Dashboard/home/stop_card.dart @@ -208,7 +208,7 @@ class StopCard extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 21.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, height: 1.12, letterSpacing: -0.6, color: ColorConstants.slateText, @@ -227,7 +227,7 @@ class StopCard extends StatelessWidget { style: TextStyle( fontSize: 15.sp, height: 1.4, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -290,7 +290,7 @@ class StopCard extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, letterSpacing: 1.0, // A mark, in the type's own colour — never a fill and never a // border. See `stop_card_neutral_test`, which pins exactly that. @@ -336,7 +336,7 @@ class StopCard extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 15.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.2, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -387,7 +387,7 @@ class StopCard extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, letterSpacing: -0.2, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -518,7 +518,7 @@ class StopCard extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, ), @@ -629,7 +629,7 @@ class _LabelChip extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.1, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -819,7 +819,7 @@ class _LiveMarkState extends State overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, letterSpacing: 0.2, color: accent, fontFamily: FontConstants.fontFamily, @@ -896,7 +896,7 @@ class StopStateChip extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: filled ? 12.5.sp : 11.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, letterSpacing: 0.3, color: look.color, fontFamily: FontConstants.fontFamily, @@ -953,7 +953,7 @@ class _MetaLine extends StatelessWidget { Widget build(BuildContext context) { TextStyle style(Color c) => TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.2, color: c, fontFamily: FontConstants.fontFamily, diff --git a/lib/views/Dashboard/home/stop_detail_sheet.dart b/lib/views/Dashboard/home/stop_detail_sheet.dart index 723ad03..678b72a 100644 --- a/lib/views/Dashboard/home/stop_detail_sheet.dart +++ b/lib/views/Dashboard/home/stop_detail_sheet.dart @@ -202,8 +202,12 @@ class StopDetailSheet extends StatelessWidget { // 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 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']); @@ -266,114 +270,114 @@ class StopDetailSheet extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _header(kind, name), - SizedBox(height: 14.h), - if (hasLocation) ...[ - // ── 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(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, - ), - ), + _header(kind, name), + SizedBox(height: 14.h), + if (hasLocation) ...[ + // ── 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(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, ), - SizedBox(height: 18.h), - ], - _sectionLabel('TASK DETAILS'), - SizedBox(height: 8.h), - // ── The call sits on the job, not at the foot of the sheet ── + ), + // 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.w600, + color: Narrative.ink, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + SizedBox(height: 18.h), + ], + _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: Narrative.burgundy, + title: _countLabel('Deliver', deliver), + // ── No OTP is asked for at the door ── // - // 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: Narrative.burgundy, - title: _countLabel('Deliver', deliver), - // ── 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: Narrative.burgundy, - title: _countLabel('Collect', collect), - detail: cash > 0 ? 'Collect ₹${cash.round()}' : 'Photo', - trailing: (phone.isEmpty || kind.hasDelivery) - ? null - : _CallDisc(onTap: () => _dial(phone)), - ), - SizedBox(height: 6.h), - _detailGrid(), - if (notes.isNotEmpty) ...[ - SizedBox(height: 16.h), - _noteBox(notes), - ], - // ── 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. + // 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: Narrative.burgundy, + title: _countLabel('Collect', collect), + detail: cash > 0 ? 'Collect ₹${cash.round()}' : 'Photo', + trailing: (phone.isEmpty || kind.hasDelivery) + ? null + : _CallDisc(onTap: () => _dial(phone)), + ), + SizedBox(height: 6.h), + _detailGrid(), + if (notes.isNotEmpty) ...[ + SizedBox(height: 16.h), + _noteBox(notes), + ], + // ── 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. ], ), ), @@ -416,7 +420,7 @@ class StopDetailSheet extends StatelessWidget { '$stopNumber', style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: Narrative.burgundy, fontFamily: FontConstants.fontFamily, ), @@ -441,7 +445,7 @@ class StopDetailSheet extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 19.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.5, height: 1.15, color: Narrative.ink, @@ -463,7 +467,7 @@ class StopDetailSheet extends StatelessWidget { maxLines: 1, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: Narrative.inkMuted, fontFamily: FontConstants.fontFamily, ), @@ -582,7 +586,7 @@ class StopDetailSheet extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: Narrative.ink, fontFamily: FontConstants.fontFamily, ), @@ -596,7 +600,7 @@ class StopDetailSheet extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: Narrative.inkMuted, fontFamily: FontConstants.fontFamily, ), @@ -615,7 +619,7 @@ class StopDetailSheet extends StatelessWidget { text, style: TextStyle( fontSize: 10.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: 0.8, color: Narrative.inkMuted, fontFamily: FontConstants.fontFamily, @@ -678,7 +682,7 @@ class StopDetailSheet extends StatelessWidget { title, style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: Narrative.ink, fontFamily: FontConstants.fontFamily, ), @@ -701,7 +705,7 @@ class StopDetailSheet extends StatelessWidget { // read as something to tap, and it is a statement. style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: Narrative.inkMuted, fontFamily: FontConstants.fontFamily, ), @@ -815,7 +819,7 @@ class StopDetailSheet extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 9.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: 0.9, color: Narrative.inkMuted, fontFamily: FontConstants.fontFamily, @@ -839,7 +843,7 @@ class StopDetailSheet extends StatelessWidget { style: TextStyle( fontSize: 14.sp, height: 1.25, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.2, color: Narrative.ink, fontFamily: FontConstants.fontFamily, @@ -854,7 +858,7 @@ class StopDetailSheet extends StatelessWidget { style: TextStyle( fontSize: 14.sp, height: 1.25, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.2, color: Narrative.ink, fontFamily: FontConstants.fontFamily, @@ -890,7 +894,7 @@ class StopDetailSheet extends StatelessWidget { style: TextStyle( fontSize: 13.sp, height: 1.4, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.onErrorContainer, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/Dashboard/home/trip.dart b/lib/views/Dashboard/home/trip.dart index 0f80c28..5be29a4 100644 --- a/lib/views/Dashboard/home/trip.dart +++ b/lib/views/Dashboard/home/trip.dart @@ -482,14 +482,13 @@ class Trip { /// `eta` is read last and usually answers nothing — on the v1 adapter it is /// a duration, not a clock, and [_parseTimestamp] rejects it rather than /// inventing a 15th of the month out of "15 min". - static DateTime? dayPartTimeOf(Map stop) => - _parseTimestamp( - stop['expected_pickup_time'] ?? - stop['expectedpickuptime'] ?? - stop['assignedat'] ?? - stop['assignedon'] ?? - stop['eta'], - ); + static DateTime? dayPartTimeOf(Map stop) => _parseTimestamp( + stop['expected_pickup_time'] ?? + stop['expectedpickuptime'] ?? + stop['assignedat'] ?? + stop['assignedon'] ?? + stop['eta'], + ); /// Address components every stop on this trip shares — city, state, /// pincode and so on. Cached per build by the caller. diff --git a/lib/views/Dashboard/home/trip_brief_strip.dart b/lib/views/Dashboard/home/trip_brief_strip.dart index d0f92ae..7534802 100644 --- a/lib/views/Dashboard/home/trip_brief_strip.dart +++ b/lib/views/Dashboard/home/trip_brief_strip.dart @@ -92,12 +92,26 @@ class TripBriefStrip extends StatefulWidget { /// Opens the whole route on one map. Hidden when the trip has no stops. final VoidCallback? onViewRoute; + /// This brief is drawn on the brand, inside Home's trip tab panel. + /// + /// It changes two things together, because they are one decision. The white + /// panel comes off — the tabs are a surface in their own right and a card + /// inside a card is the nested-surface problem this file has been through + /// twice — and every ink in here inverts, because slate on the brand bar's + /// red is unreadable. + /// + /// One flag rather than two: a bare brief that kept its dark ink, or a white + /// brief that kept its panel, are both broken states nobody should be able to + /// ask for. See [TripSelector]. + final bool onBrand; + const TripBriefStrip({ super.key, required this.trip, required this.brief, required this.percent, this.onViewRoute, + this.onBrand = false, Trip? remaining, }) : remaining = remaining ?? trip; @@ -141,6 +155,40 @@ class _TripBriefStripState extends State { int get percent => widget.percent; VoidCallback? get onViewRoute => widget.onViewRoute; + // ── The ink follows the ground ── + // + // On the canvas the brief is slate on white. Inside Home's trip panel it is + // white on the brand bar's red. Every colour in this file that is not a + // *meaning* — not the green of money, not the amber of a warning — goes + // through one of these, so the two states cannot drift apart one `Text` at a + // time. + bool get _onBrand => widget.onBrand; + + /// Figures, headings, anything the rider reads first. + Color get _ink => _onBrand ? Colors.white : ColorConstants.slateText; + + /// Captions and labels: present, but not competing with [_ink]. + Color get _inkMuted => _onBrand + ? Colors.white.withValues(alpha: 0.74) + : ColorConstants.secondaryText; + + /// A rule between bands. + Color get _hairline => _onBrand + ? Colors.white.withValues(alpha: 0.20) + : ColorConstants.borderSubtle; + + /// The quiet tile behind a metric's glyph. + Color get _tileFill => _onBrand + ? Colors.white.withValues(alpha: 0.14) + : ColorConstants.tint(ColorConstants.slateText, 0.055); + + /// Money, and the completion green. Both are meanings rather than decoration, + /// so on the canvas they keep their own colour — but green on red is the one + /// pairing that fails outright, so on the brand they defer to white and let + /// the words carry it. + Color get _moneyInk => _onBrand ? Colors.white : ColorConstants.moneyGreen; + Color get _doneInk => _onBrand ? Colors.white : ColorConstants.acceptGreen; + /// Stops the rider has finished, derived from the percentage the page already /// computed rather than recounting the sets here. int get _done => (_trip.stopCount * percent / 100).round(); @@ -159,6 +207,16 @@ class _TripBriefStripState extends State { // sit"; measured, they were four points apart. Both sections now take // their edge from `MilerSurface.panelGutter` + `panelPad`, so they cannot // disagree — and `home_gutter_test` fails if they ever do. + // ── Unless the caller is already the surface ── + // + // Home's trip tabs draw a glass panel and attach themselves to its top + // edge; the brief is what is inside it. A [MilerPanel] in there would be a + // white card floating in a tinted one, which is the nested-surface problem + // this file has been through twice. So the caller says which it wants, the + // gutter argument below still holds for everyone who wants the panel, and + // the tabs supply their own inset. See [TripSelector.briefInset]. + if (widget.onBrand) return _column(); + return MilerPanel( // ── Not the bordered card that was removed from here ── // @@ -171,43 +229,48 @@ class _TripBriefStripState extends State { // off it at 1.290 : 1, which is a surface rather than a hairline — so // this returns as a *fill with no border*, which is the thing the old // box was only pretending to be. - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - Semantics( - button: true, - expanded: _open, - label: _open ? 'Hide trip figures' : 'Show trip figures', - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => setState(() => _open = !_open), - child: _head(), - ), + child: _column(), + ); + } + + /// The brief itself, with no surface of its own. + Widget _column() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + button: true, + expanded: _open, + label: _open ? 'Hide trip figures' : 'Show trip figures', + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => _open = !_open), + child: _head(), ), - // ── 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, - curve: Curves.easeOutCubic, - alignment: Alignment.topCenter, - child: _open - ? _body() - : const SizedBox(width: double.infinity, height: 0), - ), + ), + // ── 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, + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: _open + ? _body() + : const SizedBox(width: double.infinity, height: 0), ), - ], - ), + ), + ], ); } @@ -281,11 +344,7 @@ class _TripBriefStripState extends State { crossAxisAlignment: CrossAxisAlignment.center, children: [ if (complete) ...[ - Icon( - LucideIcons.circleCheck, - size: 22.sp, - color: ColorConstants.acceptGreen, - ), + Icon(LucideIcons.circleCheck, size: 22.sp, color: _doneInk), SizedBox(width: 8.w), // ── Expanded, not Flexible-plus-Spacer ── // @@ -305,9 +364,9 @@ class _TripBriefStripState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 20.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, letterSpacing: -0.5, - color: ColorConstants.acceptGreen, + color: _doneInk, fontFamily: FontConstants.fontFamily, ), ), @@ -333,7 +392,7 @@ class _TripBriefStripState extends State { textAlign: TextAlign.center, style: TextStyle( fontSize: 17.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.4, // ── 1.2 → 1.0 ── // @@ -349,7 +408,7 @@ class _TripBriefStripState extends State { // are the gaps either side of it. height: 1.0, fontFeatures: const [FontFeature.tabularFigures()], - color: ColorConstants.slateText, + color: _ink, fontFamily: FontConstants.fontFamily, ), ), @@ -364,7 +423,7 @@ class _TripBriefStripState extends State { child: Icon( LucideIcons.chevronDown, size: 20.sp, - color: ColorConstants.secondaryText, + color: _inkMuted, ), ), ], @@ -382,7 +441,12 @@ class _TripBriefStripState extends State { child: Container( height: 6.h, width: double.infinity, - color: MilerSurface.canvas, + // On the brand the canvas grey is darker than the ground it + // sits on, so an empty track reads as a filled one. White at a + // fifth is the same idea the other way up. + color: _onBrand + ? Colors.white.withValues(alpha: 0.22) + : MilerSurface.canvas, alignment: Alignment.centerLeft, child: AnimatedFractionallySizedBox( duration: DesignConstants.motionState, @@ -390,7 +454,10 @@ class _TripBriefStripState extends State { widthFactor: pct, child: Container( decoration: BoxDecoration( - color: complete + // Brand red on the brand bar is a fill you cannot see. + color: _onBrand + ? Colors.white + : complete ? ColorConstants.acceptGreen : ColorConstants.primary, borderRadius: BorderRadius.circular( @@ -435,7 +502,12 @@ class _TripBriefStripState extends State { // // Nothing is lost. Open the card and both figures are there, stated // properly, with the parcels and the cash beside them. - Row(children: [const Spacer(), Flexible(child: _timeLeftLine())]), + Row( + children: [ + const Spacer(), + Flexible(child: _timeLeftLine()), + ], + ), ], ), ), @@ -488,7 +560,7 @@ class _TripBriefStripState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: color, fontFamily: FontConstants.fontFamily, ), @@ -508,9 +580,9 @@ class _TripBriefStripState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.3, - color: ColorConstants.moneyGreen, + color: _moneyInk, fontFamily: FontConstants.fontFamily, ), ); @@ -551,7 +623,7 @@ class _TripBriefStripState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Divider(height: 1, color: ColorConstants.borderSubtle), + Divider(height: 1, color: _hairline), SizedBox(height: 14.h), // ── Band 1: the shift window ── @@ -572,10 +644,10 @@ class _TripBriefStripState extends State { icon: LucideIcons.calendarCheck, label: 'Shift', value: _brief.shiftWindowLabel, - tint: ColorConstants.slateText, + tint: _ink, ), SizedBox(height: 14.h), - Divider(height: 1, color: ColorConstants.borderSubtle), + Divider(height: 1, color: _hairline), SizedBox(height: 14.h), ], @@ -685,14 +757,10 @@ class _TripBriefStripState extends State { height: 34.w, alignment: Alignment.center, decoration: BoxDecoration( - color: ColorConstants.tint(ColorConstants.slateText, 0.055), + color: _tileFill, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), - child: Icon( - cell.$1, - size: 16.sp, - color: ColorConstants.secondaryText, - ), + child: Icon(cell.$1, size: 16.sp, color: _inkMuted), ), SizedBox(width: 10.w), Expanded( @@ -707,8 +775,8 @@ class _TripBriefStripState extends State { style: TextStyle( fontSize: 12.5.sp, height: 1.2, - fontWeight: FontWeight.w500, - color: ColorConstants.secondaryText, + fontWeight: FontWeight.w600, + color: _inkMuted, fontFamily: FontConstants.fontFamily, ), ), @@ -722,12 +790,10 @@ class _TripBriefStripState extends State { style: TextStyle( fontSize: 15.sp, height: 1.15, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.3, fontFeatures: const [FontFeature.tabularFigures()], - color: cell.$4 - ? ColorConstants.secondaryText - : ColorConstants.slateText, + color: cell.$4 ? _inkMuted : _ink, fontFamily: FontConstants.fontFamily, ), ), @@ -755,7 +821,9 @@ class _TripBriefStripState extends State { height: 30.w, alignment: Alignment.center, decoration: BoxDecoration( - color: tint.withValues(alpha: 0.09), + color: _onBrand + ? Colors.white.withValues(alpha: 0.14) + : tint.withValues(alpha: 0.09), borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), child: Icon(icon, size: 16.sp, color: tint), @@ -765,8 +833,8 @@ class _TripBriefStripState extends State { label, style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w600, - color: ColorConstants.secondaryText, + fontWeight: FontWeight.w700, + color: _inkMuted, fontFamily: FontConstants.fontFamily, ), ), @@ -779,9 +847,9 @@ class _TripBriefStripState extends State { textAlign: TextAlign.right, style: TextStyle( fontSize: 13.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.2, - color: ColorConstants.slateText, + color: _ink, fontFamily: FontConstants.fontFamily, ), ), diff --git a/lib/views/Dashboard/home/trip_card.dart b/lib/views/Dashboard/home/trip_card.dart index dae55b7..d230c05 100644 --- a/lib/views/Dashboard/home/trip_card.dart +++ b/lib/views/Dashboard/home/trip_card.dart @@ -19,6 +19,7 @@ import 'package:miler/Models/stop_status.dart'; import 'package:miler/data/order_manifest.dart'; import 'package:miler/data/stop_area.dart'; import 'package:miler/data/milk_run.dart'; +import 'package:miler/data/pickup_locations.dart'; import 'package:miler/data/service_profile.dart'; /// ───────────────────────────────────────────────────────────────────────── @@ -251,7 +252,11 @@ class TripCard extends StatelessWidget { /// The collapsed trip brief: progress, time left, cash — and everything else /// one tap away. See [TripBriefStrip] for what this used to be and why the /// four surfaces it replaced were the wrong price for the information. - Widget buildSummary() => TripBriefStrip( + /// [onBrand] hands the brief over for Home's trip tab panel: no white panel + /// of its own, and its ink inverted for the brand red it lands on. See + /// [TripSelector]. + Widget buildSummary({bool onBrand = false}) => TripBriefStrip( + onBrand: onBrand, 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 @@ -423,11 +428,42 @@ class TripCard extends StatelessWidget { /// /// A group whose orders have all left Home — collected, and now on the work /// tab — emits nothing at all. Its heading would be a title over a gap. + /// Every stop is drawn as the **place** it is worked at, with its orders in a + /// list under it. + /// + /// ── One shape for both lines ── + /// + /// This was a flag, then a per-stop question, and it is now neither. The + /// history is worth keeping because each step was right about something: + /// + /// 1. `ServiceProfile.sourceIsKitchen` — a milk round grouped its orders + /// under the counter they are loaded at; a logistics route grouped + /// nothing. The argument for the second half was that a parcel rider + /// "goes to one door for one consignment", so a header above a single + /// order would print the same name twice. + /// 2. Then deliveries were added to it: a booking the rider *hands over* was + /// loaded at the depot, and six drops drawing six nodes repeated one base + /// address six times. + /// + /// The premise under step 1 was wrong, and the milk run is the proof. A place + /// holding one order does not print the same name twice: the header names + /// **where** — `VBK Complex, 11C-18, Ramasamy Rd` — and the row under it + /// names **who** — `Jai Hari Shankar P R`. Those are two different facts, and + /// on a collection the rider needs both: the address is what he rides to, the + /// customer is who he asks for when he gets there. Flattening them threw the + /// address away and titled the card with the person. + /// + /// So every stop groups by its source now, and the shape of Home is one shape + /// on both lines. Two collections off one address gather, which they always + /// should have; a lone one is a place holding one order. The only stop still + /// drawn flat is one with nothing to name a place by at all — no counter and + /// no address — where there is genuinely no header to write. See the `flat` + /// rule below, which is unchanged. + @visibleForTesting List routeGroups() { final order = []; final buckets = >{}; - final grouped = ServiceProfile.active.sourceIsKitchen; // Every stop is bucketed, not only the ones Home still draws: a group's bag // numbers are counted against the whole group the rider was handed, so the @@ -449,9 +485,23 @@ class TripCard extends StatelessWidget { // is the same two-readers-one-question failure `sourceNameOf` documents, // repeated on the id, and it splits a kitchen's orders across two groups // on the screen while the collect still treats them as one. - final key = grouped + // ── One key for a milk round's counter, one for the whole depot ── + // + // A kitchen line has several counters in a day and the rider loads at + // each in turn, so its stops key on the counter they came off. + // + // A logistics day has one building. It assigns the day's bookings — + // collections, drops and both — hands over the route, and the rider + // accepts the lot before he leaves. So the run belongs to that building, + // and every stop keys to it however the individual booking is addressed. + // See [RiderHub]. + // + // `base`, not the internal word for it: `no_hub_in_rider_copy_test` + // forbids that string in this layer and it is right to — a bucket key is + // one rename away from being a label. + final key = ServiceProfile.active.sourceIsKitchen ? MilkRun.sourceKeyOf(stop, within: trip.stops) - : 'stop:${_idOf(stop)}'; + : 'base'; if (!buckets.containsKey(key)) { buckets[key] = []; order.add(key); @@ -522,22 +572,35 @@ class TripCard extends StatelessWidget { // to name its place with — no counter name and no address. With an // address it is a place holding one order, headed by where it is, with // the order in the list under it. + // Every bucket is a place now, so the only stop still drawn flat is one + // with nothing to head it with — no counter name and no address. There + // is genuinely no header to write for that one. final flat = - all.length == 1 && - (!grouped || (stopSourceName(first).isEmpty && place.isEmpty)); + all.length == 1 && stopSourceName(first).isEmpty && place.isEmpty; // A grouped place is named by its counter, or failing that by where it // is. A flat one *is* the stop, so it is named by the customer — // `MilkRun.navigationLabel` answers "pickup" for a parcel that has not // been collected yet, which is a description of the leg rather than a // name the rider can read off a gate. + // ── The head card is named after the place, and the depot is a place ── + // + // On a logistics run that place is the rider's **hub**, not any one + // booking's address. Reading it off the stops titled the card with a + // customer's street — right for the drops, which are loaded at the + // depot, and wrong for the collections, which are not. + // + // It falls back through what the payload names and then the address, so + // a build with no locations table still draws the card it drew before + // rather than an untitled one. + final hub = ServiceProfile.active.sourceIsKitchen ? '' : RiderHub.name; final name = flat ? OrderManifest.customerOf(first) - : (grouped - ? (stopSourceName(first).trim().isNotEmpty - ? stopSourceName(first) - : place) - : MilkRun.navigationLabel(first)); + : (hub.isNotEmpty + ? hub + : stopSourceName(first).trim().isNotEmpty + ? stopSourceName(first) + : place); return RouteGroup( key: key, // A route with no source names still groups — into one unnamed @@ -559,7 +622,16 @@ class TripCard extends StatelessWidget { // components that are identical on every stop of every route and // would push the part that names the street off the end of the line. // Not repeated when it is already the heading — see [name] above. - location: flat || name.trim() == place ? '' : place, + // + // ── And never a booking's street under the hub's name ── + // + // `place` is the first stop's address, which is the right subtitle + // for a counter the stops were collected from and quite wrong under + // a depot: it printed a customer's road under `H1`, which reads as + // the hub's address and is not. The locations table gives the hub a + // name and no street, so when the head is the hub the line is left + // empty rather than filled with something that looks like an answer. + location: flat || hub.isNotEmpty || name.trim() == place ? '' : place, stops: stops, allStops: all, firstIndex: indices.first, @@ -857,7 +929,7 @@ class TripCard extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 15.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.2, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -879,7 +951,7 @@ class TripCard extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.1, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -1008,7 +1080,7 @@ class TripCard extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: 0.7, color: _hubColor, fontFamily: FontConstants.fontFamily, @@ -1101,7 +1173,7 @@ class TripCard extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.2, color: picked.isEmpty ? ColorConstants.secondaryText @@ -1117,7 +1189,7 @@ class TripCard extends StatelessWidget { all ? 'CLEAR' : 'ALL', style: TextStyle( fontSize: 11.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: 0.6, color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, @@ -1379,7 +1451,7 @@ class TripCard extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: ColorConstants.acceptGreen, fontFamily: FontConstants.fontFamily, ), @@ -1429,7 +1501,7 @@ class TripCard extends StatelessWidget { textAlign: TextAlign.center, style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -1477,7 +1549,7 @@ class _ViewRouteAction extends StatelessWidget { 'MAP', style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: 0.6, color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, diff --git a/lib/views/Dashboard/home/trip_empty_state.dart b/lib/views/Dashboard/home/trip_empty_state.dart index 35c8945..4132664 100644 --- a/lib/views/Dashboard/home/trip_empty_state.dart +++ b/lib/views/Dashboard/home/trip_empty_state.dart @@ -62,7 +62,7 @@ class TripEmptyState extends StatelessWidget { return Padding( // Generous top air. With the card gone there is no edge to separate this - // from the pinned tab bar, so the space does that job — and an empty + // from the tab bar above, so the space does that job — and an empty // screen is the one place in the app that can afford it. // // Vertical only: [MilerEmptyState] already insets its copy by 40 a side, diff --git a/lib/views/Dashboard/home/trip_progress_rail.dart b/lib/views/Dashboard/home/trip_progress_rail.dart index aa050c6..669473b 100644 --- a/lib/views/Dashboard/home/trip_progress_rail.dart +++ b/lib/views/Dashboard/home/trip_progress_rail.dart @@ -404,7 +404,7 @@ class _TripProgressRailState extends State { // Fixed against the box: the circle does not grow with the system // font scale, so the digits inside it cannot either. fontSize: 14.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.3, height: 1, color: ink, @@ -482,7 +482,7 @@ class _TripProgressRailState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.2, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -494,7 +494,7 @@ class _TripProgressRailState extends State { allDone ? 'All stops done' : '$done of $total done', style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.2, color: allDone ? ColorConstants.acceptGreen @@ -541,7 +541,7 @@ class _TripProgressRailState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: allDone ? ColorConstants.acceptGreen : ColorConstants.secondaryText, diff --git a/lib/views/Dashboard/home/trip_tabs.dart b/lib/views/Dashboard/home/trip_tabs.dart index c38d6ca..6c3e771 100644 --- a/lib/views/Dashboard/home/trip_tabs.dart +++ b/lib/views/Dashboard/home/trip_tabs.dart @@ -1,83 +1,140 @@ import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:tab_container/tab_container.dart'; import 'package:miler/views/Dashboard/home/trip.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'; +import 'package:miler/views/helpers/constants/miler_surface.dart'; /// ───────────────────────────────────────────────────────────────────────── -/// THE TRIP SELECTOR — 1 · 2 · 3, under the header on white +/// THE TRIP SELECTOR — 1 · 2 · 3, and what the chosen slot is /// /// A rider is assigned at most three trips a day, so three targets fit across a /// phone and every trip is one tap away. /// -/// ── It has no container ── +/// ── It is a [TabContainer] now ── /// -/// 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. +/// The control used to be a bare row of pills with a rule travelling under +/// them, drawn by hand: a marker animation, a per-pill width measurement pass, +/// and an overflow menu once a hub assigned more than three trips. All of that +/// was mechanism — tab geometry, tab transitions, tab overflow — re-implemented +/// for one screen. /// -/// It is a row of pills now, on the white page under the header: +/// `tab_container` owns that mechanism now. It draws the tab strip as part of +/// the panel's own shape, so the selected tab *is* the top edge of the body +/// below it, and it carries the selection between tabs itself. Past three +/// trips its tab strip scrolls, which is what the overflow menu was for. /// /// ``` -/// ╭─────────╮ ╭─────────╮ ╭─────────╮ -/// │ Trip 1 │ │ Trip 2 │ │ Trip 3 │ -/// ╰─────────╯ ╰─────────╯ ╰─────────╯ -/// ━━━━━ +/// ╭────────╮╭────────╮ ╭────────╮ +/// │ Trip 1 ││ Trip 2 │ │ Trip 3 │ +/// │ ━━━ ││ │ │ │ +/// ├────────╯╰────────┴─┴────────┴──────────╮ +/// │ 10:00 AM – 1:00 PM · 12 to collect │ +/// ╰────────────────────────────────────────╯ /// ``` /// -/// 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. +/// ── The rule stayed ── /// -/// ── Why it came off the brand ground ── +/// The package's own idiom is that the selected tab reads as a shaped +/// extension of the panel. On its own that is a *filled* tab, and a filled tab +/// is the thing this control has twice been redesigned away from — it makes a +/// filter look like a primary action and competes with the real button at the +/// foot of the card below. /// -/// 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. +/// So selection is still a short brand rule under the chosen numeral, drawn +/// inside the tab itself. The panel's shape says which tab owns the body; the +/// rule says which trip the rider is on. `test/card_density_test.dart` holds +/// both — one mark at a time, and a rule rather than a fill. /// -/// ── And it says only which trip it is ── +/// ── The body is the trip's own brief, and nothing else ── /// -/// 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. +/// A [TabContainer] has a body, and an empty one would be a panel drawn around +/// nothing. What goes in it is the brief the tabs choose between — the figures +/// for the selected trip — handed in bare, because this panel is already the +/// surface they stand on. /// +/// It carried a line of its own for a version: `1 delivery on this round`, +/// under a heading that already read `1 stop left`. One panel, one count, +/// stated twice — and on the device the two sat eight points apart. A panel +/// under a set of tabs does not also need to narrate what the tabs selected. +/// +/// The panel deliberately does **not** repeat `33 / 50` either. That ratio is +/// the headline of the card below in type four times this size; it was on these +/// tabs once and was taken off for exactly that reason. A filter says what it +/// filters to. /// ───────────────────────────────────────────────────────────────────────── class TripSelector extends StatefulWidget { final List trips; final int selectedIndex; final ValueChanged onSelect; - /// Optimistic accept/reject sets, for the per-trip count. + /// Optimistic accept/reject sets, for the per-slot count in the brief. final Set acceptedIds; final Set rejectedIds; - /// Trips shown inline before the rest collapse into the overflow menu. + /// The selected trip's whole page — brief, route and action bar. /// - /// 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. + /// ── The panel encloses the work, it does not sit above it ── + /// + /// The selector used to be a control in a sliver of its own, with the trip's + /// content in the next sliver down: two unrelated boxes, one of which + /// happened to govern the other. A [TabContainer] is a *container*, and the + /// thing it is built to contain is the content its tabs choose — so the + /// trip's page is handed in here and the selected tab is literally the top + /// edge of it. + /// + /// Null leaves the control standing on its own at [height], which is what the + /// widget tests measure and what any caller that only wants the tabs gets. + final Widget? body; + + /// The number of tab slots ALWAYS rendered. + /// + /// A rider's day is three trips, so 2 and 3 are shown before the hub has + /// assigned them. A tab that appears and disappears through the morning makes + /// the screen feel unstable; an empty third slot answers the question he + /// actually has. Past three, the hub's own count wins and the strip scrolls. static const int maxInlineTabs = 3; - /// The travelling selection indicator. + /// The selection rule. static const Key markerKey = ValueKey('trip_tab_marker'); - /// The control's height — the pill, the air under it, and the indicator. + /// The tab strip — [TabContainer.tabExtent]. /// - /// 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. + /// 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 => 42.sp; + + /// The rule's thickness — the spec's 2–3dp. + static const double indicator = 3; + + /// The panel's own inner padding, measured to land its copy on the same + /// vertical as the cards below it. + /// + /// A [MilerPanel] insets its content by its gutter, its padding and the one + /// point of border it keeps — which is why `TODAY'S RUN` begins at 29 and why + /// `home_gutter_test` measures that number. This panel is a [TabContainer] + /// rather than a [MilerPanel], and its caller supplies the gutter, so what is + /// left for it to add is the other two parts. Built from the same tokens, not + /// from a number that happens to match today. + static double get contentInset => MilerSurface.panelPad + 1; + + /// The control's own height: strip plus brief, with no [body] in it. + /// + /// Fixed, because the tabs and the line under them must not move under the + /// rider's thumb as trips arrive through the morning. With a [body] the panel + /// is as tall as the page it encloses and this is just its head. + /// + /// ── And capped, because it is charged continuously ── + /// + /// This is header furniture: the rider sees it at every scroll position, so + /// every point it takes is a point the route below never gets. The budget is + /// 80 and `card_density_test.dart` holds it. Adding the body cost the strip + /// two points rather than the budget four — the panel earns its height by + /// saying what the slot is, not by being taller than the row it replaced. /// /// ── Why there is no `.h` in this sum ── /// @@ -87,18 +144,7 @@ class TripSelector extends StatefulWidget { /// 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; + static double get height => pillHeight; const TripSelector({ super.key, @@ -107,6 +153,7 @@ class TripSelector extends StatefulWidget { required this.onSelect, this.acceptedIds = const {}, this.rejectedIds = const {}, + this.body, }); @override @@ -114,110 +161,74 @@ class TripSelector extends StatefulWidget { } 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. + with TickerProviderStateMixin { + TabController? _tabs; + + /// How many tab slots the strip draws. /// - /// 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. + /// Three is the norm, not a guarantee — a hub can assign more, and when it + /// does every one of them gets a tab rather than being folded away. + int get _slotCount => widget.trips.length > TripSelector.maxInlineTabs + ? widget.trips.length + : TripSelector.maxInlineTabs; + + /// The selection, clamped into the strip. /// - /// 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 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 _markerAnim; - late final Animation _markerCurve; - late Tween _markerTween; + /// The page owns the selection and is allowed to hold an index this control + /// cannot draw — a slot that existed before a refresh shortened the day. A + /// [TabController] cannot, so it is clamped here rather than asserted. + int get _safeIndex => widget.selectedIndex.clamp(0, _slotCount - 1); @override void initState() { super.initState(); - _markerAnim = AnimationController( + _buildController(); + } + + void _buildController() { + _tabs?.removeListener(_onTabChanged); + _tabs?.dispose(); + _tabs = TabController( + length: _slotCount, + initialIndex: _safeIndex, 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()), - ); + )..addListener(_onTabChanged); } - double get _markerPos => _markerTween.evaluate(_markerCurve); - - /// Trip indices rendered as inline segments, left to right. + /// The controller moved — either the rider tapped, or [didUpdateWidget] + /// drove it to follow the page. /// - /// Three is the norm, not a guarantee — a hub can assign more. Past three the - /// 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 > TripSelector.maxInlineTabs - ? widget.trips.length - : 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) - widget.selectedIndex, - ]; - } - - List _hiddenTabs(List inline) { - final tabCount = widget.trips.length > TripSelector.maxInlineTabs - ? widget.trips.length - : 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, - ]; - } - - /// Which *slot* the selection occupies. Not the same as the trip index once - /// an overflow menu is in play. - double _slotOf(List inline) { - final i = inline.indexOf(widget.selectedIndex); - return (i < 0 ? 0 : i).toDouble(); + /// Reported on `indexIsChanging == false` as well as on settle, so a tap is + /// answered as the animation starts rather than a frame after it lands: the + /// page swaps the route below, and waiting for the tab to arrive first shows + /// the outgoing trip sliding under the incoming label. + void _onTabChanged() { + final i = _tabs?.index; + if (i == null || i == widget.selectedIndex) return; + widget.onSelect(i); } @override 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 - _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); + + // A day that grew or shrank needs a controller of the new length; there is + // no way to re-length one in place. + if (_tabs == null || _tabs!.length != _slotCount) { + setState(_buildController); + return; + } + + // Selection is owned by the page, not by this control, so the travel + // starts from here rather than from a tap handler. Guarded, or the + // listener above and this line drive each other in a loop. + if (_tabs!.index != _safeIndex) _tabs!.animateTo(_safeIndex); } @override void dispose() { - _markerAnim.dispose(); + _tabs?.removeListener(_onTabChanged); + _tabs?.dispose(); super.dispose(); } @@ -226,347 +237,171 @@ class _TripSelectorState extends State /// 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 - /// first segment on a day with no morning stops, and the rider's trip 2 would - /// not be the hub's. + /// first tab on a day with no morning stops, and the rider's trip 2 would not + /// be the hub's. @override Widget build(BuildContext context) { - final inline = _inlineTabs(); - final hidden = _hiddenTabs(inline); + final controller = _tabs!; + final selected = _safeIndex; + + // With nothing to enclose, the control is the tab strip and nothing else — + // a fixed head, which is what `trip_tabs_pinned_test` measures. + final body = widget.body; return SizedBox( - height: TripSelector.height, - child: AnimatedBuilder( - animation: _markerAnim, - builder: (context, _) { - final p = _markerPos; - - return Stack( - children: [ - Row( + height: body == null ? TripSelector.height : null, + child: TabContainer( + controller: controller, + tabEdge: TabEdge.top, + tabExtent: TripSelector.pillHeight, + // Wide enough to stay a target at any text scale. Past three trips the + // strip scrolls at this width rather than shrinking every tab until + // none of them can be hit. + tabMinLength: 88.w, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + tabBorderRadius: BorderRadius.circular(DesignConstants.radiusLg), + // ── The app bar's own red ── + // + // The body and the selected tab are one shape in one fill, which is + // what says which tab owns what is under it. That fill is the brand + // bar's red — the same [ColorConstants.brandBarTop] the header above is + // painted in, from one definition so the two cannot drift into being + // *nearly* the same colour, which reads as a mistake rather than as a + // family. + // + // A gradient is not available here: [TabContainer] paints the tab notch + // and the body as one path in one colour. The bar's top stop is the + // more saturated of its two and the one the eye reads as "the app's + // red", so that is the one a solid takes. + // + // Everything inside the panel inverts to white with it — the brief + // included, which is why it is handed over with + // `buildSummary(onBrand: true)` rather than merely bare. + // + // The *unselected* tabs are not painted by this at all; they stand on + // the page canvas beside the panel. That is what makes the control read + // as tabs rather than as three words on a red band. + color: ColorConstants.brandBarTop, + // ── `childPadding` is not applied to a single `child` ── + // + // The package pads only the `children` branch: `_buildChild` reads + // `widget.child ?? Padding(padding: childPadding, …)`, so handing it one + // child skips the padding entirely and the brief sat hard against the + // screen edge. It is passed anyway for the day this takes `children`, + // and the inset that actually runs is [briefInset] below. + childPadding: EdgeInsets.symmetric(horizontal: MilerSurface.panelPad), + // The tabs are built here, fully styled, so the package must not + // re-style them: its own selected/unselected interpolation would scale + // the numeral and fight the rule underneath it. + overrideTextProperties: true, + duration: const Duration(milliseconds: 220), + curve: Curves.easeInOutCubic, + tabs: [ + for (var i = 0; i < _slotCount; i++) + _TripTab(index: i, selected: i == selected), + ], + // ── The dropdown is what the tabs are attached to ── + // + // The brief comes first and touches the tab strip, because the tabs + // choose the trip and the brief is that trip's figures: the selected + // tab is literally the top edge of the panel holding them. The slot + // line closes the panel underneath, where it reads as a caption on the + // figures rather than as something between the tabs and their content. + child: body == null + // Full width, zero height. `SizedBox.shrink()` is 0x0, and the + // panel takes its width from its child — so an empty one collapsed + // the whole control to nothing and the tabs stopped being tappable + // while still being painted. + ? const SizedBox(width: double.infinity, height: 0) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, 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), - ), + Padding( + // Air under the tab strip. Without it the brief's heading + // sits on the seam it shares with the selected tab, and the + // two read as one squeezed block rather than as a tab and + // the panel it opens. + padding: EdgeInsets.fromLTRB( + TripSelector.contentInset, + 12, + TripSelector.contentInset, + 0, ), - ], - if (hidden.isNotEmpty) ...[ - SizedBox(width: 10.w), - _OverflowChip( - width: 44.w, - hidden: hidden, - trips: widget.trips, - onSelect: widget.onSelect, - ), - ], + child: body, + ), + SizedBox(height: TripSelector.contentInset), ], ), - // ── 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( - key: TripSelector.markerKey, - height: TripSelector.indicator, - decoration: BoxDecoration( - color: ColorConstants.primary, - 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), - ), - ], - ), - ), - ), - ), - ), - ], - ); - }, ), ); } - - /// Every pill's measured width, by slot. - /// - /// 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(() {}); - }); - } - - /// The gap between pills, matching the `SizedBox` in the row. - double get _gap => 10.w; - - 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); - } - - 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; - } } -/// One pill: the trip's name, and whether it is the one being worked. +/// One tab: the trip's name, and the rule when it is the one being worked. /// -/// See [TripSelector] for why there is no count on it and no card around it. +/// See [TripSelector] for why selection is a rule rather than the package's +/// own filled tab. class _TripTab extends StatelessWidget { final int index; + final bool selected; - /// 0 → 1, how far the travelling rule is over this pill. - final double lit; - - final VoidCallback onTap; - - /// 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.onTap, - required this.onMeasured, - }); + const _TripTab({required this.index, required this.selected}); @override Widget build(BuildContext context) { - // ── Selection is colour and edge, not fill ── - // - // 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: lit >= 0.999, - label: 'Trip ${index + 1}', - excludeSemantics: true, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onTap, - 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, - ), + return Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + // Scale-down rather than ellipsis: the numeral is the whole label and + // `Trip 3` truncated to `Tri…` names nothing. This is also what keeps + // the strip one height at 2.0× text — see [TripSelector.height]. + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + Trip.tabLabel(index), + maxLines: 1, + style: TextStyle( + fontFamily: FontConstants.fontFamily, + fontSize: 14.sp, + // ── Two grounds, so two inks ── + // + // The selected tab is part of the red panel; the others stand + // on the page canvas beside it. There is no one colour for + // both — brand red on the brand bar is invisible, slate on it + // fails contrast — so the chosen numeral goes white and the + // rest stay slate, quiet enough that the row reads as one + // control with one choice in it rather than as three buttons. + fontWeight: selected ? FontWeight.w800 : FontWeight.w600, + color: selected + ? Colors.white + : ColorConstants.onSurfaceVariant, ), ), ), ), - ), - ); - } -} - -/// 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 _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, - }); - - @override - Widget build(BuildContext context) { - return SizedBox( - width: width, - child: Semantics( - button: true, - label: '${hidden.length} more trips', - excludeSemantics: true, - child: PopupMenuButton( - tooltip: '', - padding: EdgeInsets.zero, - color: ColorConstants.pureSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(DesignConstants.radiusLg), - ), - onSelected: onSelect, - itemBuilder: (context) => [ - for (final i in hidden) - PopupMenuItem( - value: i, - 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: 15.sp, - fontWeight: FontWeight.w700, - color: ColorConstants.slateText, - fontFamily: FontConstants.fontFamily, + SizedBox(height: 4.sp), + // The rule. Only the selected tab draws it, so there is exactly one in + // the tree at a time — which is what `card_density_test` asserts. + SizedBox( + height: TripSelector.indicator, + width: 26.w, + child: selected + ? Container( + key: TripSelector.markerKey, + decoration: BoxDecoration( + // White, because the rule sits on the brand. + color: Colors.white, + borderRadius: BorderRadius.circular( + DesignConstants.radiusFull, + ), ), - ), - ), - ], - 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, - ), - ), - ), - ), + ) + : null, ), - ), + ], ); } } diff --git a/lib/views/Dashboard/orders/orderstaus_button.dart b/lib/views/Dashboard/orders/orderstaus_button.dart index c9cc7eb..75a60df 100644 --- a/lib/views/Dashboard/orders/orderstaus_button.dart +++ b/lib/views/Dashboard/orders/orderstaus_button.dart @@ -431,7 +431,7 @@ class StatusPill extends StatelessWidget { style: TextStyle( fontSize: 14.5, color: fg, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, letterSpacing: 0.3, fontFamily: FontConstants.fontFamily, ), @@ -669,7 +669,7 @@ class _OrderStatusButtonState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, letterSpacing: 0.2, color: color, diff --git a/lib/views/Dashboard/pickups/card.dart b/lib/views/Dashboard/pickups/card.dart index 8f1be94..898f1fe 100644 --- a/lib/views/Dashboard/pickups/card.dart +++ b/lib/views/Dashboard/pickups/card.dart @@ -59,6 +59,19 @@ class PickupCard extends StatelessWidget { /// off the row's raw status. final Set outForDeliveryIds; + /// `next_action` per order, as the pivot returned it. + /// + /// Only needed to tell a **hub-routed** parcel from one whose routing has not + /// been reported yet: both sit on `Created`, and only the recorded pivot + /// instruction separates them. Everything else the card asks resolves from + /// the row's own `consignmentstatus`. + /// + /// Defaulting to empty is safe by construction — without it a hub parcel + /// reads as [NextLeg.unknown], which withholds the customer destination and + /// the Start-delivery control exactly as the hub leg does. It loses the + /// precise wording, never the guard. + final Map pivotActions; + const PickupCard({ super.key, required this.item, @@ -70,6 +83,7 @@ class PickupCard extends StatelessWidget { this.label = '', this.collectedIds = const {}, this.outForDeliveryIds = const {}, + this.pivotActions = const {}, }); String _val(List keys, [String fallback = '']) { @@ -101,6 +115,17 @@ class PickupCard extends StatelessWidget { // his box. See [MilkRun.workingKind]. final StopKind kind = MilkRun.workingKind(item, collectedIds: collectedIds); + // ── Where this parcel goes next, per order ── + // + // The card used to answer this from [ServiceProfile.deliversToCustomer] — a + // line-level constant — which is false on logistics. So a hyperlocal parcel + // already released into the rider's hands drew its *sender* as the + // destination, and a Chennai-bound one drew a receiver he is never going to. + final NextLegDecision leg = NextLegResolver.resolve( + item, + pivotAction: pivotActions[MilkRun.idOf(item)] ?? '', + ); + final String customer = _val([ 'pickupcustomer', 'tenantname', @@ -129,8 +154,12 @@ class PickupCard extends StatelessWidget { // customer names the pickup, and the drop end appears only if the // payload actually carries one. final String source = stopSourceName(item); - final bool pickupIsSource = - ServiceProfile.active.deliversToCustomer && source.isNotEmpty; + // The receiver names the far end whenever the rider is carrying it there — + // whether that is a milk round (line-level) or a hyperlocal parcel the + // backend routed direct (per-order). + final bool carryingToCustomer = + ServiceProfile.active.deliversToCustomer || leg.isCustomer; + final bool pickupIsSource = carryingToCustomer && source.isNotEmpty; final String pickupName = pickupIsSource ? source : customer; final String dropAddress = _val(['dropaddress', 'DropAddress']); final String dropName = pickupIsSource @@ -139,8 +168,16 @@ class PickupCard extends StatelessWidget { /// True once the rider is carrying it: the drop becomes the destination and /// takes the top of the card. + // ── The immediate destination leads; never a hub leg's receiver ── + // + // On [NextLeg.hub] the receiver is in another district and the rider is + // going to a hub. Letting that address take the headline is the navigation + // mistake this whole change exists to prevent, so a hub leg never leads + // with a drop — the final destination is demoted to context below. final bool dropLeads = - pickupIsSource && (dropName.isNotEmpty || dropAddress.isNotEmpty); + !leg.isHub && + (pickupIsSource || leg.isCustomer) && + (dropName.isNotEmpty || dropAddress.isNotEmpty); // ── No accent stripe any more ── // @@ -217,6 +254,30 @@ class PickupCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + // ── What kind of journey this is, above the name ── + // + // HUB HANDOVER and CUSTOMER DELIVERY are different + // physical acts with different endings, and the row + // has to say which before the rider reads the place. + // Drawn only once the parcel is actually his; an + // uncollected stop has no leg yet. + if (leg.leg.eyebrow.isNotEmpty) ...[ + Text( + leg.leg.eyebrow, + style: TextStyle( + fontSize: 10.sp, + fontWeight: FontWeight.w800, + letterSpacing: 0.8, + color: leg.isHub + ? ColorConstants.deliveryAccent + : leg.isUnknown + ? ColorConstants.warning + : ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 3.h), + ], Text( (dropLeads ? dropName : pickupName).isEmpty ? 'Not named' @@ -225,7 +286,7 @@ class PickupCard extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 22.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, height: 1.1, letterSpacing: -0.6, color: ColorConstants.slateText, @@ -264,7 +325,7 @@ class PickupCard extends StatelessWidget { style: TextStyle( fontSize: 15.sp, height: 1.35, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -357,7 +418,7 @@ class PickupCard extends StatelessWidget { style: TextStyle( fontSize: 14.5.sp, height: 1.35, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.warning, fontFamily: FontConstants.fontFamily, ), @@ -390,7 +451,7 @@ class PickupCard extends StatelessWidget { 'PICKED UP FROM', style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, letterSpacing: 0.8, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -409,7 +470,7 @@ class PickupCard extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.2, height: 1.2, color: ColorConstants.slateText, @@ -462,7 +523,7 @@ class PickupCard extends StatelessWidget { child: Text( 'Finish the current stop first', style: TextStyle( - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, fontSize: 14.sp, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -627,7 +688,7 @@ class _JourneyBlock extends StatelessWidget { distance, style: TextStyle( fontSize: 15.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, letterSpacing: -0.3, color: fg, fontFamily: FontConstants.fontFamily, @@ -641,7 +702,7 @@ class _JourneyBlock extends StatelessWidget { '~$travel', style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -687,7 +748,7 @@ class _DistanceEta extends StatelessWidget { travel == '—' ? distance : '$distance · ~$travel', style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.2, color: fg, fontFamily: FontConstants.fontFamily, @@ -737,7 +798,7 @@ class _ActionLine extends StatelessWidget { TextStyle style(Color c) => TextStyle( fontSize: 14.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: c, fontFamily: FontConstants.fontFamily, ); @@ -1040,7 +1101,7 @@ class _QueueHeading extends StatelessWidget { label, style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, letterSpacing: 1.2, color: live ? ColorConstants.primary @@ -1054,7 +1115,7 @@ class _QueueHeading extends StatelessWidget { '$count', style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -1129,7 +1190,7 @@ class _LabelPair extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: size.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, letterSpacing: -0.2, color: muted ? ColorConstants.secondaryText @@ -1147,7 +1208,7 @@ class _LabelPair extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: size.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.2, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -1212,7 +1273,7 @@ class _TaskChip extends StatelessWidget { maxLines: 1, style: TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, letterSpacing: 1.0, color: look.color, fontFamily: FontConstants.fontFamily, @@ -1268,7 +1329,7 @@ class _NextCard extends StatelessWidget { // in position, which is what makes their labels a // convenience rather than the only thing ranking them. fontSize: 17.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, height: 1.15, letterSpacing: -0.4, color: ColorConstants.slateText, @@ -1292,7 +1353,7 @@ class _NextCard extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -1349,7 +1410,7 @@ class _LaterRow extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 16.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, letterSpacing: -0.3, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -1361,7 +1422,7 @@ class _LaterRow extends StatelessWidget { RouteMetricsHelper.formatDistance(distanceMeters), style: TextStyle( fontSize: 14.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -1417,7 +1478,7 @@ class _SkippedRow extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 18.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.4, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -1436,7 +1497,7 @@ class _SkippedRow extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, height: 1.3, color: ColorConstants.warning, fontFamily: FontConstants.fontFamily, @@ -1447,7 +1508,12 @@ class _SkippedRow extends StatelessWidget { Row( children: [ if (label.isNotEmpty) ...[ - _LabelPair(label: label, customer: '', muted: true, size: 13.5), + _LabelPair( + label: label, + customer: '', + muted: true, + size: 13.5, + ), SizedBox(width: 12.w), ], Expanded( diff --git a/lib/views/Dashboard/pickups/collect_payment.dart b/lib/views/Dashboard/pickups/collect_payment.dart index 5ad501d..27645d9 100644 --- a/lib/views/Dashboard/pickups/collect_payment.dart +++ b/lib/views/Dashboard/pickups/collect_payment.dart @@ -8,6 +8,7 @@ import 'package:slider_button_lite/feature/presentation/slider_button/slider_but import 'package:miler/views/Dashboard/pickups/stop_type.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/miler_surface.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; @@ -325,7 +326,7 @@ class _CollectPaymentPageState extends State { // 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, + backgroundColor: MilerSurface.canvas, // 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( @@ -377,7 +378,7 @@ class _CollectPaymentPageState extends State { 'DUE AT THIS STOP', style: TextStyle( fontSize: 10.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: 0.8, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -391,7 +392,7 @@ class _CollectPaymentPageState extends State { '₹${_fmt(_due)}', style: TextStyle( fontSize: 42.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -1.6, height: 1.05, color: ColorConstants.slateText, @@ -406,7 +407,7 @@ class _CollectPaymentPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -457,7 +458,7 @@ class _CollectPaymentPageState extends State { label, style: TextStyle( fontSize: 12.5.sp, - fontWeight: strong ? FontWeight.w700 : FontWeight.w500, + fontWeight: strong ? FontWeight.w800 : FontWeight.w600, color: strong ? ColorConstants.slateText : ColorConstants.secondaryText, @@ -469,7 +470,7 @@ class _CollectPaymentPageState extends State { value, style: TextStyle( fontSize: strong ? 14.sp : 12.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: strong ? ColorConstants.slateText : ColorConstants.secondaryText, @@ -529,7 +530,7 @@ class _CollectPaymentPageState extends State { style: TextStyle( fontSize: 12.sp, height: 1.35, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -555,7 +556,7 @@ class _CollectPaymentPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -570,7 +571,7 @@ class _CollectPaymentPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.2, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -628,7 +629,7 @@ class _CollectPaymentPageState extends State { 'Show this to the customer', style: TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: ColorConstants.upiPurple, fontFamily: FontConstants.fontFamily, ), @@ -666,7 +667,7 @@ class _CollectPaymentPageState extends State { textAlign: TextAlign.center, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), @@ -677,7 +678,7 @@ class _CollectPaymentPageState extends State { textAlign: TextAlign.center, style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -719,7 +720,7 @@ class _CollectPaymentPageState extends State { m.label, style: TextStyle( fontSize: 14.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), @@ -728,7 +729,7 @@ class _CollectPaymentPageState extends State { m.blurb, style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -821,7 +822,6 @@ class _CollectPaymentPageState extends State { final onBehalf = _shipper.isNotEmpty ? _shipper : 'the shipper'; 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, @@ -835,7 +835,7 @@ class _CollectPaymentPageState extends State { style: TextStyle( fontSize: 12.sp, height: 1.4, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.tertiary, fontFamily: FontConstants.fontFamily, ), @@ -869,7 +869,7 @@ class _CollectPaymentPageState extends State { textAlign: TextAlign.center, style: TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -884,7 +884,7 @@ class _CollectPaymentPageState extends State { 'Customer cannot pay', style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.errorRed, fontFamily: FontConstants.fontFamily, ), @@ -929,7 +929,7 @@ class _CollectPaymentPageState extends State { 'Finish the details above', style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -953,7 +953,7 @@ class _CollectPaymentPageState extends State { textAlign: TextAlign.center, style: TextStyle( fontSize: 14.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: Colors.white, fontFamily: FontConstants.fontFamily, ), @@ -982,7 +982,7 @@ class _CollectPaymentPageState extends State { 'Collect payment', style: TextStyle( fontSize: 17.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), @@ -1004,7 +1004,7 @@ class _CollectPaymentPageState extends State { 'Nothing to collect here', style: TextStyle( fontSize: 18.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), @@ -1016,7 +1016,7 @@ class _CollectPaymentPageState extends State { style: TextStyle( fontSize: 13.sp, height: 1.4, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -1051,7 +1051,7 @@ class _CollectPaymentPageState extends State { title, style: TextStyle( fontSize: 16.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.2, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -1086,7 +1086,7 @@ class _CollectPaymentPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: selected ? Colors.white : ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), @@ -1113,7 +1113,7 @@ class _CollectPaymentPageState extends State { onChanged: (_) => setState(() {}), style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), @@ -1121,7 +1121,7 @@ class _CollectPaymentPageState extends State { hintText: hint, hintStyle: TextStyle( fontSize: 13.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -1167,7 +1167,7 @@ class _CollectPaymentPageState extends State { style: TextStyle( fontSize: 12.sp, height: 1.35, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: accent, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/Dashboard/pickups/delivery_actions.dart b/lib/views/Dashboard/pickups/delivery_actions.dart index 21d80cf..b59aed1 100644 --- a/lib/views/Dashboard/pickups/delivery_actions.dart +++ b/lib/views/Dashboard/pickups/delivery_actions.dart @@ -269,6 +269,42 @@ bool get _handoffHoldIsPossible => ServiceProfile.active.endsAtHub; Future releaseForDelivery(Map stop) async { lastReleaseFailure = null; + // ── A hub-routed parcel has no customer round to start ── + // + // This is the gate that keeps a Gandhipuram → Chennai consignment out of the + // delivery lifecycle. `start-delivery` moves it to `Out_for_Delivery` against + // a receiver 500km away, the hub then sees a rider "actively delivering" a + // parcel that is going onto a line-haul truck, and there is no way back from + // that state on the handset. + // + // Asked of the order, never of the line — see [NextLegResolver]. The pivot's + // own instruction is read from the store because that is the only place the + // routing answer survives a rebuild; `GET /miler/bookings` does not carry it. + final leg = NextLegResolver.resolve( + stop, + pivotAction: (await getPivotNextActions())[MilkRun.idOf(stop)] ?? '', + ); + if (leg.isHub) { + debugPrint('[NEXTLEG] release refused for ${MilkRun.idOf(stop)} — $leg'); + lastReleaseFailure = + 'This parcel goes back to base, not to the customer. Hand it over at ' + 'base — there is no delivery to start here.'; + return false; + } + + // ── `unknown` is NOT refused here, deliberately ── + // + // It would be the cautious-looking thing to do and it is the wrong thing. + // A row that says nothing is exactly the case the consignment re-read below + // exists for: `Created` and a blank status both arrive on a poll that is + // behind the rider, and the server is the judge of its own consignment. + // Refusing here short-circuits that read and strands a rider on a stop the + // server would have released — the failure `_handoffHoldIsPossible` already + // documents, arriving by a new route. + // + // Only [NextLeg.hub] is refused, because that is the one answer the server + // has actually given. + final consignmentId = await resolveConsignmentId(stop); if (consignmentId.isEmpty) { debugPrint('[MILKRUN] no consignment id for ${MilkRun.idOf(stop)}'); @@ -292,8 +328,7 @@ Future releaseForDelivery(Map stop) async { // 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) { + if (state == ConsignmentState.unknown || state == ConsignmentState.created) { state = await ConsignmentGate.stateOf(consignmentId); } if (state.isDeliverable || state.isDelivered) { diff --git a/lib/views/Dashboard/pickups/delivery_proof_page.dart b/lib/views/Dashboard/pickups/delivery_proof_page.dart index fc8ebf8..b72d3d6 100644 --- a/lib/views/Dashboard/pickups/delivery_proof_page.dart +++ b/lib/views/Dashboard/pickups/delivery_proof_page.dart @@ -7,6 +7,7 @@ import 'package:image_picker/image_picker.dart'; import 'package:miler/data/proof_store.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/miler_surface.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; @@ -214,7 +215,7 @@ class _DeliveryProofPageState extends State { final hasShot = _shotPath != null; return Scaffold( - backgroundColor: ColorConstants.daylightSurface, + backgroundColor: MilerSurface.canvas, appBar: const MilerAppBar(title: 'Proof of delivery', showBack: true), body: SafeArea( top: false, @@ -286,7 +287,7 @@ class _DeliveryProofPageState extends State { 'Handing over to', style: TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -298,7 +299,7 @@ class _DeliveryProofPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 17.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.4, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -384,7 +385,7 @@ class _DeliveryProofPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -442,7 +443,7 @@ class _DeliveryProofPageState extends State { textAlign: TextAlign.center, style: TextStyle( fontSize: 17.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.4, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -457,7 +458,7 @@ class _DeliveryProofPageState extends State { style: TextStyle( fontSize: 13.5.sp, height: 1.45, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -528,7 +529,7 @@ class _DeliveryProofPageState extends State { "Couldn't hand it over?", style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, letterSpacing: -0.1, color: Narrative.inkMuted, fontFamily: FontConstants.fontFamily, @@ -542,7 +543,7 @@ class _DeliveryProofPageState extends State { textAlign: TextAlign.center, style: TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.warning, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/Dashboard/pickups/done.dart b/lib/views/Dashboard/pickups/done.dart index 430ece8..50093e3 100644 --- a/lib/views/Dashboard/pickups/done.dart +++ b/lib/views/Dashboard/pickups/done.dart @@ -390,7 +390,7 @@ class _PickupsDoneState extends State overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.2, color: tone, fontFamily: FontConstants.fontFamily, @@ -436,7 +436,7 @@ class _PickupsDoneState extends State overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 28.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.9, height: 1.08, color: ColorConstants.slateText, @@ -451,7 +451,7 @@ class _PickupsDoneState extends State style: TextStyle( fontSize: 14.5.sp, height: 1.45, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.onSurfaceVariant, fontFamily: FontConstants.fontFamily, ), @@ -478,7 +478,7 @@ class _PickupsDoneState extends State overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: kind.accent, fontFamily: FontConstants.fontFamily, ), @@ -514,7 +514,7 @@ class _PickupsDoneState extends State style: TextStyle( fontSize: 12.5.sp, height: 1.35, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.warning, fontFamily: FontConstants.fontFamily, ), @@ -588,7 +588,7 @@ class _PickupsDoneState extends State '$position', style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: kind.accent, fontFamily: FontConstants.fontFamily, ), @@ -605,7 +605,7 @@ class _PickupsDoneState extends State overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, letterSpacing: -0.2, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -619,7 +619,7 @@ class _PickupsDoneState extends State overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -661,7 +661,7 @@ class _PickupsDoneState extends State 'Finding your next stop…', style: TextStyle( fontSize: 13.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -702,7 +702,7 @@ class _PickupsDoneState extends State textAlign: TextAlign.center, style: TextStyle( fontSize: 24.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.6, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -716,7 +716,7 @@ class _PickupsDoneState extends State style: TextStyle( fontSize: 14.sp, height: 1.5, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -775,7 +775,7 @@ class _PickupsDoneState extends State // least readable combination in the app, on the screen a rider glances at // between two stops. fontSize: 12.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: 0.9, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -783,7 +783,7 @@ class _PickupsDoneState extends State TextStyle get _metaStyle => TextStyle( fontSize: 13.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ); @@ -828,7 +828,7 @@ class _PickupsDoneState extends State 'You Won a Bonus!', style: TextStyle( fontSize: 24.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -910,7 +910,7 @@ class _PickupsDoneState extends State '${widget.bonusPoints}', style: TextStyle( fontSize: 52.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, letterSpacing: -2, @@ -920,7 +920,7 @@ class _PickupsDoneState extends State 'Bonus Points', style: TextStyle( fontSize: 16.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.onSurfaceVariant, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/Dashboard/pickups/map.dart b/lib/views/Dashboard/pickups/map.dart index 3eae0bb..af58917 100644 --- a/lib/views/Dashboard/pickups/map.dart +++ b/lib/views/Dashboard/pickups/map.dart @@ -1018,7 +1018,7 @@ class _PickupMapScreenState extends State<_PickupMapScreen> 'Finding the route', style: MilerType.micro.copyWith( fontSize: 12.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, ), ), ], @@ -1491,7 +1491,7 @@ class _PickupMapScreenState extends State<_PickupMapScreen> "Couldn't deliver? Skip or cancel", style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, letterSpacing: -0.1, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -1519,9 +1519,7 @@ class _PickupMapScreenState extends State<_PickupMapScreen> // 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', + label: onTheWay ? "Arrived" : 'Start ride', icon: onTheWay ? LucideIcons.check : LucideIcons.navigation, // Green advances the job, maroon is brand and navigation. color: onTheWay ? ColorConstants.acceptGreen : ColorConstants.primary, @@ -2477,7 +2475,7 @@ class _LiveEtaHeader extends StatelessWidget { style: TextStyle( fontSize: known ? 34.sp : 20.sp, height: 1.0, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, letterSpacing: -1.4, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -2494,7 +2492,7 @@ class _LiveEtaHeader extends StatelessWidget { style: TextStyle( fontSize: 17.sp, height: 1.0, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.5, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, diff --git a/lib/views/Dashboard/pickups/multi_map.dart b/lib/views/Dashboard/pickups/multi_map.dart index 3f092c9..70bf56c 100644 --- a/lib/views/Dashboard/pickups/multi_map.dart +++ b/lib/views/Dashboard/pickups/multi_map.dart @@ -553,7 +553,7 @@ class _MultiCustomerMapScreenState extends State style: TextStyle( fontFamily: FontConstants.fontFamily, color: Colors.white, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, fontSize: 14.sp, ), ), @@ -576,7 +576,7 @@ class _MultiCustomerMapScreenState extends State style: TextStyle( fontFamily: FontConstants.fontFamily, color: Colors.white, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, fontSize: 12.sp, ), ), @@ -606,7 +606,7 @@ class _MultiCustomerMapScreenState extends State fontFamily: FontConstants.fontFamily, fontSize: 16.sp, color: ColorConstants.slateText, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, ), ), const SizedBox(height: 8), @@ -615,7 +615,7 @@ class _MultiCustomerMapScreenState extends State style: TextStyle( fontFamily: FontConstants.fontFamily, fontSize: 18.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, ), ), @@ -646,7 +646,7 @@ class _MultiCustomerMapScreenState extends State fontFamily: FontConstants.fontFamily, fontSize: 16.sp, color: ColorConstants.slateText, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, ), ), const SizedBox(height: 8), @@ -655,7 +655,7 @@ class _MultiCustomerMapScreenState extends State style: TextStyle( fontFamily: FontConstants.fontFamily, fontSize: 18.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, ), ), @@ -687,7 +687,7 @@ class _MultiCustomerMapScreenState extends State fontFamily: FontConstants.fontFamily, fontSize: 16.sp, color: ColorConstants.slateText, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, ), ), const SizedBox(height: 4), @@ -696,7 +696,7 @@ class _MultiCustomerMapScreenState extends State style: TextStyle( fontFamily: FontConstants.fontFamily, fontSize: 18.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, ), ), diff --git a/lib/views/Dashboard/pickups/payment_screen.dart b/lib/views/Dashboard/pickups/payment_screen.dart index 723d46e..43564e9 100644 --- a/lib/views/Dashboard/pickups/payment_screen.dart +++ b/lib/views/Dashboard/pickups/payment_screen.dart @@ -174,7 +174,7 @@ class _PaymentScreenState extends State 'Payment Collected Successfully', style: TextStyle( fontSize: 26.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -245,7 +245,7 @@ class _PaymentScreenState extends State 'Payment Receipt', style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -356,7 +356,7 @@ class _PaymentScreenState extends State value, style: TextStyle( fontSize: 14.sp, - fontWeight: valueBold ? FontWeight.w600 : FontWeight.w500, + fontWeight: valueBold ? FontWeight.w700 : FontWeight.w600, color: valueColor ?? ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -418,7 +418,7 @@ class _PaymentScreenState extends State 'Payment Collection', style: TextStyle( fontSize: 20.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: Colors.white, fontFamily: FontConstants.fontFamily, letterSpacing: 0.5, @@ -452,7 +452,7 @@ class _PaymentScreenState extends State '₹${_totalAmount.toStringAsFixed(0)}', style: TextStyle( fontSize: 36.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, color: Colors.white, fontFamily: FontConstants.fontFamily, letterSpacing: -1, @@ -483,7 +483,7 @@ class _PaymentScreenState extends State '#$_orderId', style: TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: Colors.white, fontFamily: FontConstants.fontFamily, ), @@ -642,7 +642,7 @@ class _PaymentScreenState extends State textAlign: TextAlign.center, style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: Colors.white, fontFamily: FontConstants.fontFamily, ), @@ -690,7 +690,7 @@ class _PaymentScreenState extends State label, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.onSurfaceVariant, fontFamily: FontConstants.fontFamily, letterSpacing: 0.5, @@ -759,7 +759,7 @@ class _PaymentScreenState extends State title, style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -814,7 +814,7 @@ class _PaymentScreenState extends State style: TextStyle( fontSize: isTotal ? 15.sp : 13.sp, color: ColorConstants.onSurfaceVariant, - fontWeight: isBold ? FontWeight.w500 : FontWeight.normal, + fontWeight: isBold ? FontWeight.w600 : FontWeight.normal, fontFamily: FontConstants.fontFamily, ), ), @@ -823,8 +823,8 @@ class _PaymentScreenState extends State style: TextStyle( fontSize: isTotal ? 17.sp : 14.sp, fontWeight: isTotal - ? FontWeight.w700 - : (isBold ? FontWeight.w600 : FontWeight.w500), + ? FontWeight.w800 + : (isBold ? FontWeight.w700 : FontWeight.w600), color: isTotal ? ColorConstants.primary : ColorConstants.onSurface, diff --git a/lib/views/Dashboard/pickups/pickup_verify.dart b/lib/views/Dashboard/pickups/pickup_verify.dart index 0a4233a..22752d8 100644 --- a/lib/views/Dashboard/pickups/pickup_verify.dart +++ b/lib/views/Dashboard/pickups/pickup_verify.dart @@ -272,7 +272,7 @@ class _PickupVerificationPageState extends State style: TextStyle( fontSize: 21.sp, color: ColorConstants.onSurface, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, fontFamily: FontConstants.fontFamily, ), ), @@ -348,7 +348,7 @@ class _PickupVerificationPageState extends State label, style: TextStyle( fontSize: 10.sp, - fontWeight: done ? FontWeight.w600 : FontWeight.w500, + fontWeight: done ? FontWeight.w700 : FontWeight.w600, color: done ? _successGreen : ColorConstants.onSurfaceVariant, @@ -412,7 +412,7 @@ class _PickupVerificationPageState extends State style: TextStyle( color: Colors.white, fontSize: 16.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, fontFamily: FontConstants.fontFamily, ), ), @@ -427,7 +427,7 @@ class _PickupVerificationPageState extends State _customerName, style: TextStyle( fontSize: 16.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: Colors.white, fontFamily: FontConstants.fontFamily, ), @@ -489,7 +489,7 @@ class _PickupVerificationPageState extends State label, style: TextStyle( fontSize: 9.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: accentColor, letterSpacing: 0.8, fontFamily: FontConstants.fontFamily, @@ -541,7 +541,7 @@ class _PickupVerificationPageState extends State title, style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -558,7 +558,7 @@ class _PickupVerificationPageState extends State badge, style: TextStyle( fontSize: 10.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: _primaryRed, fontFamily: FontConstants.fontFamily, ), @@ -679,7 +679,7 @@ class _PickupVerificationPageState extends State 'Tap to take parcel photo', style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.onSurfaceVariant, fontFamily: FontConstants.fontFamily, ), @@ -738,7 +738,7 @@ class _PickupVerificationPageState extends State label, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -750,7 +750,7 @@ class _PickupVerificationPageState extends State style: TextStyle( fontSize: 13.sp, color: _primaryRed, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, ), ), @@ -806,7 +806,7 @@ class _PickupVerificationPageState extends State : 'Complete all fields to continue', style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -844,7 +844,7 @@ class _PickupVerificationPageState extends State textAlign: TextAlign.center, style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: Colors.white, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/Dashboard/pickups/pickups.dart b/lib/views/Dashboard/pickups/pickups.dart index 5f46089..ddb8190 100644 --- a/lib/views/Dashboard/pickups/pickups.dart +++ b/lib/views/Dashboard/pickups/pickups.dart @@ -15,7 +15,7 @@ library; import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:flutter/foundation.dart' show kDebugMode, setEquals; +import 'package:flutter/foundation.dart' show kDebugMode, mapEquals, setEquals; import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; @@ -73,6 +73,7 @@ import 'package:miler/views/Dashboard/pickups/stop_verify.dart'; import 'package:miler/views/Dashboard/pickups/shipment_capture.dart'; import 'package:miler/views/Dashboard/pickups/shipment_review.dart'; import 'package:miler/data/milk_run.dart'; +import 'package:miler/data/next_leg.dart'; import 'package:miler/data/order_events.dart'; import 'package:miler/data/route_order.dart'; import 'package:miler/data/stop_area.dart'; @@ -527,6 +528,11 @@ class _MyPickupsState extends State Set _acceptedIds = {}; Set _collectedIds = {}; + + /// `next_action` per order, as the pivot returned it. Continuity for + /// [NextLegResolver] across a rebuild and a poll — see that resolver for why + /// it is the lowest rung of the precedence and never an override. + Map _pivotActions = {}; Set _outForDeliveryIds = {}; Set _notLoadedIds = {}; Set _rejectedIds = {}; @@ -600,8 +606,6 @@ class _MyPickupsState extends State return [...done, ...open]; } - - /// Progress state for every stop on the accepted trip, in the admin's fixed /// order — what drives the red→green rail at the top of Bookings. /// @@ -921,7 +925,7 @@ class _MyPickupsState extends State textAlign: TextAlign.center, style: TextStyle( fontSize: 16.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.3, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -934,7 +938,7 @@ class _MyPickupsState extends State style: TextStyle( fontSize: 12.5.sp, height: 1.45, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -958,7 +962,7 @@ class _MyPickupsState extends State style: TextStyle( fontSize: 10.sp, height: 1.5, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -2038,6 +2042,10 @@ class _MyPickupsState extends State // no tab at all: dropped from Home as collected, withheld from here as // unreleased. final Set collectedIds = await getCollectedOrderIds(); + final pivotActions = await getPivotNextActions(); + if (mounted && !mapEquals(_pivotActions, pivotActions)) { + setState(() => _pivotActions = pivotActions); + } // ── Held on the state whatever the line ── // @@ -2092,6 +2100,7 @@ class _MyPickupsState extends State collectedIds: collectedIds, acceptedIds: acceptedIds, closedIds: closedIds, + pivotActions: pivotActions, ); // Drop stops the rider just finished. The backend may still return them @@ -3285,6 +3294,7 @@ class _MyPickupsState extends State collectedIds: _collectedIds, outForDeliveryIds: _outForDeliveryIds, + pivotActions: _pivotActions, ), QueueDensity.next => _NextCard( item: item, diff --git a/lib/views/Dashboard/pickups/pip.dart b/lib/views/Dashboard/pickups/pip.dart index c373a45..cda20fc 100644 --- a/lib/views/Dashboard/pickups/pip.dart +++ b/lib/views/Dashboard/pickups/pip.dart @@ -112,7 +112,7 @@ class _PipInfoCardState extends State { 'Out', style: TextStyle( fontSize: 14, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, ), @@ -133,7 +133,7 @@ class _PipInfoCardState extends State { strokeCap: StrokeCap.round, textStyle: TextStyle( fontSize: 17, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, ), @@ -157,7 +157,7 @@ class _PipInfoCardState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/Dashboard/pickups/sheet.dart b/lib/views/Dashboard/pickups/sheet.dart index 0d6b564..aae32b3 100644 --- a/lib/views/Dashboard/pickups/sheet.dart +++ b/lib/views/Dashboard/pickups/sheet.dart @@ -138,7 +138,7 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.2, color: color, fontFamily: FontConstants.fontFamily, @@ -253,7 +253,7 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> modeLabel, style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: confirmColor, fontFamily: FontConstants.fontFamily, ), @@ -281,7 +281,7 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> : confirmLabel, style: TextStyle( fontSize: 19.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, fontFamily: FontConstants.fontFamily, color: ColorConstants.onSurface, ), @@ -383,7 +383,7 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> isDelivery ? 'Delivered' : 'Picked up', style: TextStyle( fontSize: 16.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.3, color: Colors.white, fontFamily: FontConstants.fontFamily, @@ -557,7 +557,7 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet> overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 16.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: Colors.white, fontFamily: FontConstants.fontFamily, ), @@ -1306,7 +1306,7 @@ class _StopBrief extends StatelessWidget { style: TextStyle( color: ColorConstants.secondaryText, fontSize: 14.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, fontFamily: FontConstants.fontFamily, ), ), @@ -1322,7 +1322,7 @@ class _StopBrief extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 15.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.3, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -1345,7 +1345,7 @@ class _StopBrief extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -1387,7 +1387,7 @@ class _StopBrief extends StatelessWidget { kind.locationLabel, style: TextStyle( fontSize: 9.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: 0.6, color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, @@ -1401,7 +1401,7 @@ class _StopBrief extends StatelessWidget { style: TextStyle( fontSize: 13.sp, height: 1.35, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.onSurfaceVariant, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/Dashboard/pickups/shipment_capture.dart b/lib/views/Dashboard/pickups/shipment_capture.dart index fcffff7..4568a72 100644 --- a/lib/views/Dashboard/pickups/shipment_capture.dart +++ b/lib/views/Dashboard/pickups/shipment_capture.dart @@ -8,6 +8,7 @@ import 'package:geolocator/geolocator.dart'; import 'package:miler/data/miler_api.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/miler_surface.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; @@ -289,7 +290,7 @@ class _ShipmentCapturePageState extends State { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: ColorConstants.daylightSurface, + backgroundColor: MilerSurface.canvas, appBar: milerPageBar('Shipment details'), body: SafeArea( child: Column( @@ -331,7 +332,7 @@ class _ShipmentCapturePageState extends State { textAlign: TextAlign.center, style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -488,7 +489,7 @@ class _ShipmentCapturePageState extends State { label, style: TextStyle( fontSize: 10.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, letterSpacing: 1.2, color: tint, fontFamily: FontConstants.fontFamily, @@ -533,7 +534,7 @@ class _ShipmentCapturePageState extends State { 'THE PARCEL', style: TextStyle( fontSize: 10.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, letterSpacing: 1.2, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -597,7 +598,6 @@ class _ShipmentCapturePageState extends State { color: priced ? ColorConstants.tint(ColorConstants.acceptGreen, 0.06) : ColorConstants.pureSurface, - rail: priced ? ColorConstants.acceptGreen : null, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -613,7 +613,7 @@ class _ShipmentCapturePageState extends State { 'WHAT THE CUSTOMER PAYS', style: TextStyle( fontSize: 10.5.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, letterSpacing: 1.2, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -631,7 +631,7 @@ class _ShipmentCapturePageState extends State { quote.currency, style: TextStyle( fontSize: 17.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: ColorConstants.acceptGreen, fontFamily: FontConstants.fontFamily, ), @@ -645,7 +645,7 @@ class _ShipmentCapturePageState extends State { style: TextStyle( fontSize: 34.sp, height: 1.05, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, letterSpacing: -1.2, color: ColorConstants.acceptGreen, fontFamily: FontConstants.fontFamily, @@ -661,7 +661,7 @@ class _ShipmentCapturePageState extends State { '${quote.maxPrice.toStringAsFixed(0)} · charging the lower', style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -697,7 +697,7 @@ class _ShipmentCapturePageState extends State { 'Getting the price…', style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -715,7 +715,7 @@ class _ShipmentCapturePageState extends State { 'Enter both pincodes and the weight to get a price.', style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -742,7 +742,7 @@ class _ShipmentCapturePageState extends State { label, style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -752,7 +752,7 @@ class _ShipmentCapturePageState extends State { value, style: TextStyle( fontSize: emphasis ? 17.sp : 13.sp, - fontWeight: emphasis ? FontWeight.w800 : FontWeight.w600, + fontWeight: emphasis ? FontWeight.w900 : FontWeight.w700, color: emphasis ? ColorConstants.primary : ColorConstants.slateText, @@ -768,7 +768,7 @@ class _ShipmentCapturePageState extends State { note, style: TextStyle( fontSize: 10.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -798,7 +798,7 @@ class _ShipmentCapturePageState extends State { onChanged: (_) => setState(() {}), style: TextStyle( fontSize: 13.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), @@ -807,10 +807,20 @@ class _ShipmentCapturePageState extends State { hintText: hint, prefixIcon: Icon(icon, size: 18.sp), filled: true, - fillColor: ColorConstants.pureSurface, + // ── A field on a white card needs a ground of its own ── + // + // This was `pureSurface` — white type on white inside a white card, + // separated from it by a hairline alone. It read as a line drawn on the + // card rather than as somewhere to type, and at arm's length in sun the + // hairline is the first thing to go. + // + // The canvas is the app's own "one step back from a surface" tone, so a + // field wearing it sits *in* the card the way a well does, and the + // hairline becomes the edge of a shape rather than the whole of it. + fillColor: MilerSurface.canvas, hintStyle: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -844,7 +854,7 @@ class _ShipmentCapturePageState extends State { label, style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -879,7 +889,7 @@ class _ShipmentCapturePageState extends State { v, style: TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: v == selected ? ColorConstants.onAccent : ColorConstants.slateText, diff --git a/lib/views/Dashboard/pickups/shipment_review.dart b/lib/views/Dashboard/pickups/shipment_review.dart index 579e0cf..a06afcf 100644 --- a/lib/views/Dashboard/pickups/shipment_review.dart +++ b/lib/views/Dashboard/pickups/shipment_review.dart @@ -3,6 +3,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/miler_surface.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; @@ -192,7 +193,14 @@ class _ShipmentReviewPageState extends State // Once the order exists, going back would offer to create it twice. canPop: !_created && !_busy, child: Scaffold( - backgroundColor: ColorConstants.daylightSurface, + // ── The canvas, not the old slate ── + // + // This was `daylightSurface` — #DEE3EA, the heavy grey the whole app + // used to sit on. The ground moved to a near-white canvas when cards + // took on a hairline and a lift (see [Narrative]); these screens kept + // the old value, so the flow a rider spends the most time in was the + // one place still showing dark bars between its sections. + backgroundColor: MilerSurface.canvas, appBar: _created ? null : milerPageBar('Review & pay'), body: SafeArea(child: _created ? _successView() : _reviewView()), ), @@ -306,7 +314,7 @@ class _ShipmentReviewPageState extends State label, style: TextStyle( fontSize: 10.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: 0.6, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -317,7 +325,7 @@ class _ShipmentReviewPageState extends State address.isEmpty ? 'Not captured' : address, style: TextStyle( fontSize: 13.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, height: 1.3, color: address.isEmpty ? ColorConstants.secondaryText @@ -332,7 +340,7 @@ class _ShipmentReviewPageState extends State tail, style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -403,7 +411,7 @@ class _ShipmentReviewPageState extends State 'Amount to collect', style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), @@ -413,7 +421,7 @@ class _ShipmentReviewPageState extends State _amountLabel, style: TextStyle( fontSize: 20.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, letterSpacing: -0.4, color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, @@ -434,9 +442,15 @@ class _ShipmentReviewPageState extends State duration: DesignConstants.motionState, padding: EdgeInsets.symmetric(vertical: 12.h), decoration: BoxDecoration( + // The unselected tile was `daylightSurface` — the old slate — so on a + // white card the option he has *not* chosen was the heaviest shape in + // the group, and the two read as one filled and one disabled rather + // than as a choice between two. Unselected is the card's own white + // with a hairline, which is how every other pair of options in this + // flow is drawn. color: selected ? ColorConstants.tint(ColorConstants.primary, 0.08) - : ColorConstants.daylightSurface, + : ColorConstants.pureSurface, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), border: Border.all( color: selected @@ -459,7 +473,7 @@ class _ShipmentReviewPageState extends State m.label, style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: selected ? ColorConstants.primary : ColorConstants.slateText, @@ -509,7 +523,7 @@ class _ShipmentReviewPageState extends State label, style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -519,7 +533,7 @@ class _ShipmentReviewPageState extends State value.isEmpty ? '—' : value, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), @@ -533,7 +547,7 @@ class _ShipmentReviewPageState extends State note, style: TextStyle( fontSize: 10.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -624,7 +638,7 @@ class _ShipmentReviewPageState extends State textAlign: TextAlign.center, style: TextStyle( fontSize: 19.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, letterSpacing: -0.4, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -638,7 +652,7 @@ class _ShipmentReviewPageState extends State textAlign: TextAlign.center, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -654,7 +668,9 @@ class _ShipmentReviewPageState extends State vertical: 10.h, ), decoration: BoxDecoration( - color: ColorConstants.daylightSurface, + // A well inside a white card, so it takes the canvas + // rather than the old slate the page used to sit on. + color: MilerSurface.canvas, borderRadius: BorderRadius.circular( DesignConstants.radiusFull, ), @@ -673,7 +689,7 @@ class _ShipmentReviewPageState extends State tracking, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: 0.3, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, diff --git a/lib/views/Dashboard/pickups/skip_sheet.dart b/lib/views/Dashboard/pickups/skip_sheet.dart index 6023d3f..310dcc3 100644 --- a/lib/views/Dashboard/pickups/skip_sheet.dart +++ b/lib/views/Dashboard/pickups/skip_sheet.dart @@ -110,7 +110,7 @@ Future _showMyOptionsSheet( textAlign: TextAlign.center, style: TextStyle( fontSize: 20.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, color: ColorConstants.onSurface, ), @@ -150,7 +150,7 @@ Future _showMyOptionsSheet( "Proceeding will forfeit your bonus points for this session.", style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.rewardAmber, fontFamily: FontConstants.fontFamily, height: 1.3, diff --git a/lib/views/Dashboard/pickups/stop_type.dart b/lib/views/Dashboard/pickups/stop_type.dart index 70b4c3d..2decf83 100644 --- a/lib/views/Dashboard/pickups/stop_type.dart +++ b/lib/views/Dashboard/pickups/stop_type.dart @@ -270,6 +270,30 @@ extension StopKindUi on StopKind { StopKind.combined => 'PICKUP & DELIVERY', }; + /// One or two characters, for a rail row that has no room for a word. + /// + /// ── Why not [badgeLabel] at a smaller size ── + /// + /// The route's rail is a column of orders, and the thing the rider is asking + /// as he runs down it is "which of these am I collecting and which am I + /// handing over". `PICKUP & DELIVERY` set small enough to fit beside the + /// chevron is a word he has to *read*; `P&D` is a mark he recognises. At one + /// per row, down a list, the difference is the whole point of putting it + /// there. + /// + /// A line with one kind of work all day takes the first letter of its own + /// name, so a milk round reads `D` rather than borrowing the parcel line's + /// vocabulary — see [ServiceProfile.workTypeLabel]. + String get shortLabel { + final single = ServiceProfile.active.workTypeLabel; + if (single != null && single.isNotEmpty) return single[0].toUpperCase(); + return switch (this) { + StopKind.pickup => 'P', + StopKind.delivery => 'D', + StopKind.combined => 'P&D', + }; + } + /// Sentence-case name of the mode, for places that state it as a *label* /// rather than stamp it as a chip — see the handoff screen's next-stop block. /// diff --git a/lib/views/Dashboard/pickups/stop_verify.dart b/lib/views/Dashboard/pickups/stop_verify.dart index 96af406..389b57f 100644 --- a/lib/views/Dashboard/pickups/stop_verify.dart +++ b/lib/views/Dashboard/pickups/stop_verify.dart @@ -9,6 +9,7 @@ import 'package:image_picker/image_picker.dart'; import 'package:miler/data/service_profile.dart'; import 'package:miler/views/Dashboard/pickups/stop_type.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/miler_surface.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; import 'package:miler/views/helpers/constants/miler_type.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; @@ -445,7 +446,14 @@ class _StopVerificationPageState extends State { // which on this screen is the difference between an address on two lines // and an address on three. child: Scaffold( - backgroundColor: ColorConstants.daylightSurface, + // ── The canvas, not the old slate ── + // + // This was `daylightSurface` — #DEE3EA, the heavy grey the whole app + // used to sit on. The ground moved to a near-white canvas when cards + // took on a hairline and a lift (see [Narrative]); these screens kept + // the old value, so the flow a rider spends the most time in was the + // one place still showing dark bars between its sections. + backgroundColor: MilerSurface.canvas, // ── The bar breathes, and its title is a title ── // // 17sp jammed against the status bar is a system default, not a @@ -600,7 +608,7 @@ class _StopVerificationPageState extends State { '${idx + 1}', style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: current ? Colors.white : ColorConstants.secondaryText, @@ -620,7 +628,7 @@ class _StopVerificationPageState extends State { style: TextStyle( fontSize: 10.5.sp, height: 1.2, - fontWeight: current ? FontWeight.w700 : FontWeight.w500, + fontWeight: current ? FontWeight.w800 : FontWeight.w600, color: past ? ColorConstants.acceptGreen : current @@ -687,7 +695,7 @@ class _StopVerificationPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: _kind.accent, fontFamily: FontConstants.fontFamily, ), @@ -703,7 +711,7 @@ class _StopVerificationPageState extends State { textAlign: TextAlign.right, style: TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: ColorConstants.acceptGreen, fontFamily: FontConstants.fontFamily, ), @@ -718,7 +726,7 @@ class _StopVerificationPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 19.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.5, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -811,7 +819,7 @@ class _StopVerificationPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: accent, fontFamily: FontConstants.fontFamily, ), @@ -859,17 +867,14 @@ class _StopVerificationPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ _choiceGrid( - count: _Recipient.values.length, - chips: (width, lastWidth) => [ - for (final (i, r) in _Recipient.values.indexed) + chips: (width) => [ + for (final r in _Recipient.values) _choiceChip( label: r.label, icon: r.icon, selected: _recipient == r, accent: ColorConstants.deliveryAccent, - width: i == _Recipient.values.length - 1 - ? lastWidth - : width, + width: width, onTap: () => setState(() => _recipient = r), ), ], @@ -955,7 +960,7 @@ class _StopVerificationPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), @@ -974,7 +979,7 @@ class _StopVerificationPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: ColorConstants.acceptGreen, fontFamily: FontConstants.fontFamily, ), @@ -1020,15 +1025,14 @@ class _StopVerificationPageState extends State { done: !_condition.needsNote, icon: LucideIcons.badgeCheck, child: _choiceGrid( - count: _Condition.values.length, - chips: (width, lastWidth) => [ - for (final (i, c) in _Condition.values.indexed) + chips: (width) => [ + for (final c in _Condition.values) _choiceChip( label: c.label, icon: c.icon, selected: _condition == c, accent: c.accent, - width: i == _Condition.values.length - 1 ? lastWidth : width, + width: width, onTap: () => setState(() => _condition = c), ), ], @@ -1149,7 +1153,7 @@ class _StopVerificationPageState extends State { '$_collected', style: TextStyle( fontSize: 30.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -1, height: 1.1, color: ColorConstants.slateText, @@ -1160,7 +1164,7 @@ class _StopVerificationPageState extends State { 'of $_expectedPickup booked', style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -1223,7 +1227,7 @@ class _StopVerificationPageState extends State { : 'Something is missing', style: TextStyle( fontSize: 19.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.5, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -1241,7 +1245,7 @@ class _StopVerificationPageState extends State { style: TextStyle( fontSize: 13.5.sp, height: 1.4, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -1375,7 +1379,7 @@ class _StopVerificationPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: 0.8, color: accent, fontFamily: FontConstants.fontFamily, @@ -1419,7 +1423,7 @@ class _StopVerificationPageState extends State { done ? 'Complete' : 'Unfinished', style: TextStyle( fontSize: 10.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: 0.2, color: color, fontFamily: FontConstants.fontFamily, @@ -1469,7 +1473,7 @@ class _StopVerificationPageState extends State { 'No proof photo — go back and take one', style: TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.warning, fontFamily: FontConstants.fontFamily, ), @@ -1524,7 +1528,7 @@ class _StopVerificationPageState extends State { 'Tap to enlarge', style: TextStyle( fontSize: 10.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: Colors.white, fontFamily: FontConstants.fontFamily, ), @@ -1649,7 +1653,7 @@ class _StopVerificationPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -1665,7 +1669,7 @@ class _StopVerificationPageState extends State { style: TextStyle( fontSize: 15.sp, height: 1.25, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: fact.mono ? 1.2 : -0.3, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -1715,7 +1719,7 @@ class _StopVerificationPageState extends State { textAlign: TextAlign.center, style: TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -1810,7 +1814,7 @@ class _StopVerificationPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -1943,7 +1947,6 @@ class _StopVerificationPageState extends State { // 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, @@ -1981,7 +1984,7 @@ class _StopVerificationPageState extends State { title, style: MilerType.body.copyWith( fontSize: 15.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.25, ), ), @@ -2022,7 +2025,7 @@ class _StopVerificationPageState extends State { 'Optional', style: TextStyle( fontSize: 11.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -2052,7 +2055,7 @@ class _StopVerificationPageState extends State { 'Required', style: TextStyle( fontSize: 9.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: 0.3, color: ColorConstants.warning, fontFamily: FontConstants.fontFamily, @@ -2078,20 +2081,22 @@ class _StopVerificationPageState extends State { /// two-up grid leaves the third at half width on a line of its own, which /// looks like the grid ran out rather than like a row. The last one takes the /// full width instead, and the block reads as two deliberate rows. - Widget _choiceGrid({ - required int count, - required List Function(double width, double lastWidth) chips, - }) { + Widget _choiceGrid({required List Function(double width) chips}) { return LayoutBuilder( builder: (context, constraints) { const gap = 9.0; final full = constraints.maxWidth; final half = (full - gap) / 2; - return Wrap( - spacing: gap, - runSpacing: gap, - children: chips(half, count.isOdd ? full : half), - ); + // ── An odd last option keeps its column ── + // + // It used to stretch to the full width, and with three options that put + // the biggest target on the screen under `Damaged` — the rarest choice + // and the one with the worst consequence, drawn twice the size of + // `Sealed & intact`. Size is emphasis, and this set has no reason to + // emphasise its exception. + // + // A grid with an empty cell in the last row is what a grid looks like. + return Wrap(spacing: gap, runSpacing: gap, children: chips(half)); }, ); } @@ -2121,7 +2126,22 @@ class _StopVerificationPageState extends State { constraints: BoxConstraints(minHeight: 52.h), alignment: Alignment.centerLeft, decoration: BoxDecoration( - color: selected ? accent : ColorConstants.pureSurface, + // ── Selection is a tint and an edge, not a slab ── + // + // The chosen option was painted in the accent at full strength with + // white type on it. At 52pt tall and half the page wide that is not + // a selected option, it is a filled button — the heaviest object on + // a screen whose actual button is at the foot of it, and on a + // finished section it put a block of solid green inside a card that + // was already green. + // + // The accent at a tenth, under an edge of the accent itself, with + // the label in it. It is the treatment the payment tiles and the + // service chips in this same flow already use, so a rider learns + // "chosen looks like this" once. + color: selected + ? ColorConstants.tint(accent, 0.10) + : ColorConstants.pureSurface, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), // An edge at rest. On the old grey panel a white chip separated // itself by fill alone; on a white page it has nothing to separate @@ -2129,7 +2149,7 @@ class _StopVerificationPageState extends State { // option the rider does not know he can press. border: Border.all( color: selected ? accent : ColorConstants.borderStrong, - width: 1.4, + width: selected ? 1.6 : 1.4, ), ), child: Row( @@ -2145,7 +2165,7 @@ class _StopVerificationPageState extends State { Icon( icon, size: 16.sp, - color: selected ? Colors.white : ColorConstants.secondaryText, + color: selected ? accent : ColorConstants.secondaryText, ), SizedBox(width: 7.w), // Flexible, not a bare Text. A [Wrap] hands every chip the full page @@ -2161,8 +2181,8 @@ class _StopVerificationPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w600, - color: selected ? Colors.white : ColorConstants.slateText, + fontWeight: selected ? FontWeight.w800 : FontWeight.w700, + color: selected ? accent : ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ); @@ -2195,7 +2215,7 @@ class _StopVerificationPageState extends State { onChanged: (_) => setState(() {}), style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), @@ -2203,7 +2223,7 @@ class _StopVerificationPageState extends State { hintText: hint, hintStyle: TextStyle( fontSize: 13.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -2213,7 +2233,17 @@ class _StopVerificationPageState extends State { color: ColorConstants.secondaryText, ), filled: true, - fillColor: ColorConstants.pureSurface, + // ── A field on a white card needs a ground of its own ── + // + // This was `pureSurface` — white type on white inside a white card, + // separated from it by a hairline alone. It read as a line drawn on the + // card rather than as somewhere to type, and at arm's length in sun the + // hairline is the first thing to go. + // + // The canvas is the app's own "one step back from a surface" tone, so a + // field wearing it sits *in* the card the way a well does, and the + // hairline becomes the edge of a shape rather than the whole of it. + fillColor: MilerSurface.canvas, contentPadding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 13.h), // Outlined at rest, with the accent spent on the field the cursor is // in. This was borderless, which worked while the fill sat on a grey @@ -2313,7 +2343,7 @@ class _StopVerificationPageState extends State { 'Take photo', style: TextStyle( fontSize: 13.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: accent, fontFamily: FontConstants.fontFamily, ), @@ -2330,10 +2360,12 @@ class _StopVerificationPageState extends State { margin: EdgeInsets.symmetric(horizontal: _bandPad), padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 11.h), decoration: BoxDecoration( + // Tinted *onto the card it sits on*. Mixed against the old page slate + // it came out darker than its own surface, which reads as a hole. color: ColorConstants.tint( accent, 0.10, - on: ColorConstants.daylightSurface, + on: ColorConstants.pureSurface, ), borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), @@ -2347,7 +2379,7 @@ class _StopVerificationPageState extends State { style: TextStyle( fontSize: 12.sp, height: 1.35, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: accent, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/Dashboard/profile/Profilepage.dart b/lib/views/Dashboard/profile/Profilepage.dart index bea2099..001f126 100644 --- a/lib/views/Dashboard/profile/Profilepage.dart +++ b/lib/views/Dashboard/profile/Profilepage.dart @@ -533,7 +533,7 @@ class _ProfilePageState extends State { Text( '·', style: MilerType.label.copyWith( - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: ColorConstants.borderStrong, ), ), @@ -745,7 +745,7 @@ class _ProfilePageState extends State { 'Retry', style: MilerType.micro .on(ColorConstants.primary) - .copyWith(fontWeight: FontWeight.w700), + .copyWith(fontWeight: FontWeight.w800), ), ), ), diff --git a/lib/views/Dashboard/profile/informations/faq.dart b/lib/views/Dashboard/profile/informations/faq.dart index 9dec6f1..be454a5 100644 --- a/lib/views/Dashboard/profile/informations/faq.dart +++ b/lib/views/Dashboard/profile/informations/faq.dart @@ -175,7 +175,7 @@ class _FaqPageState extends State { onChanged: (v) => setState(() => _query = v), style: TextStyle( fontSize: 14.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -234,7 +234,7 @@ class _FaqPageState extends State { textAlign: TextAlign.center, style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -267,7 +267,7 @@ class _FaqPageState extends State { 'Still stuck?', style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -331,7 +331,7 @@ class _FaqTileState extends State<_FaqTile> { widget.faq.question, style: TextStyle( fontSize: 14.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, height: 1.35, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, diff --git a/lib/views/Dashboard/profile/informations/help_center.dart b/lib/views/Dashboard/profile/informations/help_center.dart index ea6cf28..c50b816 100644 --- a/lib/views/Dashboard/profile/informations/help_center.dart +++ b/lib/views/Dashboard/profile/informations/help_center.dart @@ -90,7 +90,7 @@ class HelpCenter extends StatelessWidget { 'How can we help?', style: TextStyle( fontSize: 20.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.4, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, @@ -220,7 +220,7 @@ class _HelpCenterMessageState extends State { 'What has happened?', style: TextStyle( fontSize: 16.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -282,7 +282,7 @@ class _HelpCenterMessageState extends State { text, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -302,7 +302,7 @@ class _HelpCenterMessageState extends State { validator: validator, style: TextStyle( fontSize: 14.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/Dashboard/profile/informations/notifications_page.dart b/lib/views/Dashboard/profile/informations/notifications_page.dart index 5782fb6..38af646 100644 --- a/lib/views/Dashboard/profile/informations/notifications_page.dart +++ b/lib/views/Dashboard/profile/informations/notifications_page.dart @@ -118,7 +118,7 @@ class _NotificationsPageState extends State { 'Clear notifications?', style: TextStyle( fontSize: 17.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -140,7 +140,7 @@ class _NotificationsPageState extends State { 'Cancel', style: TextStyle( color: ColorConstants.onSurfaceVariant, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, ), ), @@ -151,7 +151,7 @@ class _NotificationsPageState extends State { 'Clear', style: TextStyle( color: ColorConstants.primary, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, ), ), @@ -181,7 +181,7 @@ class _NotificationsPageState extends State { 'Clear', style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, ), @@ -329,7 +329,7 @@ class _NotificationRow extends StatelessWidget { title, style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, height: 1.3, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, @@ -341,7 +341,7 @@ class _NotificationRow extends StatelessWidget { time, style: TextStyle( fontSize: 11.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.onSurfaceVariant, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/Dashboard/profile/informations/order_alert_sound.dart b/lib/views/Dashboard/profile/informations/order_alert_sound.dart index cf98c00..969c493 100644 --- a/lib/views/Dashboard/profile/informations/order_alert_sound.dart +++ b/lib/views/Dashboard/profile/informations/order_alert_sound.dart @@ -167,7 +167,7 @@ class _OrderAlertSoundPageState extends State { _currentLabel, style: TextStyle( fontSize: 16.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/Dashboard/profile/informations/profile.dart b/lib/views/Dashboard/profile/informations/profile.dart index ff469e3..bd271cd 100644 --- a/lib/views/Dashboard/profile/informations/profile.dart +++ b/lib/views/Dashboard/profile/informations/profile.dart @@ -155,7 +155,7 @@ class _ProfileState extends State { 'Change Photo', style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, ), @@ -333,7 +333,7 @@ class _ProfileState extends State { label, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.onSurface, letterSpacing: 0.2, fontFamily: FontConstants.fontFamily, @@ -347,7 +347,7 @@ class _ProfileState extends State { keyboardType: keyboardType, style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: readOnly ? ColorConstants.onSurfaceVariant : ColorConstants.onSurface, @@ -358,7 +358,7 @@ class _ProfileState extends State { prefixText: prefix, prefixStyle: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/Dashboard/profile/informations/rider_rewards_page.dart b/lib/views/Dashboard/profile/informations/rider_rewards_page.dart index 1ffb3ef..b775cb2 100644 --- a/lib/views/Dashboard/profile/informations/rider_rewards_page.dart +++ b/lib/views/Dashboard/profile/informations/rider_rewards_page.dart @@ -177,7 +177,7 @@ class _RiderRewardsPageState extends State { '${s.tier.label} status', style: TextStyle( fontSize: 18.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.3, color: Colors.white, fontFamily: FontConstants.fontFamily, @@ -189,7 +189,7 @@ class _RiderRewardsPageState extends State { '$daysLeft ${daysLeft == 1 ? 'day' : 'days'} left', style: TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: Colors.white.withValues(alpha: 0.78), fontFamily: FontConstants.fontFamily, ), @@ -205,7 +205,7 @@ class _RiderRewardsPageState extends State { loading ? '—' : '${s.points}', style: TextStyle( fontSize: 30.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, letterSpacing: -1, height: 1, color: Colors.white, @@ -217,7 +217,7 @@ class _RiderRewardsPageState extends State { 'points', style: TextStyle( fontSize: 11.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, letterSpacing: 0.4, color: Colors.white.withValues(alpha: 0.78), fontFamily: FontConstants.fontFamily, @@ -244,7 +244,7 @@ class _RiderRewardsPageState extends State { '${s.nextTier!.label}', style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: Colors.white, fontFamily: FontConstants.fontFamily, ), @@ -297,8 +297,8 @@ class _RiderRewardsPageState extends State { style: TextStyle( fontSize: 10.5.sp, fontWeight: tier == s.tier - ? FontWeight.w700 - : FontWeight.w500, + ? FontWeight.w800 + : FontWeight.w600, color: tier == s.tier ? ColorConstants.primary : ColorConstants.secondaryText, @@ -309,7 +309,7 @@ class _RiderRewardsPageState extends State { '${tier.threshold}', style: TextStyle( fontSize: 9.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -404,7 +404,7 @@ class _RiderRewardsPageState extends State { 'cleared both checks', style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), @@ -447,7 +447,7 @@ class _RiderRewardsPageState extends State { style: TextStyle( fontSize: 11.5.sp, height: 1.4, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -539,7 +539,7 @@ class _RiderRewardsPageState extends State { style: TextStyle( fontSize: 11.5.sp, height: 1.45, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -555,7 +555,7 @@ class _RiderRewardsPageState extends State { text, style: TextStyle( fontSize: 11.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: 0.8, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -565,7 +565,7 @@ class _RiderRewardsPageState extends State { TextStyle get _bodyStyle => TextStyle( fontSize: 12.5.sp, height: 1.45, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ); @@ -627,7 +627,7 @@ class _RiderRewardsPageState extends State { '$score%', style: TextStyle( fontSize: 18.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, letterSpacing: -0.5, height: 1, color: accent, @@ -639,7 +639,7 @@ class _RiderRewardsPageState extends State { 'SCORE', style: TextStyle( fontSize: 8.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: 0.6, height: 1, color: accent, @@ -670,7 +670,7 @@ class _RiderRewardsPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.3, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -687,7 +687,7 @@ class _RiderRewardsPageState extends State { style: TextStyle( fontSize: 11.5.sp, height: 1.35, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -728,7 +728,7 @@ class _RiderRewardsPageState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), @@ -739,7 +739,7 @@ class _RiderRewardsPageState extends State { '$hit/$total · $percent%', style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.2, color: accent, fontFamily: FontConstants.fontFamily, @@ -810,7 +810,7 @@ class _RiderRewardsPageState extends State { title, style: TextStyle( fontSize: 13.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.2, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, diff --git a/lib/views/Dashboard/profile/informations/saved_address.dart b/lib/views/Dashboard/profile/informations/saved_address.dart index 98b6371..abfb69d 100644 --- a/lib/views/Dashboard/profile/informations/saved_address.dart +++ b/lib/views/Dashboard/profile/informations/saved_address.dart @@ -117,7 +117,7 @@ class _SavedAddressPageState extends State { 'Edit', style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, ), @@ -187,7 +187,7 @@ class _SavedAddressPageState extends State { 'Home address', style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -233,7 +233,7 @@ class _SavedAddressPageState extends State { 'Home address', style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -249,7 +249,7 @@ class _SavedAddressPageState extends State { style: TextStyle( fontSize: 15.sp, height: 1.45, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -298,7 +298,7 @@ class _SavedAddressPageState extends State { 'Cancel', style: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.onSurfaceVariant, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/Dashboard/summary/summary.dart b/lib/views/Dashboard/summary/summary.dart index affd2bc..ff26f6d 100644 --- a/lib/views/Dashboard/summary/summary.dart +++ b/lib/views/Dashboard/summary/summary.dart @@ -203,7 +203,7 @@ class _SummaryState extends State { _periods[index], style: TextStyle( fontSize: 13.sp, - fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + fontWeight: isSelected ? FontWeight.w800 : FontWeight.w600, letterSpacing: -0.1, color: isSelected ? Narrative.burgundy : Narrative.inkMuted, fontFamily: FontConstants.fontFamily, @@ -282,7 +282,7 @@ class _SummaryState extends State { style: TextStyle( color: Colors.white.withValues(alpha: 0.85), fontSize: 12.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, letterSpacing: 0.2, fontFamily: FontConstants.fontFamily, ), @@ -412,7 +412,7 @@ class _SummaryState extends State { style: TextStyle( color: Colors.white, fontSize: 17.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.3, fontFamily: FontConstants.fontFamily, ), @@ -425,7 +425,7 @@ class _SummaryState extends State { // ten points is decoration; the tile is worthless without it. color: Colors.white.withValues(alpha: 0.78), fontSize: 12.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, ), ), @@ -469,7 +469,7 @@ class _SummaryState extends State { 'LIFETIME', style: TextStyle( fontSize: 11.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: 1.0, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -539,7 +539,7 @@ class _SummaryState extends State { value, style: TextStyle( fontSize: 20.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.6, height: 1.05, color: ColorConstants.slateText, @@ -554,7 +554,7 @@ class _SummaryState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -685,7 +685,7 @@ class _TotalDistanceCardState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), @@ -805,7 +805,7 @@ class _TotalDistanceCardState extends State { textAlign: TextAlign.center, style: TextStyle( fontSize: 14.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), @@ -817,7 +817,7 @@ class _TotalDistanceCardState extends State { style: TextStyle( fontSize: 12.5.sp, height: 1.4, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -872,7 +872,7 @@ class _TotalDistanceCardState extends State { '${rod.toY.toStringAsFixed(1)} km', TextStyle( color: Colors.white, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, fontSize: 12.sp, fontFamily: FontConstants.fontFamily, ), @@ -923,8 +923,8 @@ class _TotalDistanceCardState extends State { style: TextStyle( fontSize: 11.sp, fontWeight: isTouched - ? FontWeight.w700 - : FontWeight.w500, + ? FontWeight.w800 + : FontWeight.w600, color: isTouched ? ColorConstants.primary : ColorConstants.onSurfaceVariant, @@ -1014,7 +1014,7 @@ class _TotalDistanceCardState extends State { value, style: TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: color, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/helpers/constants/Colorconstants.dart b/lib/views/helpers/constants/Colorconstants.dart index 8c24d09..9d4d679 100644 --- a/lib/views/helpers/constants/Colorconstants.dart +++ b/lib/views/helpers/constants/Colorconstants.dart @@ -37,6 +37,19 @@ class ColorConstants { static const Color surfaceTint = Color(0xFF960019); // Primary Red + /// ── The app bar's own red ── + /// + /// The brand bar is painted in a two-stop gradient, and these are its stops. + /// They were literals inside `home_header.dart`, which was fine while the bar + /// was the only thing wearing them — Home's trip panel wears the same red + /// now, and a panel that is *nearly* the app bar's colour reads as a mistake + /// rather than as a family. One definition, so they cannot drift apart. + /// + /// Not [primary]: that is the accent the app draws buttons and rules in, and + /// it is a cooler, deeper crimson. The bar has always been this warmer brick. + static const Color brandBarTop = Color(0xFFA51C1C); + static const Color brandBarBottom = Color(0xFF8B1A1A); + static const Color primary = Color(0xFF960019); static const Color onPrimary = Color(0xFFFFFFFF); static const Color primaryContainer = Color(0xFFC01227); diff --git a/lib/views/helpers/constants/Font_constant.dart b/lib/views/helpers/constants/Font_constant.dart index 175d3ec..1ef8cfa 100644 --- a/lib/views/helpers/constants/Font_constant.dart +++ b/lib/views/helpers/constants/Font_constant.dart @@ -3,41 +3,26 @@ import 'package:flutter/material.dart'; class FontConstants { /// ── The app's typeface ── /// - /// Poppins: geometric, OFL-licensed, and the one every screen now reads from. - /// It replaced Manrope, which was itself a stand-in for Uber Move. + /// Manrope: a free, OFL-licensed geometric sans, bundled as a single + /// variable font (`assets/fonts/Manrope/Manrope-VF.ttf`) and chosen as the + /// closest legal stand-in for Uber Move, which is proprietary. /// - /// **Read this before changing it.** The family was already declared in - /// `pubspec.yaml` as `poppins`, with a lowercase path on every one of its - /// seven assets, while the files on disk are `assets/fonts/Poppins/Poppins-*`. - /// Asset bundling is case-sensitive on both platforms, so not one of them - /// resolved: anything asking for the family fell back to Roboto and no error - /// was raised anywhere. The declaration is fixed and all seven static weights - /// are registered — Poppins ships as separate files rather than a variable - /// font, and without explicit 800/900 entries Flutter synthesises them by - /// smearing SemiBold, which is what makes a heading look muddy at exactly the - /// size it is meant to be read from. - /// ── The weight scale is calibrated for THIS family ── + /// **Poppins was tried here and reverted.** It is declared in `pubspec.yaml` + /// and its seven static weights do resolve, so it is one string away if it is + /// ever wanted again — but it runs optically far heavier than Manrope at + /// every grade: geometric, wide bowls, thick stems. Swapping the family + /// alone turned the whole app bold, headings especially, and stepping the + /// weight scale down a grade to compensate was not enough to undo it. /// - /// Poppins runs optically heavier than Manrope at every grade: it is - /// geometric, its bowls are wide and its stems thick, so `w800` here is far - /// darker on the page than `w800` was in a variable Manrope. The whole scale - /// was tuned against Manrope and came across a grade too heavy — headings - /// read as shouting and body copy as bold. - /// - /// Every weight in `lib/` (outside `lib/xpress`, which is Proxima Nova) was - /// stepped down one grade: 900→800, 800→700, 700→600, 600→500. The *relative* - /// hierarchy is untouched — each level is still one step from its neighbour — - /// so nothing lost its rank; the whole run just sits where it should for this - /// typeface. - /// - /// **If the family changes again, re-tune this.** A weight is a number, not a - /// design: what it looks like belongs to the font. - static const String fontFamily = 'Poppins'; + /// **The weight scale below is calibrated for THIS family.** If the family + /// changes again, re-tune it — a weight is a number, not a design; what it + /// looks like belongs to the font. + static const String fontFamily = 'Manrope'; - /// Only ever reached for a glyph Poppins does not carry. Manrope covers the + /// Only ever reached for a glyph Manrope does not carry. Manrope covers the /// Latin and currency sets the app uses, so this is a safety net rather than /// a second typeface in the design. - static String fontFamilyFallback = 'Manrope'; + static String fontFamilyFallback = 'PlusJakartaSans'; // Typography scale static const double displayLg = 32.0; @@ -50,21 +35,21 @@ class FontConstants { static TextStyle displayLgStyle = const TextStyle( fontSize: 32, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, letterSpacing: -0.64, fontFamily: fontFamily, ); static TextStyle headlineMdStyle = const TextStyle( fontSize: 24, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, letterSpacing: -0.24, fontFamily: fontFamily, ); static TextStyle headlineSmStyle = const TextStyle( fontSize: 20, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, letterSpacing: -0.2, fontFamily: fontFamily, ); @@ -83,7 +68,7 @@ class FontConstants { static TextStyle labelBoldStyle = const TextStyle( fontSize: 14, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, fontFamily: fontFamily, ); diff --git a/lib/views/helpers/constants/app_theme.dart b/lib/views/helpers/constants/app_theme.dart index 3416e0e..59eed49 100644 --- a/lib/views/helpers/constants/app_theme.dart +++ b/lib/views/helpers/constants/app_theme.dart @@ -36,13 +36,10 @@ class AppTheme { }, ), fontFamily: FontConstants.fontFamily, - // Manrope first: it is bundled, so its coverage is guaranteed on every - // device, and it is what backs Poppins up for anything Poppins does not - // carry. The platform families behind it are the last resort. Poppins is - // missing U+2192 among others — a glyph the app no longer renders as a - // character, but the net belongs here rather than in a rule nobody - // remembers. - fontFamilyFallback: const ['Manrope', 'Roboto', 'sans-serif'], + // Manrope is the family above and is bundled, so its coverage is + // guaranteed on every device; these are only reached for a glyph it does + // not carry, and the platform families are the last resort. + fontFamilyFallback: const ['Roboto', 'sans-serif'], textTheme: ThemeData.light().textTheme.apply( fontFamily: FontConstants.fontFamily, ), @@ -116,7 +113,7 @@ class AppTheme { ), textStyle: const TextStyle( fontSize: 16, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, ), ), @@ -153,7 +150,7 @@ class AppTheme { ), textStyle: const TextStyle( fontSize: 15, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, ), ), @@ -162,7 +159,7 @@ class AppTheme { style: TextButton.styleFrom( foregroundColor: _primary, textStyle: const TextStyle( - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, ), ), diff --git a/lib/views/helpers/constants/miler_type.dart b/lib/views/helpers/constants/miler_type.dart index 33f23fb..937734b 100644 --- a/lib/views/helpers/constants/miler_type.dart +++ b/lib/views/helpers/constants/miler_type.dart @@ -64,7 +64,7 @@ class MilerType { /// The one thing a screen is about. One per screen, or none. static TextStyle get hero => TextStyle( fontSize: 28.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.9, height: 1.08, color: ColorConstants.slateText, @@ -74,7 +74,7 @@ class MilerType { /// App bar titles and sheet headings. static TextStyle get pageTitle => TextStyle( fontSize: 22.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.6, height: 1.15, color: ColorConstants.slateText, @@ -84,7 +84,7 @@ class MilerType { /// A stop name in a list, a section heading inside a screen. static TextStyle get cardTitle => TextStyle( fontSize: 19.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.4, height: 1.2, color: ColorConstants.slateText, @@ -94,7 +94,7 @@ class MilerType { /// Addresses, running copy, the title line of a compact row. static TextStyle get body => TextStyle( fontSize: 16.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, letterSpacing: -0.2, height: 1.35, color: ColorConstants.slateText, @@ -105,7 +105,7 @@ class MilerType { /// explains, and explanation is secondary to the thing being explained. static TextStyle get caption => TextStyle( fontSize: 15.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, height: 1.4, color: ColorConstants.secondaryText, fontFamily: _family, @@ -114,7 +114,7 @@ class MilerType { /// Metadata, timestamps, chip text, the second line of a row. static TextStyle get label => TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, height: 1.35, color: ColorConstants.secondaryText, fontFamily: _family, @@ -124,7 +124,7 @@ class MilerType { /// expected to be read outdoors. static TextStyle get micro => TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, height: 1.3, color: ColorConstants.secondaryText, fontFamily: _family, @@ -134,7 +134,7 @@ class MilerType { /// the reason most groups in this app need no outline. static TextStyle get eyebrow => TextStyle( fontSize: 11.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: 1.2, height: 1.3, color: ColorConstants.secondaryText, @@ -150,7 +150,7 @@ class MilerType { /// number, resolved without `.sp`. static const TextStyle pageTitleFixed = TextStyle( fontSize: 22, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.6, height: 1.15, color: ColorConstants.slateText, @@ -165,7 +165,7 @@ class MilerType { /// them lines up. static TextStyle figure(double size, {Color? color}) => TextStyle( fontSize: size.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.3, height: 1.1, fontFeatures: const [FontFeature.tabularFigures()], @@ -183,7 +183,7 @@ class MilerType { ? 15 : 14) .sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, letterSpacing: -0.1, color: ColorConstants.onAccent, fontFamily: _family, @@ -198,7 +198,7 @@ class MilerType { extension MilerTypeTint on TextStyle { TextStyle on(Color color) => copyWith(color: color); - TextStyle get bold => copyWith(fontWeight: FontWeight.w700); + TextStyle get bold => copyWith(fontWeight: FontWeight.w800); - TextStyle get semibold => copyWith(fontWeight: FontWeight.w600); + TextStyle get semibold => copyWith(fontWeight: FontWeight.w700); } diff --git a/lib/views/helpers/widgets/app_widgets.dart b/lib/views/helpers/widgets/app_widgets.dart index 2a02b1f..8d20ede 100644 --- a/lib/views/helpers/widgets/app_widgets.dart +++ b/lib/views/helpers/widgets/app_widgets.dart @@ -9,6 +9,7 @@ import 'package:get/get.dart'; import 'package:shimmer/shimmer.dart'; import 'package:miler/Models/stop_status.dart'; +import 'package:miler/views/helpers/constants/narrative.dart'; import 'package:miler/views/helpers/constants/miler_type.dart'; import 'package:miler/views/helpers/widgets/miler_app_bar.dart' show milerBrandSurface; @@ -64,10 +65,6 @@ class MilerBand extends StatelessWidget { /// 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; @@ -76,21 +73,36 @@ class MilerBand extends StatelessWidget { 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; + /// One number for the whole app: two bands a couple of points apart is + /// exactly how a column of text stops looking like a column. + static double get pad => 18.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; + /// The page margin the band sits in. + /// + /// ── Bands used to run edge to edge, and the page paid for it ── + /// + /// A full-bleed band has no shape of its own, so the only thing separating + /// one from the next was a [gap] of bare page showing through — which turned + /// the ground into a set of stripes between the content. On the old slate + /// canvas those stripes were dark grey bars, and the screen read as a stack + /// of unrelated slabs rather than as a form. + /// + /// A band is a card now: inset from both edges, rounded, on a hairline and a + /// soft lift. The gap between two of them is air rather than a bar, and the + /// page's left margin is the card's edge instead of the padding inside it. + static double get gutter => 14.w; + + /// The air between one band and the next. + static double get gap => 12.h; + + /// The corner. Sections of a form, so a little tighter than a page-level + /// panel — see [Narrative.radiusCard], which is the shape Home's cards take. + static double get radius => Narrative.radiusInner; @override Widget build(BuildContext context) { @@ -102,14 +114,16 @@ class MilerBand extends StatelessWidget { duration: DesignConstants.motionState, curve: Curves.easeOut, width: double.infinity, + margin: EdgeInsets.symmetric(horizontal: gutter), 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), - ), + borderRadius: BorderRadius.circular(radius), + // The hairline and the lift together, the same recipe every other card + // in the app is built on — so a form section and a panel on Home read + // as the same kind of object. See [Narrative.cardBox]. + border: Border.all(color: Narrative.cardBorder, width: 1), + boxShadow: Narrative.cardShadow, ), child: child, ); @@ -261,7 +275,7 @@ class PrimaryButton extends StatelessWidget { maxLines: 1, style: const TextStyle( fontSize: 16, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, ), ), @@ -324,7 +338,7 @@ class SecondaryButton extends StatelessWidget { style: TextStyle( color: c, fontSize: 14, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, ), ), @@ -370,7 +384,7 @@ class StatusChip extends StatelessWidget { style: TextStyle( color: color, fontSize: 12, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, ), ), @@ -440,7 +454,7 @@ class SectionHeader extends StatelessWidget { maxLines: 1, style: TextStyle( fontSize: 18, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, letterSpacing: -0.2, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, @@ -456,7 +470,7 @@ class SectionHeader extends StatelessWidget { action!, style: TextStyle( fontSize: 14, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, ), @@ -540,7 +554,7 @@ class InfoBanner extends StatelessWidget { color: color, fontSize: 13, height: 1.35, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, ), ), @@ -722,7 +736,7 @@ class AppLoader extends StatelessWidget { label!, style: TextStyle( fontSize: 13, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.onSurfaceVariant, fontFamily: FontConstants.fontFamily, ), @@ -1629,7 +1643,7 @@ class MilerButton extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: fontSize, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.1, color: fg, fontFamily: FontConstants.fontFamily, @@ -2042,7 +2056,7 @@ class StopSourceLine extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.5, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.1, color: accent, fontFamily: FontConstants.fontFamily, @@ -2063,7 +2077,7 @@ class StopSourceLine extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.5, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, // Tabular, because these are read as codes and a column of // them down a list should line up. fontFeatures: const [FontFeature.tabularFigures()], @@ -2124,7 +2138,7 @@ class OrderStatusTag extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.5, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.1, color: color, fontFamily: FontConstants.fontFamily, diff --git a/lib/views/helpers/widgets/load_state_view.dart b/lib/views/helpers/widgets/load_state_view.dart index 244b6a2..31c8b8a 100644 --- a/lib/views/helpers/widgets/load_state_view.dart +++ b/lib/views/helpers/widgets/load_state_view.dart @@ -143,7 +143,7 @@ class _Message extends StatelessWidget { textAlign: TextAlign.center, style: TextStyle( fontSize: 17.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.3, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -156,7 +156,7 @@ class _Message extends StatelessWidget { style: TextStyle( fontSize: 14.sp, height: 1.45, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -172,7 +172,7 @@ class _Message extends StatelessWidget { 'Try again', style: TextStyle( fontSize: 14.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, fontFamily: FontConstants.fontFamily, ), ), diff --git a/lib/views/helpers/widgets/miler_app_bar.dart b/lib/views/helpers/widgets/miler_app_bar.dart index de86cc3..f0bfb29 100644 --- a/lib/views/helpers/widgets/miler_app_bar.dart +++ b/lib/views/helpers/widgets/miler_app_bar.dart @@ -319,7 +319,7 @@ class MilerAvatar extends StatelessWidget { // Fixed .sp off a fixed box: the circle does not grow with // the system font scale, so neither can the letter inside it. fontSize: 19.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, height: 1, color: onBrand ? ColorConstants.primary diff --git a/lib/views/helpers/widgets/miler_map.dart b/lib/views/helpers/widgets/miler_map.dart index 16ef9cd..d5d4f31 100644 --- a/lib/views/helpers/widgets/miler_map.dart +++ b/lib/views/helpers/widgets/miler_map.dart @@ -149,7 +149,7 @@ class _MilerAttribution extends StatelessWidget { '© OpenStreetMap', style: TextStyle( fontSize: 9, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), @@ -345,7 +345,7 @@ class MilerNumberPin extends StatelessWidget { style: const TextStyle( fontSize: 14, height: 1, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: Colors.white, fontFamily: FontConstants.fontFamily, ), @@ -512,14 +512,102 @@ class _MilerLegMapState extends State { } } + /// How many times to ask for the road before settling for the bearing. + /// + /// ── Why one attempt was never enough ── + /// + /// `route()` answers an empty list for every failure — a 429, a cold DNS + /// lookup that outran the timeout, a tunnel — and this asked exactly once, + /// from `initState`. The only other trigger is `didUpdateWidget`, and it + /// fires on a change of *endpoint*: a rider standing at a door changes + /// neither. So a single unlucky moment at the instant the sheet opened left + /// `_road` empty for as long as the sheet stayed open, and the leg drew as a + /// dashed straight line across a route the router would have returned a + /// second later. That is the "it shows a straight line, not the real route" + /// report, and it is a retry bug rather than a routing one — the public OSRM + /// server answers this leg in under a second when asked again. + /// + /// Three tries with a widening gap. Cheap, bounded, and it costs nothing at + /// all in the normal case where the first answer is the road. + static const int _routeAttempts = 3; + static const Duration _retryBackoff = Duration(milliseconds: 700); + + /// Distinguishes a stale in-flight request from the current one, so a leg + /// that changed while the network was slow cannot have the previous leg's + /// road painted onto it when the old request finally lands. + int _fetchGeneration = 0; + + /// ── The backoff has to be cancellable, not just ignored ── + /// + /// A bare `Future.delayed` checks `mounted` on the far side of the wait, which + /// is too late for the one observer that counts: the timer is still pending + /// when the tree goes, and Flutter's test binding asserts on exactly that — + /// *"A Timer is still pending even after the widget tree was disposed"*. It + /// failed 15 tests that open this sheet and dispose it inside the retry gap. + /// + /// Off a test bench it is the same leak in slower motion: a rider flicking + /// through six stops leaves six timers running against six dead widgets. + /// + /// So the wait is a [Timer] this state owns and [dispose] cancels — and + /// disposing also completes the pending waiter, so the retry loop wakes, + /// sees `!mounted`, and unwinds instead of parking forever on a future + /// nothing will ever finish. + Timer? _retryTimer; + Completer? _retryWait; + + @override + void dispose() { + _retryTimer?.cancel(); + _retryTimer = null; + final waiter = _retryWait; + _retryWait = null; + if (waiter != null && !waiter.isCompleted) waiter.complete(); + super.dispose(); + } + + /// A cancellable [Future.delayed]. See [_retryTimer]. + Future _wait(Duration d) { + final completer = Completer(); + _retryWait = completer; + _retryTimer = Timer(d, () { + _retryTimer = null; + if (identical(_retryWait, completer)) _retryWait = null; + if (!completer.isCompleted) completer.complete(); + }); + return completer.future; + } + Future _fetchRoad() async { final from = widget.from; if (from == null) return; - final road = await MilerRouter.route(from, widget.to); - if (!mounted || road.isEmpty) return; - setState(() => _road = road); - // The road can bulge outside the straight-line bounds; refit around it. - _fit(); + final to = widget.to; + final generation = ++_fetchGeneration; + + for (var attempt = 1; attempt <= _routeAttempts; attempt++) { + final road = await MilerRouter.route(from, to); + // Superseded, or gone from the tree, while the request was in flight. + if (!mounted || generation != _fetchGeneration) return; + + if (road.isNotEmpty) { + setState(() => _road = road); + // The road can bulge outside the straight-line bounds; refit around it. + _fit(); + return; + } + + if (attempt < _routeAttempts) { + await _wait(_retryBackoff * attempt); + if (!mounted || generation != _fetchGeneration) return; + } + } + + // Says which leg gave up, so a rider reporting a dashed line can be matched + // against the `[MAP] OSRM …` line that explains why. + debugPrint( + '[MAP] no road after $_routeAttempts attempts for ' + '${from.latitude},${from.longitude} -> ${to.latitude},${to.longitude} — ' + 'drawing the dashed bearing instead.', + ); } void _fit() { diff --git a/lib/views/helpers/widgets/miler_sheet_kit.dart b/lib/views/helpers/widgets/miler_sheet_kit.dart index 280348b..d0b1f0e 100644 --- a/lib/views/helpers/widgets/miler_sheet_kit.dart +++ b/lib/views/helpers/widgets/miler_sheet_kit.dart @@ -209,7 +209,7 @@ class MilerSheetHeader extends StatelessWidget { title, style: TextStyle( fontSize: 18.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.45, height: 1.2, color: ColorConstants.slateText, @@ -222,7 +222,7 @@ class MilerSheetHeader extends StatelessWidget { subtitle!, style: TextStyle( fontSize: 13.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, height: 1.35, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -337,8 +337,8 @@ class MilerSheetChoiceRow extends StatelessWidget { color: selected ? accent : ColorConstants.slateText, fontFamily: FontConstants.fontFamily, fontWeight: selected - ? FontWeight.w700 - : FontWeight.w500, + ? FontWeight.w800 + : FontWeight.w600, ), ), ), diff --git a/lib/views/helpers/widgets/slide_action.dart b/lib/views/helpers/widgets/slide_action.dart index c46ebb8..2707892 100644 --- a/lib/views/helpers/widgets/slide_action.dart +++ b/lib/views/helpers/widgets/slide_action.dart @@ -295,7 +295,7 @@ class _MilerSlideActionState extends State overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 16.sp, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, letterSpacing: -0.3, color: ColorConstants.onAccent, fontFamily: FontConstants.fontFamily, diff --git a/lib/views/introscreens/introscreen.dart b/lib/views/introscreens/introscreen.dart index fbb63a0..c3d0b4a 100644 --- a/lib/views/introscreens/introscreen.dart +++ b/lib/views/introscreens/introscreen.dart @@ -236,7 +236,7 @@ class _OnboardButton extends StatelessWidget { style: TextStyle( color: ColorConstants.primary, fontSize: 16.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.2, fontFamily: FontConstants.fontFamily, ), @@ -279,7 +279,7 @@ class _SkipAction extends StatelessWidget { 'Skip', style: TextStyle( fontSize: 13.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/onboardscreens/Creat_mpin.dart b/lib/views/onboardscreens/Creat_mpin.dart index 13edefc..a8aa15a 100644 --- a/lib/views/onboardscreens/Creat_mpin.dart +++ b/lib/views/onboardscreens/Creat_mpin.dart @@ -236,7 +236,7 @@ class _CreateMpinBodyState extends State<_CreateMpinBody> { 'Matches', style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: ColorConstants.acceptGreen, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/onboardscreens/Sign_in.dart b/lib/views/onboardscreens/Sign_in.dart index 3f3b031..ca347d1 100644 --- a/lib/views/onboardscreens/Sign_in.dart +++ b/lib/views/onboardscreens/Sign_in.dart @@ -240,7 +240,7 @@ class _SignInState extends State { cursorColor: ColorConstants.primary, style: TextStyle( fontSize: 18.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, letterSpacing: 1.2, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -259,7 +259,7 @@ class _SignInState extends State { floatingLabelBehavior: FloatingLabelBehavior.always, labelStyle: TextStyle( fontSize: 13.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, letterSpacing: -0.1, color: error ? ColorConstants.errorRed @@ -272,7 +272,7 @@ class _SignInState extends State { hintStyle: TextStyle( color: ColorConstants.secondaryText, fontSize: 18.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, letterSpacing: 1.2, fontFamily: FontConstants.fontFamily, ), @@ -293,7 +293,7 @@ class _SignInState extends State { '+91', style: TextStyle( fontSize: 17.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.2, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, @@ -347,7 +347,7 @@ class _SignInState extends State { text: TextSpan( style: TextStyle( fontSize: 11.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, height: 1.5, fontFamily: FontConstants.fontFamily, @@ -358,7 +358,7 @@ class _SignInState extends State { text: 'Terms of Service', style: TextStyle( color: ColorConstants.slateText, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, decoration: TextDecoration.underline, decorationColor: ColorConstants.borderStrong, fontFamily: FontConstants.fontFamily, @@ -370,7 +370,7 @@ class _SignInState extends State { text: 'Privacy Policy', style: TextStyle( color: ColorConstants.slateText, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, decoration: TextDecoration.underline, decorationColor: ColorConstants.borderStrong, fontFamily: FontConstants.fontFamily, diff --git a/lib/views/onboardscreens/auth_scaffold.dart b/lib/views/onboardscreens/auth_scaffold.dart index 4719586..8c76f80 100644 --- a/lib/views/onboardscreens/auth_scaffold.dart +++ b/lib/views/onboardscreens/auth_scaffold.dart @@ -51,7 +51,7 @@ import 'package:miler/views/helpers/widgets/app_widgets.dart'; abstract final class AuthType { static TextStyle get title => TextStyle( fontSize: 27.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.7, height: 1.15, color: ColorConstants.slateText, @@ -60,7 +60,7 @@ abstract final class AuthType { static TextStyle get subtitle => TextStyle( fontSize: 14.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, height: 1.45, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -68,7 +68,7 @@ abstract final class AuthType { static TextStyle get fieldLabel => TextStyle( fontSize: 12.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: 0.5, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -76,7 +76,7 @@ abstract final class AuthType { static TextStyle get meta => TextStyle( fontSize: 12.5.sp, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, height: 1.45, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -169,7 +169,7 @@ class AuthSteps extends StatelessWidget { 'STEP $step OF $total', style: TextStyle( fontSize: 10.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: 1.0, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, @@ -578,7 +578,7 @@ class AuthBrandBanner extends StatelessWidget { 'MILER · RIDER APP', style: TextStyle( fontSize: 10.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: 1.6, color: ColorConstants.onAccent.withValues(alpha: 0.72), fontFamily: FontConstants.fontFamily, @@ -731,7 +731,7 @@ class AuthPrimaryButton extends StatelessWidget { style: TextStyle( color: Colors.white, fontSize: 16.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, letterSpacing: -0.2, fontFamily: FontConstants.fontFamily, ), @@ -786,7 +786,7 @@ class AuthTextAction extends StatelessWidget { label, style: TextStyle( fontSize: 13.5.sp, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: color, fontFamily: FontConstants.fontFamily, ), @@ -835,7 +835,7 @@ class AuthInlineError extends StatelessWidget { style: TextStyle( color: ColorConstants.errorRed, fontSize: 12.5.sp, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, height: 1.35, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/onboardscreens/otp_page.dart b/lib/views/onboardscreens/otp_page.dart index 82e2350..a52aff2 100644 --- a/lib/views/onboardscreens/otp_page.dart +++ b/lib/views/onboardscreens/otp_page.dart @@ -204,7 +204,7 @@ class _OtpPageLayoutState extends State<_OtpPageLayout> with CodeAutoFill { TextSpan( text: _maskedPhone, style: TextStyle( - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), diff --git a/lib/views/updatescreen/UpdateScreen.dart b/lib/views/updatescreen/UpdateScreen.dart index 4458ee9..fb37d25 100644 --- a/lib/views/updatescreen/UpdateScreen.dart +++ b/lib/views/updatescreen/UpdateScreen.dart @@ -153,7 +153,7 @@ class _UpdateScreenState extends State "A New Update is Available!", style: TextStyle( fontSize: h * 0.025, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, color: ColorConstants.onSurface, ), diff --git a/lib/widget/Bottom_page.dart b/lib/widget/Bottom_page.dart index 503a8b9..15dbf0a 100644 --- a/lib/widget/Bottom_page.dart +++ b/lib/widget/Bottom_page.dart @@ -580,8 +580,8 @@ class _NavBarItem extends StatelessWidget { color: tint, fontSize: 10.5.sp, fontWeight: FontWeight.lerp( - FontWeight.w500, FontWeight.w600, + FontWeight.w700, lit, ), fontFamily: FontConstants.fontFamily, diff --git a/pubspec.lock b/pubspec.lock index 7f07db0..1227772 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -996,10 +996,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -1012,10 +1012,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" mgrs_dart: dependency: transitive description: @@ -1437,6 +1437,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.0" + tab_container: + dependency: "direct main" + description: + name: tab_container + sha256: "15f87032f63fd7f0a59740aa31c5bc7056af3159a37311bca546d343240f3029" + url: "https://pub.dev" + source: hosted + version: "3.5.3" tab_indicator_styler: dependency: "direct main" description: @@ -1457,10 +1465,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" timezone: dependency: transitive description: @@ -1577,10 +1585,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" vibration: dependency: "direct main" description: @@ -1710,5 +1718,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.0-0 <4.0.0" + dart: ">=3.11.0-0 <4.0.0" flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 1b0faca..666c680 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -75,6 +75,7 @@ dependencies: animated_toggle_switch: ^0.8.7 another_flushbar: ^2.2.4 lucide_icons_flutter: ^3.1.17 + tab_container: ^3.5.3 dev_dependencies: flutter_test: @@ -86,13 +87,51 @@ dev_dependencies: image_picker_platform_interface: ^2.10.0 +# ── The launcher icon looked small, and the manifest was not the reason ── +# +# `android:icon="@mipmap/ic_launcher"` only names the resource; how large the +# mark *reads* on the home screen is decided by the artwork and by whether the +# icon is adaptive. Both were working against it: +# +# 1. `doormile_icon.png` is 1024x1024 with the mark occupying 684px of it — +# 170px of transparent margin on every side, so a third of the icon was +# empty before Android touched it. +# 2. There was no `adaptive_icon_foreground`, so no `mipmap-anydpi-v26` was +# generated. Android 8+ falls back to *legacy treatment* for a plain PNG: +# it shrinks the bitmap to roughly three-quarters and centres it on a white +# rounded backdrop it draws itself. Stacked on the margin above, the mark +# ended up at about half the width of the icons beside it. +# +# So the fix is two pre-sized sources, not a manifest attribute: +# +# • **foreground** — the mark alone at 61% of the canvas, which is the 66dp +# safe zone of the 108dp adaptive canvas. Everything outside that is what +# the launcher's mask cuts away, and a circle mask on this circular mark +# now meets its edge. +# • **legacy / iOS** — the mark at 80% on white, because neither is masked the +# way an adaptive icon is and both are square. +# +# `adaptive_icon_foreground_inset: 0` because the padding is already baked into +# the foreground at the exact safe-zone ratio; the tool's default 16 would add +# a second margin and undo the change. +# +# `doormile_icon.png` is deliberately left alone — `flutter_native_splash` +# below still uses it, and the splash wants the airier framing. +# +# The two generated sources live in `launcher_icon/` rather than +# `assets/images/`, which is globbed wholesale into the bundle: they are +# build-time inputs for this tool and have no business being shipped inside the +# APK the tool produces. flutter_launcher_icons: android: true ios: true web: generate: true - image_path: "assets/images/doormile_icon.png" - image_path: "assets/images/doormile_icon.png" + image_path: "launcher_icon/doormile_icon_legacy.png" + image_path: "launcher_icon/doormile_icon_legacy.png" + adaptive_icon_background: "#FFFFFF" + adaptive_icon_foreground: "launcher_icon/doormile_icon_foreground.png" + adaptive_icon_foreground_inset: 0 remove_alpha_ios: true flutter_native_splash: color: "#FFFFFF" @@ -128,19 +167,23 @@ flutter: - family: PlusJakartaSans fonts: - asset: assets/fonts/PlusJakartaSans/PlusJakartaSans-VF.ttf - # ── The app's typeface ── + # ── Declared, correct, and currently unused ── # - # Declared as `poppins` with a lowercase path on every asset — and the files - # on disk are `assets/fonts/Poppins/Poppins-*.ttf`. Asset bundling is - # case-sensitive on both platforms, so not one of these seven resolved and - # the family had never rendered a single glyph. Anything asking for it fell - # back to Roboto, silently. + # The parcel line reads Manrope (FontConstants.fontFamily) and the delivery + # line reads Proxima Nova; nothing asks for Poppins today. It was briefly + # made the app's typeface and reverted — it runs optically much heavier than + # Manrope at every grade and turned the whole app bold. # - # All seven static weights are declared because Poppins ships as separate - # files rather than a variable font like Manrope did. The app leans on w800 - # and w900 for headings; without those two entries Flutter would synthesise - # them by smearing SemiBold, which is what makes a heading look muddy at - # exactly the size it is meant to be read from. + # The declaration is kept because it is now correct and was not before: it + # said `poppins` with a lowercase path on every asset, while the files on + # disk are `assets/fonts/Poppins/Poppins-*.ttf`. Asset bundling is + # case-sensitive on both platforms, so none of the seven resolved. Leaving + # the fixed version here means the family is one string away if it is ever + # wanted, instead of silently falling back again. + # + # All seven static weights are listed because Poppins ships as separate + # files rather than a variable font the way Manrope does; without explicit + # 800/900 entries Flutter synthesises them by smearing SemiBold. - family: Poppins fonts: - asset: assets/fonts/Poppins/Poppins-Light.ttf diff --git a/test/activity_page_test.dart b/test/activity_page_test.dart index 09902e1..dc357b8 100644 --- a/test/activity_page_test.dart +++ b/test/activity_page_test.dart @@ -168,11 +168,14 @@ void main() { // selection on the bar, never four. final selected = tester.widget(find.text('All')); expect(selected.style!.color, Colors.white); - expect(selected.style!.fontWeight, FontWeight.w700); + // One grade heavier than this once read: the app was set in Poppins on a + // scale stepped down to compensate for that face, and is back on Manrope + // with the scale restored. See `FontConstants`. + expect(selected.style!.fontWeight, FontWeight.w800); final other = tester.widget(find.text('Completed')); expect(other.style!.color, Narrative.inkMuted); - expect(other.style!.fontWeight, FontWeight.w600); + expect(other.style!.fontWeight, FontWeight.w700); // Exactly one filled brand pill in the bar. final filled = tester @@ -221,7 +224,10 @@ void main() { // 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); + // One grade heavier than this once read: the app was set in Poppins on a + // scale stepped down to compensate for that face, and is back on Manrope + // with the scale restored. See `FontConstants`. + expect(name.style!.fontWeight, FontWeight.w700); expect( name.style!.fontSize, greaterThan(word.style!.fontSize!), diff --git a/test/card_density_test.dart b/test/card_density_test.dart index d4d8a7e..49d5fa5 100644 --- a/test/card_density_test.dart +++ b/test/card_density_test.dart @@ -854,9 +854,15 @@ void main() { expect(find.text('All 3 selected · Clear'), findsOneWidget); }); - testWidgets('stays away when there is only one stop to pick', ( - tester, - ) async { + testWidgets('a lone stop gets its place\'s own select-all', (tester) async { + // ── This asserted `findsNothing`, and the shape changed under it ── + // + // The run-level control still stays away for one stop — its own row is + // the select-all. What appears instead is the *place's*: a stop with an + // address is a place holding one order, and a place carries a select-all + // over its own orders however many it has. The milk run has always drawn + // `Select all 1` on a one-order counter; the parcel line draws places + // now, so it draws it too. await pump( tester, TripCard( @@ -866,7 +872,7 @@ void main() { onAcceptSelected: (_) async {}, ), ); - expect(find.textContaining('Select all'), findsNothing); + expect(find.text('Select all 1'), findsOneWidget); }); }); @@ -986,11 +992,20 @@ void main() { ), ); - // The position sits on the timeline node's own meta line now, rather - // than on the card's facts line — but it is still the trip's own index - // and not the visible list's, which is the whole contract. - expect(find.textContaining('Stop 2'), findsOneWidget); + // ── `Stop N` is gone from the parcel route, with the flat node ── + // + // Home draws places on both lines now: the header names where the work + // is, the rail under it names who. `Stop N` was the *flat* node's meta + // line, and there are no flat nodes on a route whose stops have + // addresses. The run position is the place's numeral instead. + // + // The contract underneath it — a stop is never renamed mid-shift — is + // carried by the group: a finished place settles rather than vanishing, + // so the sequence does not slide up. What this can still hold is that + // the picked stop has left the list and the remaining one is drawn. expect(find.textContaining('Stop 1'), findsNothing); + expect(find.text('B'), findsNothing, reason: 'ids are not on the row'); + expect(find.text('1 order'), findsOneWidget); }); }); diff --git a/test/home_grid_test.dart b/test/home_grid_test.dart index b38841f..bb1f20e 100644 --- a/test/home_grid_test.dart +++ b/test/home_grid_test.dart @@ -162,7 +162,14 @@ void main() { final area = tester.widget(find.text('Peelamedu').first).style!; expect(kitchen.fontSize, 19); - expect(kitchen.fontWeight, FontWeight.w700); + // ── The grade belongs to the typeface, not to this test ── + // + // This read w700 while the app was set in Poppins on a scale stepped down + // one grade to compensate for how heavily that face renders. The app is + // back on Manrope and the scale went back up with it, so the heading is + // w800 again — the value it had before the typeface was swapped. See + // `FontConstants`, which is where the scale is documented. + expect(kitchen.fontWeight, FontWeight.w800); expect(name.fontSize, 15); expect(area.fontSize, 12.5); diff --git a/test/hub_handover_contract_test.dart b/test/hub_handover_contract_test.dart new file mode 100644 index 0000000..a326377 --- /dev/null +++ b/test/hub_handover_contract_test.dart @@ -0,0 +1,291 @@ +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/consignment_state.dart'; +import 'package:miler/data/lifecycle.dart'; +import 'package:miler/data/milk_run.dart'; +import 'package:miler/data/miler_api.dart'; +import 'package:miler/data/next_leg.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/data/work_domain.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE BASE-HANDOVER CONTRACT, AS THE BACKEND SHIPPED IT +/// +/// Requests 25–31 landed together, and the app's half of them is: read the four +/// new fields off the row, understand the six `next_action` words, and be +/// correct in **both positions of `MILER_HUB_HANDOVER_ENABLED`** — because that +/// flag is flipped server-side and no app release gets to sit in between. +/// +/// off pickup-complete → Inwarded_at_Hub + handed_to_hub rider is done +/// on pickup-complete → Created + inward_at_hub rider carries it +/// +/// These pin both, plus the field plumbing that makes them readable at all: the +/// booking adapter builds a **fixed map**, so a field it does not name is a +/// field no screen can ever see, however faithfully the backend sends it. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + setUp(() => ServiceProfile.setActive(ServiceProfile.parcel)); + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + group('the adapter carries the new fields', () { + // The trap `step`, `stoptype` and every timestamp were in before it: the + // server sends the field, the adapter has no key for it, and the app is + // blind to something that was on the wire all along. + final adapted = ApiConfig.pickupFromBooking(const { + 'bookingid': 4821, + 'status': 'Converted_To_Consignment', + 'consignmentstatus': 'Created', + 'next_action': 'inward_at_hub', + 'next_hub': { + 'id': 1, + 'name': 'Coimbatore Hub', + 'address': '14 Avinashi Road, Peelamedu, Coimbatore', + 'pincode': '641004', + 'latitude': 11.0272, + 'longitude': 76.9905, + }, + 'pickup_source_type': 'customer', + 'pickup_source_name': 'Anitha R', + }); + + test('next_action survives', () { + expect(adapted['next_action'], 'inward_at_hub'); + expect(NextLegResolver.rowNextAction(adapted), 'inward_at_hub'); + }); + + test('next_hub survives whole, and is navigable', () { + final base = HandoverHub.from(adapted['next_hub']); + expect(base, isNotNull); + expect(base!.id, '1'); + expect(base.name, 'Coimbatore Hub'); + expect(base.pincode, '641004'); + expect(base.latitude, 11.0272); + expect(base.isNavigable, isTrue, + reason: 'coordinates are the whole point of request 25'); + }); + + test('pickup_source_type and _source_name survive', () { + expect(adapted['pickup_source_type'], 'customer'); + expect(PickupSource.of(adapted), PickupSource.customer); + expect(adapted['pickup_source_name'], 'Anitha R'); + }); + + test('the counter name outranks the account name', () { + // `sourcename` is filled from providercompany/providerlocation and is a + // contact person on some rows — this is the fix for `Sudharsan` heading a + // route card. + final row = ApiConfig.pickupFromBooking(const { + 'bookingid': 7, + 'sourcename': 'Sudharsan', + 'pickup_source_name': 'Sri Balaji Stores', + }); + expect(MilkRun.sourceNameOf(row), 'Sri Balaji Stores'); + }); + }); + + group('the full next_action vocabulary', () { + Map row(String action, {String consignment = ''}) => { + 'orderid': 'A1', + 'orderstatus': 'picked', + 'consignmentstatus': consignment, + 'next_action': action, + }; + + test('deliver and start_delivery are both customer legs', () { + expect(NextLegResolver.resolve(row('start_delivery')).leg, NextLeg.customer); + expect(NextLegResolver.resolve(row('deliver')).leg, NextLeg.customer); + }); + + test('inward_at_hub is a base leg, handed_to_hub is finished', () { + expect(NextLegResolver.resolve(row('inward_at_hub')).leg, NextLeg.hub); + expect(NextLegResolver.resolve(row('handed_to_hub')).leg, NextLeg.closed); + }); + + test('pickup and none decide nothing on their own', () { + // `pickup` is "not collected yet", which is not a leg. `none` means + // terminal — and rung 1 reads that off the consignment's own state, which + // is stronger evidence than a bare word. + expect(NextLegResolver.resolve(row('pickup')).leg, NextLeg.unknown); + expect(NextLegResolver.resolve(row('none')).leg, NextLeg.unknown); + }); + + test('an unknown word falls through instead of failing', () { + final decision = NextLegResolver.resolve( + row('teleport_to_mars', consignment: 'Out_for_Delivery'), + ); + expect(decision.leg, NextLeg.customer, + reason: 'the consignment state still answers'); + }); + }); + + group('correct in both positions of the flag', () { + // The row as `GET /miler/bookings` returns it after the pivot, adapted. + Map afterPickup({ + required String consignment, + required String action, + }) => ApiConfig.pickupFromBooking({ + 'bookingid': 4821, + 'status': 'Converted_To_Consignment', + 'consignmentstatus': consignment, + 'next_action': action, + }); + + test('FLAG OFF — the rider is done at the pickup', () { + // pickup-complete inwards it immediately; the assignment closes there. + final row = afterPickup( + consignment: 'Inwarded_at_Hub', + action: 'handed_to_hub', + ); + + expect(stopStatusOf(row).isPicked, isTrue, + reason: 'the booking reached Converted_To_Consignment'); + expect(NextLegResolver.resolve(row).leg, NextLeg.closed); + expect(WorkBoundary.domainOf(row), WorkDomain.closed); + }); + + test('FLAG ON — the rider is carrying it to a base', () { + final row = afterPickup(consignment: 'Created', action: 'inward_at_hub'); + + final decision = NextLegResolver.resolve(row); + expect(decision.leg, NextLeg.hub); + // ── Read from the row, not from the handset ── + // + // The pivot cache was only ever a workaround for `next_action` not being + // on the queue read. Request 27 put it there, so the live field answers + // and the cache is not consulted at all. + expect(decision.source, 'row.next_action'); + + expect(WorkBoundary.isClosed(row), isFalse); + expect(WorkBoundary.domainOf(row), WorkDomain.delivery); + expect(MilkRun.navigatesToCustomer(row), isFalse, + reason: 'a base-routed parcel never points at its receiver'); + }); + + test('and neither position needs the local cache', () { + // Passing no pivotAction at all — a fresh install mid-day. + for (final pair in const [ + ('Inwarded_at_Hub', 'handed_to_hub', NextLeg.closed), + ('Created', 'inward_at_hub', NextLeg.hub), + ('Collected_By_Miler', 'start_delivery', NextLeg.customer), + ('Out_for_Delivery', 'deliver', NextLeg.customer), + ]) { + final row = afterPickup(consignment: pair.$1, action: pair.$2); + expect(NextLegResolver.resolve(row).leg, pair.$3, + reason: '${pair.$1} / ${pair.$2}'); + } + }); + }); + + group('the handover response is read for what it proves', () { + ApiResult ok(Map data) => + ApiResult(ok: true, status: 200, data: data); + + test('a fresh handover is confirmed by the state it names', () { + final t = MilerLifecycle.inwardAtHub(ok(const { + 'consignmentstatus': 'Inwarded_at_Hub', + 'inwardedat': '2026-09-02T14:22:10Z', + 'next_action': 'handed_to_hub', + 'already_inwarded': false, + })); + expect(t.isConfirmed, isTrue); + expect(t.consignmentState, ConsignmentState.inwardedAtHub); + }); + + test('already_inwarded is a success, never a failure', () { + // He handed it over, the reply was lost, he pressed again. The parcel is + // on the base's counter and he cannot prove that from where he stands — + // so the second answer confirms the first. + final t = MilerLifecycle.inwardAtHub(ok(const { + 'consignmentstatus': 'Inwarded_at_Hub', + 'already_inwarded': true, + })); + expect(t.isConfirmed, isTrue); + }); + + test('a refusal carries its code', () { + final t = MilerLifecycle.inwardAtHub(const ApiResult( + ok: false, + status: 400, + code: 'INVALID_STATE', + message: 'already out for delivery', + )); + expect(t.isRefused, isTrue); + expect(t.code, 'INVALID_STATE'); + }); + + test('a 200 that names no state is not proof', () { + // The rule request 15 exists for: a bare success is not a transition. + final t = MilerLifecycle.inwardAtHub(ok(const {'success': true})); + expect(t.isUnconfirmed, isTrue); + }); + }); + + group('pickup source types', () { + test('each type reads off the row', () { + for (final pair in const [ + ('hub', PickupSource.hub), + ('customer', PickupSource.customer), + ('merchant', PickupSource.merchant), + ('store', PickupSource.store), + ]) { + expect(PickupSource.of({'pickup_source_type': pair.$1}), pair.$2); + } + }); + + test('an unknown or missing type is a plain pickup, never a guess', () { + expect(PickupSource.of(const {}), PickupSource.unknown); + expect( + PickupSource.of(const {'pickup_source_type': 'spaceport'}), + PickupSource.unknown, + ); + expect(PickupSource.unknown.eyebrow, 'PICKUP'); + }); + + test('no source eyebrow says "hub" to a rider', () { + // The house rule — see `no_hub_in_rider_copy_test.dart`. Base, not hub. + for (final s in PickupSource.values) { + expect(s.eyebrow.toLowerCase(), isNot(contains('hub'))); + } + expect(PickupSource.hub.eyebrow, 'BASE PICKUP'); + }); + + test('counters are collected in bulk; a door is not', () { + expect(PickupSource.hub.isCounter, isTrue); + expect(PickupSource.merchant.isCounter, isTrue); + expect(PickupSource.store.isCounter, isTrue); + expect(PickupSource.customer.isCounter, isFalse); + }); + }); + + group('At_Customer is the pickup arrival', () { + test('it no longer reads as arrival at the receiver', () { + // Settled by the backend: it is a milerprofiles.availabilitystatus value + // written only by `/reached`, so it says where the RIDER is. Reading it + // as `deliveryArrived` moved a stop into the delivery half of the day. + for (final raw in const ['At_Customer', 'at_customer', 'at customer']) { + expect(stopStatusFromRaw(raw), StopStatus.arrived, reason: raw); + } + }); + + test('the real delivery-arrival spellings are untouched', () { + expect(stopStatusFromRaw('delivery_arrived'), StopStatus.deliveryArrived); + expect(stopStatusFromRaw('deliveryarrived'), StopStatus.deliveryArrived); + }); + }); + + group('a base without coordinates is a caption, not a destination', () { + test('isNavigable is false and the card must not open a map', () { + final base = HandoverHub.from(const {'id': 2, 'name': 'Salem Hub'}); + expect(base, isNotNull); + expect(base!.isNavigable, isFalse, + reason: 'navigating to 0,0 lands in the Gulf of Guinea'); + }); + + test('an empty object is not a base at all', () { + expect(HandoverHub.from(const {}), isNull); + expect(HandoverHub.from(null), isNull); + }); + }); +} diff --git a/test/hub_pickup_group_test.dart b/test/hub_pickup_group_test.dart new file mode 100644 index 0000000..ce148b7 --- /dev/null +++ b/test/hub_pickup_group_test.dart @@ -0,0 +1,269 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/data/pickup_locations.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/views/Dashboard/home/route_timeline.dart'; +import 'package:miler/views/Dashboard/home/trip_card.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE HUB IS A PICKUP LOCATION, THE SAME WAY A KITCHEN IS +/// +/// A milk round draws its orders under the counter they are loaded at: one node +/// naming the kitchen, its address under it, and the drops in a list beneath. +/// A logistics route drew nothing of the kind — every stop was its own node — +/// because grouping was one flag for the whole line +/// ([ServiceProfile.sourceIsKitchen]). +/// +/// That is right for a *collection*. The parcel line's pickups are one +/// consignment per door and each genuinely is its own place. It is wrong for a +/// **delivery**: a booking the rider hands over was loaded at the depot, which +/// is the same relationship a kitchen has to a milk round. Six drops drew six +/// nodes repeating one base address, and the fact that mattered — these come +/// off one shelf, and here is that shelf — was the one thing missing. +/// +/// So the question is asked per stop. A route that mixes gets both shapes. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + Map drop(String id, String who) => { + 'orderid': id, + 'pickupid': 'HUB', + 'pickuplocationid': 'HUB', + 'sourcename': 'Doormile Hub — Peelamedu', + 'pickupaddress': '4, Avinashi Road, Peelamedu, Coimbatore 641004', + 'pickupcustomer': who, + 'orderstatus': 'pending', + 'type': 'delivery', + 'Quantity': '1', + }; + + Map collect(String id, String who) => { + 'orderid': id, + 'pickupid': id, + 'pickupcustomer': who, + 'pickupaddress': 'RS Puram, Coimbatore', + 'orderstatus': 'pending', + 'type': 'pickup', + 'Quantity': '1', + }; + + setUp(() { + PickupLocations.seedForTest(const {'7': 'H1 — Peelamedu Hub'}); + RiderHub.resetForTest(7); + }); + + tearDown(() { + ServiceProfile.setActive(ServiceProfile.parcel); + RiderHub.resetForTest(); + PickupLocations.seedForTest(const {}); + }); + + test('the whole logistics run is one node, named by the rider\'s hub', () { + ServiceProfile.setActive(ServiceProfile.parcel); + final groups = TripCard( + trip: Trip.fromStops( + id: 't1', + stops: [drop('1', 'Joe'), drop('2', 'Arun'), drop('3', 'Ravi')], + ), + ).routeGroups(); + + expect(groups, hasLength(1), reason: 'one hub, one node'); + + // ── The hub, not a booking's address ── + // + // A logistics day starts and ends at one building: it assigns the day's + // bookings, hands over the route, and the rider accepts the lot before he + // leaves. Reading the head off the stops titled it with whichever address + // sorted first — right for a drop, which is loaded at the depot, and wrong + // for a collection, which is not. See [RiderHub]. + expect(groups.single.name, 'H1 — Peelamedu Hub'); + expect(groups.single.stops, hasLength(3)); + expect( + groups.single.flat, + isFalse, + reason: 'a place holding orders opens a list; it is not the stop itself', + ); + + // No street under it. The locations table names the hub and gives no + // address, and a booking's road printed there reads as the hub's own. + expect(groups.single.location, isEmpty); + }); + + test('a collection belongs to the hub run too', () { + ServiceProfile.setActive(ServiceProfile.parcel); + final groups = TripCard( + trip: Trip.fromStops( + id: 't1', + stops: [collect('9', 'Sri Balaji Stores')], + ), + ).routeGroups(); + + expect(groups, hasLength(1)); + + // A collection is picked up at the customer's door, but it is still a + // booking the hub handed this rider and it is accepted at the hub with the + // rest. So it sits under the same head, with the customer on its own rung. + expect(groups.single.flat, isFalse); + expect(groups.single.name, 'H1 — Peelamedu Hub'); + expect(groups.single.manifest.single.customer, 'Sri Balaji Stores'); + }); + + test('a mixed route is still one hub run', () { + ServiceProfile.setActive(ServiceProfile.parcel); + final groups = TripCard( + trip: Trip.fromStops( + id: 't1', + stops: [ + drop('1', 'Joe'), + drop('2', 'Arun'), + collect('9', 'Sri Balaji Stores'), + ], + ), + ).routeGroups(); + + // Drops and collections alike: one building assigned them, so one node + // holds them and each rung says which job it is — see the `D`/`P`/`P&D` + // tags in `route_timeline.dart`. + expect(groups, hasLength(1)); + expect(groups.single.name, 'H1 — Peelamedu Hub'); + expect(groups.single.stops, hasLength(3)); + }); + + test('a milk round is unchanged — it grouped on the line, and still does', () { + ServiceProfile.setActive(ServiceProfile.milkMan); + final groups = TripCard( + trip: Trip.fromStops( + id: 't1', + stops: [ + { + 'orderid': '1', + 'pickupid': 'K1', + 'kitchenid': 'K1', + 'kitchenname': 'Vidhya Kitchen', + 'pickupaddress': '12, SNS Colony, Peelamedu', + 'pickupcustomer': 'Joe Mathew', + 'orderstatus': 'pending', + }, + { + 'orderid': '2', + 'pickupid': 'K1', + 'kitchenid': 'K1', + 'kitchenname': 'Vidhya Kitchen', + 'pickupaddress': '12, SNS Colony, Peelamedu', + 'pickupcustomer': 'Arun Prakash', + 'orderstatus': 'pending', + }, + ], + ), + ).routeGroups(); + + expect(groups, hasLength(1)); + expect(groups.single.name, 'Vidhya Kitchen'); + expect(groups.single.stops, hasLength(2)); + }); + + // ── The run-level select-all survives a mixed route ── + // + // `_flatSelectIds` bailed on `groups.every((g) => g.flat)`, which was right + // while a route was one shape or the other. Grouping the drops made a + // logistics route *both*, so the guard removed the only way to take the + // collections in one go — on exactly the routes that have the most of them. + group('taking the collections in one go', () { + Future pump( + WidgetTester tester, + List> stops, { + Set selected = const {}, + }) async { + tester.view.physicalSize = const Size(390 * 3, 900 * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: TripCard( + trip: Trip.fromStops(id: 't1', stops: stops), + selectedIds: selected, + onToggleSelect: (_) {}, + onSelectAll: (_) {}, + ).buildStops(), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('a logistics run has one, and it is the hub\'s', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.parcel); + await pump( + tester, + [ + drop('1', 'Joe'), + drop('2', 'Arun'), + collect('8', 'Sri Balaji Stores'), + collect('9', 'Kumar Traders'), + ], + // The head control only appears once something is ticked — it is a + // selection-mode helper, not a permanent row. + selected: const {'8'}, + ); + + // ── One control, over the whole run ── + // + // The rider accepts the day's bookings at the hub before he leaves it, + // and every booking is under the hub's node — so there is one select-all + // and it covers the lot. The run-level control that sits outside the + // groups is for stops with no place above them to hold one, and a + // logistics run has none. + expect(find.byType(SelectAllAction), findsOneWidget); + expect(find.text('1 of 4 selected'), findsOneWidget); + }); + + testWidgets('a milk round is unchanged — it has no flat groups', ( + tester, + ) async { + ServiceProfile.setActive(ServiceProfile.milkMan); + await pump( + tester, + [ + { + 'orderid': '1', + 'pickupid': 'K1', + 'kitchenid': 'K1', + 'kitchenname': 'Vidhya Kitchen', + 'pickupaddress': '12, SNS Colony, Peelamedu', + 'pickupcustomer': 'Joe Mathew', + 'orderstatus': 'pending', + }, + { + 'orderid': '2', + 'pickupid': 'K1', + 'kitchenid': 'K1', + 'kitchenname': 'Vidhya Kitchen', + 'pickupaddress': '12, SNS Colony, Peelamedu', + 'pickupcustomer': 'Arun Prakash', + 'orderstatus': 'pending', + }, + ], + selected: const {'1'}, + ); + + // Every group is a counter, so there is nothing for a run-level control + // to be over: the select-all belongs to the kitchen and there is exactly + // **one** of it. Before the fix this was also one, and it must stay one — + // the guard that was removed never applied to a route with no flat groups. + expect(find.byType(SelectAllAction), findsOneWidget); + expect(find.text('1 of 2 selected'), findsOneWidget); + }); + }); +} diff --git a/test/lifecycle_boundary_test.dart b/test/lifecycle_boundary_test.dart index 182ae02..7410586 100644 --- a/test/lifecycle_boundary_test.dart +++ b/test/lifecycle_boundary_test.dart @@ -136,11 +136,19 @@ void main() { ); }); - test('a picked parcel booking is closed; a picked milk run is not', () { - // Collecting IS the job on logistics — the hub delivers it. On a round it - // is the middle of the morning. + test('a picked parcel stays in custody until the backend routes it', () { + // ── Was: "a picked parcel booking is closed" ── + // + // That was the line-level assumption `endsAtHub` encoded, and it closed + // hyperlocal parcels the rider was still carrying. Collection is no + // longer an ending on either line: it is the moment the parcel becomes + // physically his, and where it goes next is a per-order answer from the + // server. With nothing on the row to read, it stays his. ServiceProfile.setActive(ServiceProfile.parcel); - expect(WorkBoundary.domainOf(stop('1004', 'picked')), WorkDomain.closed); + expect( + WorkBoundary.domainOf(stop('1004', 'picked')), + WorkDomain.delivery, + ); ServiceProfile.setActive(ServiceProfile.milkMan); expect( diff --git a/test/next_leg_test.dart b/test/next_leg_test.dart new file mode 100644 index 0000000..f8d73c2 --- /dev/null +++ b/test/next_leg_test.dart @@ -0,0 +1,290 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/data/next_leg.dart'; +import 'package:miler/data/service_profile.dart'; +import 'package:miler/data/work_domain.dart'; +import 'package:miler/data/milk_run.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// WHERE A COLLECTED PARCEL GOES NEXT +/// +/// The logistics line used to answer this with a line-level constant — +/// `ServiceProfile.endsAtHub` — and `work_domain.dart` closed every collected +/// parcel because of it. These pin the per-order replacement against the four +/// operational cases it exists for: +/// +/// 1. Hub H1 → customer collected at a hub, delivered by this rider +/// 2. customer → hub collected at a door, handed to the network +/// 3. customer → customer hyperlocal, one rider, no hub +/// 4. customer → another state collected here, handed to the local hub +/// +/// The routing decision is the **backend's** in every one of them. No test here +/// asserts anything about a pincode, a district or a city name, because nothing +/// in the app is allowed to read one for this purpose. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + setUp(() => ServiceProfile.setActive(ServiceProfile.parcel)); + tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); + + /// A queue row as `GET /miler/bookings` delivers it, after the adapter. + Map row( + String id, { + String status = 'picked', + String consignment = '', + String nextAction = '', + String source = '', + String drop = '', + }) => { + 'orderid': id, + 'orderstatus': status, + 'consignmentstatus': consignment, + if (nextAction.isNotEmpty) 'next_action': nextAction, + 'sourcename': source, + 'dropaddress': drop, + }; + + group('the four operational cases', () { + test('CASE 1 — Hub H1 → customer: the rider delivers it himself', () { + // Collected at the hub; the pivot released it into his hands. + final stop = row( + 'H1-001', + consignment: 'Collected_By_Miler', + source: 'Hub H1', + drop: 'Hopes, Coimbatore', + ); + + expect(NextLegResolver.resolve(stop).leg, NextLeg.customer); + expect(WorkBoundary.isClosed(stop), isFalse, + reason: 'a parcel in his hands is not finished work'); + expect(WorkBoundary.domainOf(stop), WorkDomain.delivery); + expect(MilkRun.navigatesToCustomer(stop), isTrue, + reason: 'the receiver is the immediate destination'); + }); + + test('CASE 3 — customer → customer hyperlocal: never closed at pickup', () { + // Gandhipuram → Hopes. The pivot released it in the same call, which is + // what compatibility mode does, so the row comes back Out_for_Delivery. + final stop = row( + 'HYP-1', + consignment: 'Out_for_Delivery', + source: 'Rajesh Kumar', + drop: 'Hopes, Coimbatore', + ); + + expect(NextLegResolver.resolve(stop).leg, NextLeg.customer); + + // ── The bug this whole change exists for ── + // + // `isPicked && endsAtHub` closed this row, and endsAtHub is always true + // on logistics. The rider walked away from Rajesh Kumar's door carrying a + // parcel the app had filed as finished. + expect(WorkBoundary.isClosed(stop), isFalse); + expect(WorkBoundary.domainOf(stop), WorkDomain.delivery); + expect(MilkRun.navigatesToCustomer(stop), isTrue); + }); + + test('CASE 4 — customer → another state: hub leg, no customer delivery', () { + // Gandhipuram → Chennai. The pivot routed it into the network. + final stop = row( + 'ICT-1', + consignment: 'Created', + source: 'Rajesh Kumar', + drop: 'T Nagar, Chennai', + ); + const pivot = {'ICT-1': NextActionWord.inwardAtHub}; + + expect( + NextLegResolver.resolve(stop, pivotAction: NextActionWord.inwardAtHub).leg, + NextLeg.hub, + ); + + // It is in his custody, so it is the work tab's — not closed, not Home's. + expect( + WorkBoundary.isClosed(stop, pivotActions: pivot), + isFalse, + reason: 'a parcel he is carrying must not vanish into Activity', + ); + expect( + WorkBoundary.domainOf(stop, pivotActions: pivot), + WorkDomain.delivery, + ); + + // ── The safety assertion ── + // + // Chennai is 500km away. Nothing may offer it as this rider's next + // navigation target. + expect( + MilkRun.navigatesToCustomer(stop, pivotActions: pivot), + isFalse, + reason: 'a hub-routed parcel must never point at its receiver', + ); + }); + + test('CASE 2 — the hub taking it in is what ends the rider s job', () { + // There is no rider-side handover mutation yet (backend gap). The one way + // a hub-routed parcel legitimately leaves his queue today is hub staff + // inwarding it in their console, which the next poll reports. + final handed = row('ICT-1', consignment: 'Inwarded_at_Hub'); + const pivot = {'ICT-1': NextActionWord.inwardAtHub}; + + expect(NextLegResolver.resolve(handed, pivotAction: NextActionWord.inwardAtHub).leg, + NextLeg.closed); + expect(WorkBoundary.isClosed(handed, pivotActions: pivot), isTrue); + expect( + WorkBoundary.domainOf(handed, pivotActions: pivot), + WorkDomain.closed, + ); + }); + }); + + group('precedence — the server outranks the local record', () { + test('a live consignment state beats a stale pivot instruction', () { + // The pivot said hub. The server has since said the parcel is out for + // delivery in this rider's hands. The server wins: a local record is + // continuity, never authority. + final stop = row('X1', consignment: 'Out_for_Delivery'); + final decision = + NextLegResolver.resolve(stop, pivotAction: NextActionWord.inwardAtHub); + + expect(decision.leg, NextLeg.customer); + expect(decision.source, 'row.consignmentstatus'); + }); + + test('a terminal state beats everything', () { + final stop = row('X2', status: 'delivered', consignment: 'Delivered'); + expect( + NextLegResolver.resolve(stop, pivotAction: NextActionWord.startDelivery).leg, + NextLeg.closed, + ); + }); + + test('live next_action on the row outranks the persisted pivot', () { + // Not sent by the backend today — pinned so the resolver is already + // correct on the day `GET /miler/bookings` carries it. + final stop = row('X3', consignment: 'Created', nextAction: 'start_delivery'); + final decision = + NextLegResolver.resolve(stop, pivotAction: NextActionWord.inwardAtHub); + + expect(decision.leg, NextLeg.customer); + expect(decision.source, 'row.next_action'); + }); + + test('Created alone decides nothing — it is also the transient state', () { + // `Created` is what a hub-routed pivot lands on AND what a consignment + // holds while the pivot is still routing it. Reading it as "hub" would + // strand every freshly-collected hyperlocal parcel. + final stop = row('X4', consignment: 'Created'); + final decision = NextLegResolver.resolve(stop); + + expect(decision.leg, NextLeg.unknown); + expect(decision.source, 'none'); + }); + + test('unknown keeps custody: not closed, and no customer destination', () { + final stop = row('X5', consignment: 'Created'); + + // Never written off — he is holding it. + expect(WorkBoundary.isClosed(stop), isFalse); + expect(WorkBoundary.domainOf(stop), WorkDomain.delivery); + // Never pointed anywhere — the server has not said where it goes. + expect(MilkRun.navigatesToCustomer(stop), isFalse); + }); + }); + + group('the backend outranks the ServiceProfile default', () { + test('logistics stays endsAtHub, and it no longer closes an order', () { + // The capability is untouched — it still describes the line. + expect(ServiceProfile.parcel.endsAtHub, isTrue); + expect(ServiceProfile.parcel.deliversToCustomer, isFalse); + + // And it no longer decides one order's fate. + final hyperlocal = row('P1', consignment: 'Collected_By_Miler'); + expect(WorkBoundary.isClosed(hyperlocal), isFalse); + expect(MilkRun.navigatesToCustomer(hyperlocal), isTrue); + }); + + test('a milk run is unchanged by any of this', () { + ServiceProfile.setActive(ServiceProfile.milkMan); + + // A round's rows often carry no consignment state at all; the line's own + // shape still answers for them. + final collected = row('M1', status: 'picked'); + expect(WorkBoundary.isClosed(collected), isFalse); + expect( + MilkRun.navigatesToCustomer(collected, collectedIds: const {'M1'}), + isTrue, + ); + }); + }); + + group('the leg decides the words and the controls', () { + test('only a customer leg may be released into a delivery round', () { + // The gate that keeps a Chennai consignment out of `Out_for_Delivery`. + expect(NextLeg.customer.allowsCustomerDelivery, isTrue); + expect(NextLeg.hub.allowsCustomerDelivery, isFalse); + expect(NextLeg.unknown.allowsCustomerDelivery, isFalse); + expect(NextLeg.closed.allowsCustomerDelivery, isFalse); + }); + + test('a handover and a delivery are never worded the same', () { + // Activity has to be able to tell "handed across a counter" from "given + // to the person it belongs to". One verb for both is a false record. + expect( + NextLeg.hub.completionVerb, + isNot(equals(NextLeg.customer.completionVerb)), + ); + expect(NextLeg.customer.completionVerb, contains('customer')); + }); + + test('no leg vocabulary says "hub" to a rider', () { + // The house rule, enforced for every rider-facing string by + // `no_hub_in_rider_copy_test.dart`. Pinned here too because this is where + // the words are chosen: "base" for the place, "your office" for people. + for (final leg in NextLeg.values) { + expect(leg.eyebrow.toLowerCase(), isNot(contains('hub'))); + expect(leg.completionVerb.toLowerCase(), isNot(contains('hub'))); + } + }); + + test('a closed order has no eyebrow to draw', () { + expect(NextLeg.closed.eyebrow, isEmpty); + }); + }); + + group('one owner per order', () { + test('a picked parcel is never on Home and Deliveries at once', () { + final day = [ + row('A', consignment: 'Out_for_Delivery'), // hyperlocal, customer leg + row('B', consignment: 'Created'), // hub-routed + row('C', status: 'assigned', consignment: ''), // not collected + ]; + const pivot = {'B': NextActionWord.inwardAtHub}; + + final home = WorkBoundary.pickupQueue(day, pivotActions: pivot); + final deliveries = WorkBoundary.deliveryQueue(day, pivotActions: pivot); + + final homeIds = home.map(MilkRun.idOf).toSet(); + final deliveryIds = deliveries.map(MilkRun.idOf).toSet(); + + expect(homeIds.intersection(deliveryIds), isEmpty, + reason: 'dual ownership is the failure this boundary exists to stop'); + expect(deliveryIds, containsAll({'A', 'B'})); + expect(homeIds, contains('C')); + }); + + test('restart and poll preserve the leg', () { + // Same row, read again on a later poll with nothing but the persisted + // pivot to go on — which is exactly the cold-start case. + final stop = row('B', consignment: 'Created'); + const pivot = {'B': NextActionWord.inwardAtHub}; + + for (var i = 0; i < 3; i++) { + expect( + WorkBoundary.domainOf(stop, pivotActions: pivot), + WorkDomain.delivery, + ); + expect(MilkRun.navigatesToCustomer(stop, pivotActions: pivot), isFalse); + } + }); + }); +} diff --git a/test/no_hub_in_rider_copy_test.dart b/test/no_hub_in_rider_copy_test.dart index 8910a4c..a061772 100644 --- a/test/no_hub_in_rider_copy_test.dart +++ b/test/no_hub_in_rider_copy_test.dart @@ -35,13 +35,37 @@ import 'package:flutter_test/flutter_test.dart'; /// ───────────────────────────────────────────────────────────────────────── void main() { test('no rider-facing string says "hub"', () { - // A comment, or the backend's own wire spelling. + // A comment. 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'); + return l.startsWith('//') || l.startsWith('///') || l.startsWith('*'); + } + + /// True when a quoted literal is the backend's vocabulary rather than a + /// sentence somebody reads. + /// + /// ── Why a shape and not a list ── + /// + /// This was a list of two spellings — `inwarded_at_hub` and its capitalised + /// twin — and the base-handover work immediately outgrew it: a route path + /// (`/miler/consignments/:id/inward-at-hub`), a mutation-guard key and an + /// idempotency verb all carry the word and none of them can reach a screen. + /// Adding four more strings would have meant adding a fifth next time. + /// + /// The shape is the real rule. **Machine text has a separator and no + /// spaces**: `inward_at_hub`, `inward-at-hub`, and any path containing one. + /// **Copy is prose** — it has spaces in it, because it is a sentence. That + /// distinction is what this test has always actually been about, and it now + /// says so directly instead of enumerating exceptions. + /// + /// A bare word with neither a separator nor a space stays a violation, so + /// `Text('Hub')` is still caught. Anything parsing that value compares + /// against the enum's own `name` instead of respelling it — see + /// [PickupSource.of]. + bool isWireToken(String literal) { + final t = literal.trim(); + if (t.isEmpty || t.contains(' ')) return false; + return t.contains('_') || t.contains('-') || t.contains('/'); } /// True while the scan is inside a `debugPrint(...)` / `logGap(...)` call. @@ -90,6 +114,7 @@ void main() { if (exemptLine(lines[i])) continue; for (final m in quoted.allMatches(lines[i])) { final literal = m.group(2) ?? ''; + if (isWireToken(literal)) continue; if (word.hasMatch(literal)) { offenders.add('${e.path}:${i + 1} "$literal"'); } diff --git a/test/one_counter_one_dropdown_test.dart b/test/one_counter_one_dropdown_test.dart index 6b7e80b..b084a34 100644 --- a/test/one_counter_one_dropdown_test.dart +++ b/test/one_counter_one_dropdown_test.dart @@ -177,10 +177,33 @@ void main() { expect(group.flat, isTrue); }); - test('a parcel route is always flat, whatever the address says', () { - // Every stop is its own group there — the rider really does ride to each - // customer for one consignment, and a header plus a single child would - // print the same name twice. + test('a parcel COLLECTION is a place, headed by its address', () { + // ── The parcel line draws places now, like the milk run ── + // + // Home used to give logistics a flat node per stop, titled with the + // customer. It is one shape on both lines now: the header names *where* + // the work is and the rail under it names *who* — asked for directly, + // against screenshots of the two screens side by side. + // + // Three collections at one address are one place holding three orders — + // which is the case the old shape got most wrong, drawing the same street + // three times. + ServiceProfile.setActive(ServiceProfile.parcel); + final groups = TripCard( + trip: Trip.fromStops( + id: 'T1', + stops: [ + for (var i = 1; i <= 3; i++) {...order(i), 'type': 'pickup'}, + ], + ), + ).routeGroups(); + + expect(groups.length, 1); + expect(groups.single.flat, isFalse); + expect(groups.single.manifest.length, 3); + }); + + test('but its deliveries gather under the hub they were loaded at', () { ServiceProfile.setActive(ServiceProfile.parcel); final groups = TripCard( trip: Trip.fromStops( @@ -189,8 +212,9 @@ void main() { ), ).routeGroups(); - expect(groups.length, 3); - expect(groups.every((g) => g.flat), isTrue); + expect(groups.length, 1, reason: 'one shelf, one node'); + expect(groups.single.flat, isFalse); + expect(groups.single.manifest.length, 3); }); }); diff --git a/test/pickup_delivery_boundary_test.dart b/test/pickup_delivery_boundary_test.dart index bb00a4b..865f341 100644 --- a/test/pickup_delivery_boundary_test.dart +++ b/test/pickup_delivery_boundary_test.dart @@ -254,14 +254,34 @@ void main() { ); }); - test('collected ends the job on a line that ends at the hub', () { - // Terminal here, unlike on the milk run: the parcel becomes a consignment - // and the hub carries it from there. - expect(WorkBoundary.isClosed(order('P1', status: 'picked')), isTrue); + test('collected no longer ends the job by line default', () { + // ── This assertion used to be the opposite, and it was the bug ── + // + // `isClosed` read `isPicked && ServiceProfile.active.endsAtHub`, which is + // always true on logistics, so **every** collected parcel was written off + // at the pickup — including a hyperlocal one the pivot had just released + // into this rider's own hands. He left the sender's door carrying a + // parcel his app had already filed as finished. + // + // Where a collected parcel goes next is the backend's decision, per + // order, and this row carries no answer yet: no consignment state and no + // recorded pivot instruction. So it resolves to [NextLeg.unknown], which + // keeps custody rather than guessing — the parcel is in his hands, and + // the honest screen is the work tab, not Activity. See [NextLegResolver]. + expect(WorkBoundary.isClosed(order('P1', status: 'picked')), isFalse); expect( WorkBoundary.domainOf(order('P1', status: 'picked')), - WorkDomain.closed, + WorkDomain.delivery, ); }); + + test('the backend closing it is what closes it', () { + // `Inwarded_at_Hub` is the hub taking the parcel into its own custody. + // That is a real ending, and it comes from the server. + final handed = order('P1', status: 'picked') + ..['consignmentstatus'] = 'Inwarded_at_Hub'; + expect(WorkBoundary.isClosed(handed), isTrue); + expect(WorkBoundary.domainOf(handed), WorkDomain.closed); + }); }); } diff --git a/test/stop_card_neutral_test.dart b/test/stop_card_neutral_test.dart index f976673..52dad21 100644 --- a/test/stop_card_neutral_test.dart +++ b/test/stop_card_neutral_test.dart @@ -246,20 +246,31 @@ void main() { testWidgets('a step states who it is for, and no stop type', (tester) async { await pumpCard(tester); - // This is a parcel route, so each stop is its own node and what names it is - // the customer plus its position in the run. (The `who` + `which bag` pair - // is the kitchen-group shape and is held by `one_bag_per_order_test.dart`.) + // ── The parcel route draws places now ── + // + // This read "each stop is its own node, named by the customer plus its + // position in the run". Home is one shape on both lines now: the header + // names *where* and the row under it names *who*, so the customer is on the + // rung and `Stop 1` — the flat node's meta line — is gone with the flat + // node. See `hub_pickup_group_test.dart`. expect(find.text('Sri Balaji Stores'), findsWidgets); - expect(find.text('Stop 1'), findsOneWidget); + expect(find.text('Stop 1'), findsNothing); // Everything the card used to shout on every row. The type is one tap away // in the sheet, on the badge `StopDetailSheet._header` draws. - for (final label in const [ - 'PICKUP', - 'DELIVERY', - 'P&D', - 'PICKUP & DELIVERY', - ]) { + // ── `P&D` came off this list ── + // + // The rule these labels enforce is that the route does not *shout* a + // stop's type: `PICKUP & DELIVERY` set on every rung is a word the rider + // re-reads forty times a shift, and the accents that came with it were a + // second colour system competing with the brand. + // + // A mixed route still has to answer "am I collecting or dropping here" + // without opening anything, so the rail now carries a one- or two-letter + // grey tag — see `_KindTag`. It is a mark rather than a word, and it is + // painted in the same neutral as the metric tiles, so both halves of the + // rule above still hold. The shouted forms stay banned. + for (final label in const ['PICKUP', 'DELIVERY', 'PICKUP & DELIVERY']) { expect( find.text(label), findsNothing, diff --git a/test/stop_row_alignment_test.dart b/test/stop_row_alignment_test.dart index 51d8d0c..fbbf367 100644 --- a/test/stop_row_alignment_test.dart +++ b/test/stop_row_alignment_test.dart @@ -60,7 +60,19 @@ void main() { /// 2 pending, no phone → the same, with nothing to call /// 3 rejected + phone → no tick; it is already decided /// 4 skipped, no phone → not listed at all - Future pumpRoute(WidgetTester tester, {String? kitchen}) async { + /// [uniform] forces every stop to one kind, for the tests that are about a + /// row's **state** rather than its shape. + /// + /// A mixed route no longer draws one shape: drops gather under the hub they + /// were loaded at and collections stay their own doors, so a rung and a node + /// legitimately sit at different indents. See `hub_pickup_group_test.dart`. + /// Comparing across the two measures the grouping, not the thing under test. + Future pumpRoute( + WidgetTester tester, { + String? kitchen, + String? uniform, + bool secondAddress = false, + }) async { tester.view.physicalSize = const Size(1170, 2532); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.resetPhysicalSize); @@ -69,11 +81,28 @@ void main() { final trip = Trip.fromStops( id: 'mixed', stops: [ - stop('A', type: 'pickup', phone: '9876543210', kitchen: kitchen), - stop('B', type: 'delivery', kitchen: kitchen), - stop('C', type: 'combined', phone: '9876500000', kitchen: kitchen), + stop( + 'A', + type: uniform ?? 'pickup', + phone: '9876543210', + kitchen: kitchen, + ), { - ...stop('D', type: 'pickup', kitchen: kitchen), + ...stop('B', type: uniform ?? 'delivery', kitchen: kitchen), + if (secondAddress) ...{ + 'pickupaddress': '4, Avinashi Rd, Coimbatore', + 'kitchenid': 'K2', + 'kitchenname': 'Second Counter', + }, + }, + stop( + 'C', + type: uniform ?? 'combined', + phone: '9876500000', + kitchen: kitchen, + ), + { + ...stop('D', type: uniform ?? 'pickup', kitchen: kitchen), 'orderstatus': 'skipped', }, ], @@ -102,10 +131,17 @@ void main() { } testWidgets('every node sits on one vertical axis', (tester) async { - await pumpRoute(tester); + ServiceProfile.setActive(ServiceProfile.milkMan); + await pumpRoute(tester, kitchen: 'K1', secondAddress: true); + // ── A logistics run is one node, so the axes are compared on a milk round ── + // + // Home groups the parcel line's whole day under the rider's base now, which + // is the point of the change but leaves a single node with nothing to + // compare against. A kitchen line still draws one node per counter, which + // is the shape this test was written for. final nodes = find.byType(PickupTimelineGroup); - expect(nodes.evaluate().length, greaterThan(1), reason: 'a mixed route'); + expect(nodes.evaluate().length, greaterThan(1), reason: 'two counters'); // The spine is a fixed-width rail with the node centred in it, so this // holds by construction — which is exactly why it is worth pinning. The @@ -123,7 +159,9 @@ void main() { testWidgets('the rail does not move when a row changes state', ( tester, ) async { - await pumpRoute(tester); + // One kind throughout: this is about **state**, and a mixed route now draws + // two shapes on purpose — see [pumpRoute]'s `uniform`. + await pumpRoute(tester, uniform: 'pickup'); // Two of the four are listed now. The skipped one has always been // Activity's row; the **rejected** one joined it — a declined stop leaves diff --git a/test/stop_verify_chrome_test.dart b/test/stop_verify_chrome_test.dart index b6dc5b8..41b992c 100644 --- a/test/stop_verify_chrome_test.dart +++ b/test/stop_verify_chrome_test.dart @@ -102,7 +102,10 @@ void main() { // rather than in the grey reserved for metadata. final label = tester.widget(find.text('Who received it?')); expect(label.style!.color, ColorConstants.slateText); - expect(label.style!.fontWeight, FontWeight.w700); + // One grade heavier than this once read: the app was set in Poppins on a + // scale stepped down to compensate for that face, and is back on Manrope + // with the scale restored. See `FontConstants`. + expect(label.style!.fontWeight, FontWeight.w800); // What has not changed, and must not: it is still quieter than the stop it // belongs to. The customer's name is the subject of the screen; a step @@ -125,16 +128,27 @@ void main() { } }); - testWidgets('an odd picker gives its last option the full row', ( + testWidgets('an odd picker keeps its last option in its column', ( tester, ) async { await pump(tester, stopOf(type: 'pickup', pickupQty: 2)); - // Three conditions. The third at half width on a line of its own is an - // orphan; at full width it is a second row. + // ── This used to assert the opposite, and it fought the test above ── + // + // The rule was that a third option at half width is an orphan, so it took + // the full row. But the picker two tests up exists to make every option the + // *same* width — natural-width chips packing three-then-one "reads as a + // list that ran out rather than as a set of four equal choices" — and a + // stretched third option is that same inequality, introduced deliberately. + // + // Size is emphasis. Stretching the odd one put the largest target on the + // screen under `Damaged`: the rarest condition and the one with the worst + // consequence, drawn twice the size of `Sealed & intact`. This set has no + // reason to emphasise its exception, and a grid with an empty cell in its + // last row is simply what a grid looks like. expect( chipWidth(tester, 'Damaged'), - greaterThan(chipWidth(tester, 'Sealed & intact') * 1.8), + closeTo(chipWidth(tester, 'Sealed & intact'), 0.5), ); }); diff --git a/test/support/golden_fonts.dart b/test/support/golden_fonts.dart index d7cf001..5c68aef 100644 --- a/test/support/golden_fonts.dart +++ b/test/support/golden_fonts.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; @@ -21,23 +20,19 @@ import 'package:miler/views/helpers/constants/Font_constant.dart'; /// binding does not resolve at all, and every icon on Activity came out as a /// hollow square. One loader, so the next family added to the app is added /// here rather than in five harnesses that will drift apart. +/// +/// ── Load the family the app actually reads ── +/// +/// This registers `FontConstants.fontFamily`, so it must be fed that family's +/// files. When the app moved off Poppins and back to Manrope, the loader still +/// pointed at `assets/fonts/Poppins/*` — which does not fail, it registers the +/// wrong glyphs under the right name and every reference shot becomes a +/// picture of a typeface the app does not ship. Manrope is a single variable +/// font, so one file covers the whole weight axis. Future loadGoldenFonts() async { - final poppins = FontLoader(FontConstants.fontFamily); - for (final e in const <(String, FontWeight)>[ - ('Light', FontWeight.w300), - ('Regular', FontWeight.w400), - ('Medium', FontWeight.w500), - ('SemiBold', FontWeight.w600), - ('Bold', FontWeight.w700), - ('ExtraBold', FontWeight.w800), - ('Black', FontWeight.w900), - ]) { - final f = File('assets/fonts/Poppins/Poppins-${e.$1}.ttf'); - if (f.existsSync()) { - poppins.addFont(f.readAsBytes().then((b) => ByteData.view(b.buffer))); - } - } - await poppins.load(); + await _loadIfPresent(FontConstants.fontFamily, const [ + 'assets/fonts/Manrope/Manrope-VF.ttf', + ]); await _loadIfPresent('MaterialIcons', const [ '/opt/homebrew/share/flutter/bin/cache/artifacts/material_fonts/' diff --git a/test/trip_tabs_pinned_test.dart b/test/trip_tabs_pinned_test.dart index 2e6d9d7..b4af1a3 100644 --- a/test/trip_tabs_pinned_test.dart +++ b/test/trip_tabs_pinned_test.dart @@ -175,4 +175,30 @@ void main() { await tester.tap(find.text('Trip 2')); expect(chosen, 1); }); + + // ── What the panel holds when a slot is empty ── + // + // Nothing, as far as this control is concerned. [TripSelector] draws the tab + // strip and whatever body it is handed; Home hands it the selected trip's + // brief, and for a slot the hub has not filled that is the *same* brief built + // from an empty trip — `0 stops left` and an em dash under every figure. See + // `trip_unassigned_brief_test.dart`. + // + // So there is no "empty" state in here to test, and nothing in this control + // says `Not assigned yet` any more: that line made the panel a stub under a + // tab twice its height, and repeated a sentence the empty state below already + // carried. + group('with no body', () { + testWidgets('the control is just the tab strip', (tester) async { + await pump(tester, trips: 1, selected: 2); + + expect(find.text('Not assigned yet'), findsNothing); + expect( + tester.getSize(find.byType(TripSelector)).height, + TripSelector.height, + ); + expect(tester.takeException(), isNull); + }); + }); } + diff --git a/test/trip_unassigned_brief_test.dart b/test/trip_unassigned_brief_test.dart new file mode 100644 index 0000000..b02e9c7 --- /dev/null +++ b/test/trip_unassigned_brief_test.dart @@ -0,0 +1,109 @@ +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/trip.dart'; +import 'package:miler/views/Dashboard/home/trip_brief_strip.dart'; +import 'package:miler/views/Dashboard/home/trip_card.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// AN UNFILLED SLOT LOOKS LIKE A FILLED ONE, AT ZERO +/// +/// Home's trip panel used to change shape depending on whether the hub had got +/// to the slot yet: a filled slot got the brief, an unfilled one got a single +/// line reading `Not assigned yet`, which made the panel a 32pt stub under a +/// tab twice its height — and repeated a sentence the illustration below it +/// already carried. +/// +/// The brief of an empty slot is a real brief. Every figure it draws is derived +/// from the stops, so an empty list gives `0 stops left`, an empty progress +/// track and an em dash under everything with nothing to report — with no +/// second "empty" layout to keep in step with the real one. +/// +/// What this holds is the property that makes it worth doing: the panel is the +/// **same height** either way, so when the hub assigns the trip the figures +/// fill in where the dashes were instead of the page changing shape under the +/// rider's thumb. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + Trip filled() => Trip.fromStops( + id: 't1', + stops: [ + for (var i = 0; i < 3; i++) + { + 'orderid': 'S$i', + 'pickupid': 'S$i', + 'pickupcustomer': 'Sri Balaji Stores', + 'pickupaddress': '19, Thadagam Road, Coimbatore', + 'orderstatus': 'pending', + 'Quantity': '2', + 'collectionamt': '450', + }, + ], + ); + + /// What `homepage.dart` builds for a slot the hub has not filled. + Trip unassigned() => Trip.fromStops(id: 'unassigned-1', stops: const []); + + Future pump(WidgetTester tester, Trip trip) async { + tester.view.physicalSize = const Size(390 * 3, 844 * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: TripCard(trip: trip).buildSummary(onBrand: true), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('an unfilled slot reads zero, not empty', (tester) async { + await pump(tester, unassigned()); + + expect(find.text('0 stops left'), findsOneWidget); + + // The count is a real zero; the three figures that have nothing to report + // print the em dash every other figure in the app prints, because `None` + // set in figure type reads as data. + expect(find.text('0'), findsOneWidget); + expect(find.text('—'), findsNWidgets(3)); + + // And it is still the brief, not a special case: the same four labels. + for (final label in const [ + 'Duration', + 'Distance', + 'Parcels', + 'Payment', + ]) { + expect(find.text(label), findsOneWidget); + } + expect(tester.takeException(), isNull); + }); + + testWidgets('and it is the same height as a filled one', (tester) async { + // `buildSummary` returns the strip itself — [TripCard] is a builder, and + // never enters the tree. + await pump(tester, unassigned()); + final empty = tester.getSize(find.byType(TripBriefStrip)).height; + + await pump(tester, filled()); + final full = tester.getSize(find.byType(TripBriefStrip)).height; + + expect( + empty, + closeTo(full, 0.5), + reason: + 'the panel must not change shape when the hub assigns the trip — the ' + 'figures fill in where the dashes were, and nothing below it moves', + ); + }); +} diff --git a/test/two_lines_test.dart b/test/two_lines_test.dart index fd22125..3c6240e 100644 --- a/test/two_lines_test.dart +++ b/test/two_lines_test.dart @@ -189,11 +189,23 @@ void main() { expect(find.text('START · KITCHEN'), findsNothing); }); - testWidgets('gets no kitchen sections and no meal chrome', (tester) async { - // Nothing from the meal run may leak onto a parcel route. The *source - // pill* is data-driven and stays if a parcel payload ever names one — - // see [StopSourceLine] — but the grouping, its counts and its bulk - // collect are meal-flow structure and must not appear. + testWidgets('gets no meal VOCABULARY, though it now shares the shape', ( + tester, + ) async { + // ── What changed, and what did not ── + // + // This used to assert that a parcel route got no grouping at all: no + // place header, no counts under it, the header *is* the stop. That was + // the rule, and it has been reversed deliberately — Home draws places on + // both lines now, because the header names *where* and the row under it + // names *who*, and a collection needs both. Asked for directly, against + // screenshots of the two screens side by side. + // + // What must still never leak is the meal run's **words**. Shape is a + // layout decision and the two lines can share one; `meal`, `MEAL` and + // "ready to collect" are the other line's vocabulary for the other line's + // work, and a logistics rider must never be shown them. That is the half + // of this test that was always the point, and it is untouched. await pumpRoute( tester, stops: [stop(id: '1', kitchen: 'Kitchen 1')], @@ -201,15 +213,11 @@ void main() { expect(find.text('1 meal · 1 to accept'), findsNothing); expect(find.textContaining('ready to collect'), findsNothing); expect(find.text('MEAL'), findsNothing); - // The stop is drawn — it is just drawn flat. A parcel route has no - // counter to group under, so the timeline is one node per stop and the - // header *is* the stop. `PICKUP` was the card's type mark; the type is - // progressive disclosure in the detail sheet now. + + // The place is headed by what the payload named it, and the customer is + // the row under it — the milk run's shape, in the parcel line's words. + expect(find.text('Kitchen 1'), findsOneWidget); expect(find.text('Customer 1'), findsOneWidget); - expect(find.text('Kitchen 1'), findsNothing); - // "Collect 1" on the meta line is the parcel card's own count of what he - // is picking up at this door, and stays. The heading assertions above are - // what tell the two apart. }); test('still works bookings', () { diff --git a/web/favicon.png b/web/favicon.png index c82bab9..842a5bc 100644 Binary files a/web/favicon.png and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png index 7c0a9b8..c233f5d 100644 Binary files a/web/icons/Icon-192.png and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png index 3511a9e..7d3b2e0 100644 Binary files a/web/icons/Icon-512.png and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png index 7c0a9b8..c233f5d 100644 Binary files a/web/icons/Icon-maskable-192.png and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png index 3511a9e..7d3b2e0 100644 Binary files a/web/icons/Icon-maskable-512.png and b/web/icons/Icon-maskable-512.png differ