miler map

This commit is contained in:
2026-09-09 12:55:23 +05:30
parent 074cc0eccf
commit 127fa062ed
143 changed files with 4315 additions and 2291 deletions

View File

@@ -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<T>(
context,
builder: (ctx) => MilerSheetScaffold(child: …),
large: false, // slower entrance for sheets covering most of the screen
isDismissible: true,
enableDrag: true,
);
```
It fixes four things so no caller can get them wrong:
| Setting | Value | Why |
|---|---|---|
| `backgroundColor` | `transparent` | The scaffold paints the glass. An opaque route background shows as a square white corner behind the rounded one. |
| `isScrollControlled` | `true`, always | A sheet that cannot grow past half height is a sheet whose text field the keyboard covers. |
| `sheetAnimationStyle` | `kMilerSheetStyle` / `kMilerLargeSheetStyle` | One entrance curve, two durations. |
| Shape | Owned by `milerGlassSheet` | No caller re-declares a radius. |
### What this replaced
An audit found **six** presentation styles for one kind of surface: frosted
glass at radius 28; opaque `pureSurface` at 24; default white at 16 with **no**
`isScrollControlled` (so the keyboard covered the reason field); an ad-hoc
`Container` with its own shadow and a 50pt top margin; `Get.bottomSheet` flat at
16; and a floating card with margins. Five of them drew their own drag handle —
four at 40×4 in `borderSubtle`, which on frosted glass is close to invisible —
and each applied `SafeArea` its own way.
A rider crossing one shift met most of these. That is what makes an app feel
assembled rather than designed: the same gesture arriving on different-looking
surfaces.
---
## 2. The structure inside
**`MilerSheetScaffold`** owns the fabric, the handle, the content padding and
the bottom inset. A sheet author writes content and nothing else.
```
┌───────────────────────────────────┐
│ ▬▬▬▬ │ MilerSheetHandle — 40×4, borderStrong
│ │ 10 top / 14 bottom margin
│ ◐ Title │ MilerSheetHeader — 18sp/w700
│ One supporting line. │ subtitle 13.5sp/w600, badge tinted 21sp
│ │
│ ───────────────────────────── │ content — the sheet's own
│ │
│ [ Primary action ] │
└───────────────────────────────────┘
20dp gutter, matching the page beneath
```
### The inset rule — one line, and it has bitten this app before
```dart
bottomInset = max(viewInsets.bottom, viewPadding.bottom)
```
The keyboard **replaces** the gesture bar; whichever is up pays the inset.
**Summing them is the double-SafeArea bug** — it floats the CTA a gesture bar's
height above the keyboard. Never add a `SafeArea` inside a sheet.
The padding is animated (`DesignConstants.motionState`, `easeOutCubic`) because
a sheet that jumps its full keyboard height in one frame reads as a glitch
rather than a response.
### The handle
`borderStrong`, **not** `borderSubtle`. At 7% glass over a dimmed page, subtle
grey measures under 1.15:1 against its own ground — so the sheets most worth
flicking away looked least like they could be.
Hide it (`handle: false`) only for a sheet that must not be casually dismissed.
### The header badge
Optional, **tinted, never filled** — a filled disc at this size outweighs the
title it introduces. Pass the accent the subject already wears elsewhere (duty
green, brand red, a stop-kind accent) so the sheet is visibly about the thing
that opened it.
### `MilerSheetChoiceRow`
The standard row for a list of answers: label, optional icon, accent, selected
state, optional rule beneath. Label 14.5sp. Used wherever a sheet asks the rider
to pick one of several things.
---
## 3. The sheets
### Home
#### Duty — `duty_sheet.dart`
**Opens:** tapping the duty control in the app bar.
**Asks:** `Go on duty?` / `Go off duty?`
**Body:** one sentence of consequence — on duty, the hub can add you to the next
slot and live tracking runs; off duty, the hub stops assigning and tracking
pauses, and any booking you are holding has to be finished first.
**Commits with:** a button.
> **Why not a slide.** This had accumulated a handle, an icon disc, a title, a
> subtitle, a *drawn diagram of the switch moving*, three icon-and-text
> consequence rows and a slide-to-confirm — eight blocks for a question with two
> answers. The slide was borrowed from the payment flows, and this app's rule is
> that **slides are reserved for money and for custody**. Ending a shift is
> neither.
#### Stop detail — `stop_detail_sheet.dart`
**Opens:** tapping a stop on the trip card, or a queue row on Deliveries.
**Type:** `DraggableScrollableSheet` — `0.78` initial, `0.5` min, `0.95` max.
**Answers:** *where actually is this* (a small non-interactive map with the hub,
the stop and the leg between them) and *what exactly is here* (full address,
contact, parcel counts, cash, notes, OTP).
**Layout:** number chip + customer + status → map → `620 m away · 1 min ride` →
`DELIVER TO` + address → `WHAT TO DO HERE` + the task and its requirement →
a two-up **fact grid** (From / Bag / Order / Contact) on one white surface cut
by hairlines → Call and Navigate.
> The fact grid was a `label ── value` table with a fixed 110pt label column. The
> values — the half a rider needs — sat in a ragged right column and a long one
> wrapped under a wide empty label, putting a hole in the middle of the block.
> Stacked two to a row, the whole set is scanned in a couple of saccades.
This sheet is **why the card above it can afford to truncate**. The list is for
scanning; everything it drops lives here, one tap away.
#### Pickup preview — `pickup_preview_sheet.dart`
**Opens:** the `i` / details control on a kitchen, and the kitchen card's own tap.
**Answers:** *where am I going, and what am I collecting there* — the leg drawn
from where the rider is standing, plus the load waiting at the counter.
**Primary action:** `Start navigation` — and **only then** does Miler hand off to
Google Maps.
> Handing off means the rider leaves Miler: his route, bag counts and manifest
> all go behind another app. Committing to that on the strength of a kitchen's
> name and a straight-line distance is one tap too few.
#### Rung sheet — `stop_action_sheet.dart`
**Opens:** advancing a stop from Home.
**Asks:** `Arrived at Vidhya Kitchen` / the collection equivalent, with the count
of orders at that counter and the **named manifest**.
**Commits with:** `Slide to confirm arrival` / `Slide to confirm pickup`.
> Arriving and collecting are claims about the physical world — *I am at this
> counter*, *this bag is in my box*. They are written to the hub, they move food
> and money, and a stray thumb on a moving bike must not be able to make them.
#### Reject reasons — `homepage.dart`
**Opens:** rejecting one or more assigned stops.
**Asks:** `Why are you rejecting this stop?` (or `…these N stops?`)
**Subtitle:** *The hub needs a reason to re-route it to someone else.*
**Body:** a `MilerSheetChoiceRow` list — too far from my route; shop/customer
closed today; parcel too large for my box; not enough time left in my shift;
address looks wrong.
#### Turn on location — `homepage.dart`
**Opens:** when a permission or a fix is missing.
**Asks:** `Turn on location`
**Subtitle:** *Bookings and live tracking need your position. Nothing is assigned
to a rider the hub cannot see.*
#### Product details — `homepage.dart`
**Opens:** from an order row.
**Title:** `Product details`, with an inventory badge.
**Body:** a plain ruled list of whatever the payload actually carries. An empty
payload is **one quiet sentence**, not a padded red box.
---
### Deliveries and the live map
#### Live stop sheet — `map.dart`
**Type:** `DraggableScrollableSheet`, and the **only** persistent (non-modal)
sheet in the app. Three snap points, no more:
| Name | Extent | Purpose |
|---|---|---|
| Glance | `0.35` | Map-dominant; ETA still readable |
| **Working** | **`0.45`** | Default. ETA, identity, address and the action, without scrolling |
| Detail | `0.75` | Everything, map still legible |
**Hierarchy — how long → where → who → what action:**
```
▬▬▬▬
6 min ← the dominant operational figure
1.8 km away · arrive ~10:42
───────────────────────── ← one hairline, the sheet's only rule
Joe Mathew ☎ ← identity + Call
12 SNS Colony, Peelamedu
Bag 8
[ Navigate ] [ I've arrived ] pickup leg
⟶ Slide to finish stop delivery leg
```
One continuous surface. **No cards inside it** — the hierarchy is typography,
whitespace and that single hairline.
> The camera's bottom padding is derived from `_sheetWorking` and the real safe
> areas, not from a remembered pixel value. A hardcoded `300.h` agreed with 45%
> only on the device it was measured on: on a 320×568 phone it framed the route
> into a sliver, and at large text the stop pin ended up under the sheet.
**Call** uses `StopContact.forLeg()` — the kitchen's number on a pickup leg, the
customer's on a delivery leg, and a label that says which. It is omitted
entirely when there is no number rather than offered dead.
#### How did this stop end? — `map.dart`
**Opens:** *only* after the rider completes the slide on the live sheet. **The
slide itself commits nothing.**
**Title:** `How did this stop end?`
**Subtitle:** *This is recorded with the office straight away.*
**Choices — deliberately unequal:**
| Choice | Weight |
|---|---|
| **Delivered** | Filled primary — the forward/success path |
| Skipped | Warning/amber — an exception |
| Cancelled | Destructive |
The slider carries `Semantics(button, label: 'Finish this stop', hint: 'Slide,
or double tap, to choose how the stop ended', onTap: …)` — a drag-only control
is unusable with a screen reader, and with gloves on a cold morning. The
accessible path opens the **same** question, so deliberateness is preserved.
#### Skip — `skip_sheet.dart`
**Opens:** choosing Skip.
**Body:** the reason list — customer unreachable; customer not at the location;
wrong address; access problem; customer refused; delivery paused; quantity
issue; other. Renders through `MilerSheetChoiceRow`.
**Guard:** at the limit it shows `Skip Limit Reached` — *You have exceeded the
limit of 2 skips within 3 hours.*
#### Stop brief — `sheet.dart` (`_StopBrief`)
Not a sheet itself: the shared **identity block** used by the map sheet, Update
Status and Skip, so all three name the stop the same way. Before it, the skip
sheet asked "Skip this pickup" without ever saying *which* pickup.
---
### Bookings / orders
#### Reject booking — `orderstaus_button.dart`
**Title:** `Reject booking` · **Subtitle:** *The hub re-routes it once it knows why.*
#### Cancel this booking? — `orderstaus_button.dart`
**Title:** `Cancel this booking?` · **Subtitle:** *It returns to pending and the
hub reassigns it.*
#### Payment confirm — `collect_payment.dart`
A confirm sheet before money is taken. **Slide**, per the money rule.
---
## 4. Rules for a new sheet
1. **Open it with `showMilerSheet`.** Never configure `showModalBottomSheet`
yourself.
2. **Wrap the content in `MilerSheetScaffold`.** It is the handle, the gutter and
the inset.
3. **No `SafeArea` inside a sheet.** The scaffold already paid the inset. Adding
one is the double-inset bug.
4. **No card inside a sheet.** The sheet *is* the surface. A bordered white panel
on frosted white is a line the rider reads past to reach what he came for.
5. **One header idiom** — `MilerSheetHeader`, badge tinted not filled.
6. **No close button.** The handle and the barrier dismiss. An `X` duplicates
both, and this app had exactly one sheet with one.
7. **Slide is reserved** for custody and money — arrival, collection, delivery
outcome, payment. Everything else commits with a button.
8. **A drag-only control needs `Semantics` with an `onTap` alternative** that
reaches the same confirmation.
9. **Unequal choices look unequal.** Forward path filled, exception amber,
destructive distinct. Never three identical buttons.
10. **Absent, not empty.** A row with no value is not drawn. A group with nothing
in it is not offered.
---
## 5. Known gaps
**Every modal sheet in `lib/` now goes through `showMilerSheet` +
`MilerSheetScaffold`.** The four direct callers listed in the first version of
this document have been migrated:
| File | Was | Now |
|---|---|---|
| `skip_sheet.dart` | own `SafeArea` **wrapping** a hand-rolled glass + gutter + handle, plus a ✕ | kit presenter + scaffold + `MilerSheetHeader` |
| `collect_payment.dart` | right route flags written by hand | kit presenter (content already used the scaffold) |
| `multi_map.dart` | own radius 20, **no glass**, `SafeArea` **plus** `viewInsets` padding, 24 gutter, 22sp red title, ✕ | kit presenter + scaffold + header |
| `map.dart` `_PickupBottomSheet` | a `shape: …circular(24)` that clipped nothing behind a transparent route | kit presenter |
### What remains
- **`auth.dart` uses `Get.bottomSheet`** for its eight error notices. It is a
controller with no `BuildContext`, which is why it was left; migrating it
means giving the controller a navigator key or moving the notices to the
screens. Until then, auth errors are the one surface that does not speak the
system's language.
- **The empty-state artwork cannot be verified in a test.** `Image.asset`
resolves against a bundle the test binding does not carry, so no finder sees
it. The Activity empty state is asserted by its remaining control instead;
the picture itself needs a device check.
- **The live map sheet has not been visually QA'd** across the device and
text-scale matrix. `_PickupMapScreen` is private and needs Get,
SharedPreferences, Geolocator and a live map to pump; that harness does not
exist yet.
- **`milerGlassSheet`'s blur has not been profiled** on a mid-range Android with
the route animating. Readability and GPU cost outrank glassmorphism on an
outdoor screen; if it measures badly, a high-opacity surface is the answer.
**No profiling was run, so no blur change was made.**

View File

@@ -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.

View File

@@ -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.

View File

@@ -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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="0%" />
</foreground>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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"
}
},
{

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 506 B

After

Width:  |  Height:  |  Size: 667 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 938 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

View File

@@ -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;

View File

@@ -69,6 +69,7 @@ Future<void> migrateLegacyStores() async {
_kArrivedOrderIdsKeyBase,
_kCollectedOrderIdsKeyBase,
_kConsignmentIdsKeyBase,
_kPivotNextActionKeyBase,
_kOutForDeliveryKeyBase,
_kNotLoadedKeyBase,
_kOrderLabelsKeyBase,
@@ -115,6 +116,7 @@ Future<void> clearScopedStores() async {
_kArrivedOrderIdsKeyBase,
_kCollectedOrderIdsKeyBase,
_kConsignmentIdsKeyBase,
_kPivotNextActionKeyBase,
_kOutForDeliveryKeyBase,
_kNotLoadedKeyBase,
_kOrderLabelsKeyBase,
@@ -714,6 +716,89 @@ Future<void> forgetConsignmentIds(List<String> 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<void> 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<Map<String, String>> getPivotNextActions() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(await _scopedKey(_kPivotNextActionKeyBase));
if (raw == null || raw.isEmpty) return <String, String>{};
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) return <String, String>{};
return {
for (final e in decoded.entries)
e.key.toString(): (e.value?.toString() ?? '').toLowerCase(),
}..removeWhere((_, v) => v.isEmpty);
} catch (_) {
return <String, String>{};
}
}
/// Drops instructions for orders that are finished, so the map does not grow
/// for the life of the install.
Future<void> forgetPivotNextActions(List<String> 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.

View File

@@ -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

View File

@@ -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) {

View File

@@ -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<ApiResult> 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<ApiResult> 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`,

View File

@@ -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<String, dynamic> stop, {
Set<String> collectedIds = const {},
/// `next_action` per order, from the pivot. See [NextLegResolver].
Map<String, String> 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);

521
lib/data/next_leg.dart Normal file
View File

@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> row, {
Map<String, String> 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<String> 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<String> 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<String, dynamic> 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;
}

View File

@@ -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<String, String> _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<String, HandoverHub> _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<void>? _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<String, String> byId) {
_byId = Map<String, String>.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 = <String, String>{};
final bases = <String, HandoverHub>{};
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<void> 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;
}

View File

@@ -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<int> milkManTenantIds = <int>{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<ServiceProfile> 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}');

View File

@@ -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<String, dynamic> stop, {
Set<String> closedIds = const <String>{},
/// `next_action` recorded at each order's pivot — see [NextLegResolver].
Map<String, String> pivotActions = const <String, String>{},
}) {
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<String> closedIds = const <String>{},
/// `next_action` recorded at each order's pivot — see [NextLegResolver].
Map<String, String> pivotActions = const <String, String>{},
}) {
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<String> collectedIds = const <String>{},
Set<String> acceptedIds = const <String>{},
Set<String> closedIds = const <String>{},
Map<String, String> pivotActions = const <String, String>{},
}) => [
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<String> collectedIds = const <String>{},
Set<String> acceptedIds = const <String>{},
Set<String> closedIds = const <String>{},
Map<String, String> pivotActions = const <String, String>{},
}) => [
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,

View File

@@ -368,7 +368,7 @@ class NotificationServce {
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w700,
fontFamily: FontConstants.fontFamily,
),
),

View File

@@ -281,18 +281,37 @@ class UpdatePickupProvider {
}
if (picked.ok) {
final newId = _consignmentIdFrom(picked);
if (newId.isNotEmpty) {
final orderKey = (data['orderid'] ?? id ?? '').toString().trim();
await rememberConsignmentId(orderKey, newId);
// ── What the pivot now says about what happens next ──
// ── What the pivot said about what happens next, RECORDED ──
//
// 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`.
// 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) {
await rememberConsignmentId(orderKey, newId);
debugPrint(
'[PICKUP] booking $orderKey → consignment $newId '
'(${_field(picked, 'status')}, next: '

View File

@@ -734,7 +734,7 @@ class _ActivityPageState extends State<ActivityPage>
'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<ActivityPage>
// 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<ActivityPage>
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<ActivityPage>
),
style: MilerType.body.copyWith(
fontSize: 13.5.sp,
fontWeight: FontWeight.w500,
fontWeight: FontWeight.w600,
),
),
],
@@ -1042,7 +1042,7 @@ class _ActivityPageState extends State<ActivityPage>
// 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<ActivityPage>
'$_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<ActivityPage>
'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<ActivityPage>
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<ActivityPage>
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<ActivityPage>
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<ActivityPage>
color:
ColorConstants.moneyGreen,
).copyWith(
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w800,
),
),
],
@@ -1829,7 +1829,7 @@ class _ActivityPageState extends State<ActivityPage>
12.5,
color: Narrative.inkMuted,
).copyWith(
fontWeight: FontWeight.w500,
fontWeight: FontWeight.w600,
),
),
],
@@ -1886,7 +1886,7 @@ class _ActivityPageState extends State<ActivityPage>
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<ActivityPage>
'Resume stop',
style: MilerType.body.copyWith(
fontSize: 13.5.sp,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w800,
color: ColorConstants.primary,
),
),

View File

@@ -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),
),
],
);

View File

@@ -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,

View File

@@ -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,
),

View File

@@ -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,

View File

@@ -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<Homepage>
/// "picked", and `picked` is the delivery. See the collected store.
Set<String> _collectedIds = <String>{};
/// `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<String, String> _pivotActions = <String, String>{};
/// 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<Homepage>
// 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<Homepage>
'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<Homepage>
'OK',
style: TextStyle(
fontSize: FontConstants.regular(context),
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w700,
fontFamily: FontConstants.fontFamily,
),
),
@@ -764,7 +771,7 @@ class _HomepageState extends State<Homepage>
style: TextStyle(
fontFamily: FontConstants.fontFamily,
color: _textPrimary,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w700,
),
),
content: Text(
@@ -1180,6 +1187,7 @@ class _HomepageState extends State<Homepage>
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<Homepage>
_acceptedIds = acceptedIds;
_rejectedIds = rejectedIds;
_collectedIds = collectedIds;
_pivotActions = pivotActions;
_ordersMap = newOrdersMap;
_orderIds = newOrderIds;
_selectedOrders = newSelection;
@@ -1670,14 +1679,6 @@ class _HomepageState extends State<Homepage>
}
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<Homepage>
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<Homepage>
_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<Homepage>
/// 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<void> _acceptSelectedStops(List<String> 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<Homepage>
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<String, dynamic>? _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<Homepage>
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<Homepage>
actionLabel,
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w800,
color: Colors.white,
),
),
@@ -2854,7 +2886,27 @@ class _HomepageState extends State<Homepage>
/// checks each stop is still accepted before touching it.
List<Map<String, dynamic>> _lastAcceptedBatch = const [];
Future<void> _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<void> _handleBulkAction() async {
final selectedOrderIds = _selectedOrders.entries
.where((e) => e.value)
.map((e) => e.key)
@@ -2863,7 +2915,7 @@ class _HomepageState extends State<Homepage>
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<Homepage>
final List<Map<String, dynamic>> 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<String> bulkCollected = <String>{};
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<Homepage>
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.
// ── 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<Homepage>
}
}
// 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<Homepage>
// 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<Homepage>
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<Homepage>
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<Homepage>
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<Homepage>
),
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<Homepage>
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<Homepage>
"Go Online",
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w700,
fontFamily: FontConstants.fontFamily,
),
),
@@ -3717,7 +3823,7 @@ class _HomepageState extends State<Homepage>
'${_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<Homepage>
style: TextStyle(
fontFamily: FontConstants.fontFamily,
color: _textPrimary,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w700,
),
),
content: Text(
@@ -3868,32 +3974,9 @@ class _HomepageState extends State<Homepage>
// 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<Homepage>
),
),
)
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<Homepage>
),
)
: 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<Homepage>
),
),
),
],
// ── 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,
),

View File

@@ -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,
),

View File

@@ -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,
),
),

View File

@@ -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,
),
),

View File

@@ -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<RouteTimeline> {
}
/// 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<String> get _flatSelectIds {
if (!widget.groups.every((g) => g.flat)) return const [];
final ids = <String>[];
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,13 +2365,23 @@ 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<String, dynamic> 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,
),
),

View File

@@ -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,

View File

@@ -219,7 +219,10 @@ class _StopActionSheetState extends State<StopActionSheet> {
(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<StopActionSheet> {
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<StopActionSheet> {
_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<StopActionSheet> {
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<StopActionSheet> {
'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<StopActionSheet> {
: '${_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<StopActionSheet> {
'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<StopActionSheet> {
'${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<StopActionSheet> {
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
color: gone
? ColorConstants.secondaryText
@@ -599,7 +602,10 @@ class _StopActionSheetState extends State<StopActionSheet> {
// fact the row does not already state.
if (gone || line.label.isNotEmpty)
Container(
padding: EdgeInsets.symmetric(horizontal: 7.w, vertical: 3.h),
padding: EdgeInsets.symmetric(
horizontal: 7.w,
vertical: 3.h,
),
decoration: BoxDecoration(
color: gone
? ColorConstants.errorRed.withValues(alpha: 0.08)
@@ -613,7 +619,7 @@ class _StopActionSheetState extends State<StopActionSheet> {
maxLines: 1,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w700,
letterSpacing: -0.1,
color: gone
? ColorConstants.errorRed
@@ -668,7 +674,7 @@ class _StopActionSheetState extends State<StopActionSheet> {
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,
),

View File

@@ -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<LiveMark>
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,

View File

@@ -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']);
@@ -313,7 +317,7 @@ class StopDetailSheet extends StatelessWidget {
style: TextStyle(
fontSize: 14.sp,
height: 1.5,
fontWeight: FontWeight.w500,
fontWeight: FontWeight.w600,
color: Narrative.ink,
fontFamily: FontConstants.fontFamily,
),
@@ -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,
),

View File

@@ -482,8 +482,7 @@ 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<String, dynamic> stop) =>
_parseTimestamp(
static DateTime? dayPartTimeOf(Map<String, dynamic> stop) => _parseTimestamp(
stop['expected_pickup_time'] ??
stop['expectedpickuptime'] ??
stop['assignedat'] ??

View File

@@ -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<TripBriefStrip> {
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<TripBriefStrip> {
// 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,7 +229,13 @@ class _TripBriefStripState extends State<TripBriefStrip> {
// 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(
child: _column(),
);
}
/// The brief itself, with no surface of its own.
Widget _column() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
@@ -207,7 +271,6 @@ class _TripBriefStripState extends State<TripBriefStrip> {
),
),
],
),
);
}
@@ -281,11 +344,7 @@ class _TripBriefStripState extends State<TripBriefStrip> {
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<TripBriefStrip> {
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<TripBriefStrip> {
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<TripBriefStrip> {
// 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<TripBriefStrip> {
child: Icon(
LucideIcons.chevronDown,
size: 20.sp,
color: ColorConstants.secondaryText,
color: _inkMuted,
),
),
],
@@ -382,7 +441,12 @@ class _TripBriefStripState extends State<TripBriefStrip> {
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<TripBriefStrip> {
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<TripBriefStrip> {
//
// 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<TripBriefStrip> {
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<TripBriefStrip> {
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<TripBriefStrip> {
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<TripBriefStrip> {
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<TripBriefStrip> {
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<TripBriefStrip> {
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<TripBriefStrip> {
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<TripBriefStrip> {
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<TripBriefStrip> {
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<TripBriefStrip> {
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,
),
),

View File

@@ -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<RouteGroup> routeGroups() {
final order = <String>[];
final buckets = <String, List<int>>{};
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] = <int>[];
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
: (hub.isNotEmpty
? hub
: stopSourceName(first).trim().isNotEmpty
? stopSourceName(first)
: place)
: MilkRun.navigationLabel(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,

View File

@@ -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,

View File

@@ -404,7 +404,7 @@ class _TripProgressRailState extends State<TripProgressRail> {
// 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<TripProgressRail> {
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<TripProgressRail> {
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<TripProgressRail> {
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w700,
color: allDone
? ColorConstants.acceptGreen
: ColorConstants.secondaryText,

View File

@@ -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 │
/// ╰─────────╯ ╰─────────╯ ╰─────────╯
/// ━━━━━
/// │ ━━━ ││ │ │ │
/// ├────────╯╰────────┴─┴────────┴──────────╮
/// │ 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<Trip> trips;
final int selectedIndex;
final ValueChanged<int> onSelect;
/// Optimistic accept/reject sets, for the per-trip count.
/// Optimistic accept/reject sets, for the per-slot count in the brief.
final Set<String> acceptedIds;
final Set<String> 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<TripSelector>
// 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<double> _markerCurve;
late Tween<double> _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<double>(
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<int> _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 <int>[
for (var i = 0; i < inlineCount; i++) i,
if (overflows && widget.selectedIndex >= inlineCount)
widget.selectedIndex,
];
}
List<int> _hiddenTabs(List<int> 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 <int>[
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<int> 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<double>(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<TripSelector>
/// 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(
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: [
Row(
children: [
for (var i = 0; i < inline.length; i++) ...[
if (i > 0) SizedBox(width: 10.w),
// ── Flexible, because the labels grow ──
//
// The pills size themselves to their text and the text
// scales with the system font. Three of them at 2.0× are
// wider than a 320pt phone, and a `Row` of fixed-width
// children overflows rather than compressing. Loose flex
// lets each take its natural width until there is no room,
// and the label scales down inside it after that.
Flexible(
child: _TripTab(
index: inline[i],
// How lit this pill is: 1 with the rule under it, 0
// once the rule is a whole slot away. Tied to the
// rule's position rather than to the selection, so a
// pill warms as the rule crosses instead of flipping
// the instant it is tapped while the rule is two slots
// away.
lit: (1.0 - (p - i).abs()).clamp(0.0, 1.0),
onTap: () => widget.onSelect(inline[i]),
onMeasured: (w) => _measure(i, w),
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,
),
child: body,
),
],
if (hidden.isNotEmpty) ...[
SizedBox(width: 10.w),
_OverflowChip(
width: 44.w,
hidden: hidden,
trips: widget.trips,
onSelect: widget.onSelect,
),
],
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.
/// One tab: the trip's name, and the rule when it is the one being worked.
///
/// 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<int, double> _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.
///
/// 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<double> 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),
),
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(
// 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}',
Trip.tabLabel(index),
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,
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,
),
),
),
),
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,
),
),
);
}
}
/// 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<double> 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<double> 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<int> hidden;
final List<Trip> trips;
final ValueChanged<int> 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<int>(
tooltip: '',
padding: EdgeInsets.zero,
color: ColorConstants.pureSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
onSelected: onSelect,
itemBuilder: (context) => [
for (final i in hidden)
PopupMenuItem<int>(
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,
),
),
)
: null,
),
],
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,
),
),
),
),
),
),
);
}
}

View File

@@ -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<OrderStatusButton> {
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w700,
fontFamily: FontConstants.fontFamily,
letterSpacing: 0.2,
color: color,

View File

@@ -59,6 +59,19 @@ class PickupCard extends StatelessWidget {
/// off the row's raw status.
final Set<String> 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<String, String> 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<String> 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(

View File

@@ -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<CollectPaymentPage> {
// 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<CollectPaymentPage> {
'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<CollectPaymentPage> {
'₹${_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<CollectPaymentPage> {
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<CollectPaymentPage> {
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<CollectPaymentPage> {
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<CollectPaymentPage> {
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<CollectPaymentPage> {
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<CollectPaymentPage> {
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<CollectPaymentPage> {
'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<CollectPaymentPage> {
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<CollectPaymentPage> {
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<CollectPaymentPage> {
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<CollectPaymentPage> {
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<CollectPaymentPage> {
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<CollectPaymentPage> {
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<CollectPaymentPage> {
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<CollectPaymentPage> {
'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<CollectPaymentPage> {
'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<CollectPaymentPage> {
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<CollectPaymentPage> {
'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<CollectPaymentPage> {
'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<CollectPaymentPage> {
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<CollectPaymentPage> {
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<CollectPaymentPage> {
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<CollectPaymentPage> {
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<CollectPaymentPage> {
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<CollectPaymentPage> {
style: TextStyle(
fontSize: 12.sp,
height: 1.35,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w700,
color: accent,
fontFamily: FontConstants.fontFamily,
),

View File

@@ -269,6 +269,42 @@ bool get _handoffHoldIsPossible => ServiceProfile.active.endsAtHub;
Future<bool> releaseForDelivery(Map<String, dynamic> 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<bool> releaseForDelivery(Map<String, dynamic> 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) {

View File

@@ -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<DeliveryProofPage> {
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<DeliveryProofPage> {
'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<DeliveryProofPage> {
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<DeliveryProofPage> {
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<DeliveryProofPage> {
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<DeliveryProofPage> {
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<DeliveryProofPage> {
"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<DeliveryProofPage> {
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w700,
color: ColorConstants.warning,
fontFamily: FontConstants.fontFamily,
),

View File

@@ -390,7 +390,7 @@ class _PickupsDoneState extends State<PickupsDone>
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<PickupsDone>
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<PickupsDone>
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<PickupsDone>
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<PickupsDone>
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<PickupsDone>
'$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<PickupsDone>
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<PickupsDone>
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<PickupsDone>
'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<PickupsDone>
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<PickupsDone>
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<PickupsDone>
// 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<PickupsDone>
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<PickupsDone>
'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<PickupsDone>
'${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<PickupsDone>
'Bonus Points',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w500,
fontWeight: FontWeight.w600,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),

View File

@@ -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,

View File

@@ -553,7 +553,7 @@ class _MultiCustomerMapScreenState extends State<MultiCustomerMapScreen>
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<MultiCustomerMapScreen>
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<MultiCustomerMapScreen>
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<MultiCustomerMapScreen>
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<MultiCustomerMapScreen>
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<MultiCustomerMapScreen>
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<MultiCustomerMapScreen>
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<MultiCustomerMapScreen>
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 18.sp,
fontWeight: FontWeight.w500,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
),
),

View File

@@ -174,7 +174,7 @@ class _PaymentScreenState extends State<PaymentScreen>
'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<PaymentScreen>
'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<PaymentScreen>
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<PaymentScreen>
'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<PaymentScreen>
'₹${_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<PaymentScreen>
'#$_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<PaymentScreen>
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<PaymentScreen>
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<PaymentScreen>
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<PaymentScreen>
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<PaymentScreen>
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,

View File

@@ -272,7 +272,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
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<PickupVerificationPage>
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<PickupVerificationPage>
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<PickupVerificationPage>
_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<PickupVerificationPage>
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<PickupVerificationPage>
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<PickupVerificationPage>
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<PickupVerificationPage>
'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<PickupVerificationPage>
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<PickupVerificationPage>
style: TextStyle(
fontSize: 13.sp,
color: _primaryRed,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w700,
fontFamily: FontConstants.fontFamily,
),
),
@@ -806,7 +806,7 @@ class _PickupVerificationPageState extends State<PickupVerificationPage>
: '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<PickupVerificationPage>
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w700,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),

View File

@@ -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<MyPickups>
Set<String> _acceptedIds = <String>{};
Set<String> _collectedIds = <String>{};
/// `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<String, String> _pivotActions = <String, String>{};
Set<String> _outForDeliveryIds = <String>{};
Set<String> _notLoadedIds = <String>{};
Set<String> _rejectedIds = <String>{};
@@ -600,8 +606,6 @@ class _MyPickupsState extends State<MyPickups>
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<MyPickups>
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<MyPickups>
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<MyPickups>
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<MyPickups>
// no tab at all: dropped from Home as collected, withheld from here as
// unreleased.
final Set<String> 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<MyPickups>
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<MyPickups>
collectedIds: _collectedIds,
outForDeliveryIds:
_outForDeliveryIds,
pivotActions: _pivotActions,
),
QueueDensity.next => _NextCard(
item: item,

View File

@@ -112,7 +112,7 @@ class _PipInfoCardState extends State<PipInfoCard> {
'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<PipInfoCard> {
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<PipInfoCard> {
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
fontWeight: FontWeight.w600,
color: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),

View File

@@ -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,
),

View File

@@ -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<ShipmentCapturePage> {
@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<ShipmentCapturePage> {
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<ShipmentCapturePage> {
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<ShipmentCapturePage> {
'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<ShipmentCapturePage> {
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<ShipmentCapturePage> {
'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<ShipmentCapturePage> {
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<ShipmentCapturePage> {
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<ShipmentCapturePage> {
'${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<ShipmentCapturePage> {
'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<ShipmentCapturePage> {
'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<ShipmentCapturePage> {
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<ShipmentCapturePage> {
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<ShipmentCapturePage> {
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<ShipmentCapturePage> {
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<ShipmentCapturePage> {
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<ShipmentCapturePage> {
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<ShipmentCapturePage> {
v,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w700,
color: v == selected
? ColorConstants.onAccent
: ColorConstants.slateText,

View File

@@ -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<ShipmentReviewPage>
// 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<ShipmentReviewPage>
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<ShipmentReviewPage>
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<ShipmentReviewPage>
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<ShipmentReviewPage>
'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<ShipmentReviewPage>
_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<ShipmentReviewPage>
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<ShipmentReviewPage>
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<ShipmentReviewPage>
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<ShipmentReviewPage>
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<ShipmentReviewPage>
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<ShipmentReviewPage>
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<ShipmentReviewPage>
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<ShipmentReviewPage>
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<ShipmentReviewPage>
tracking,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w800,
letterSpacing: 0.3,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,

View File

@@ -110,7 +110,7 @@ Future<void> _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<void> _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,

View File

@@ -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.
///

View File

@@ -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<StopVerificationPage> {
// 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<StopVerificationPage> {
'${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<StopVerificationPage> {
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<StopVerificationPage> {
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<StopVerificationPage> {
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<StopVerificationPage> {
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<StopVerificationPage> {
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<StopVerificationPage> {
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<StopVerificationPage> {
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<StopVerificationPage> {
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<StopVerificationPage> {
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<StopVerificationPage> {
'$_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<StopVerificationPage> {
'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<StopVerificationPage> {
: '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<StopVerificationPage> {
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<StopVerificationPage> {
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<StopVerificationPage> {
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<StopVerificationPage> {
'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<StopVerificationPage> {
'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<StopVerificationPage> {
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<StopVerificationPage> {
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<StopVerificationPage> {
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<StopVerificationPage> {
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<StopVerificationPage> {
// 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<StopVerificationPage> {
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<StopVerificationPage> {
'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<StopVerificationPage> {
'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<StopVerificationPage> {
/// 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<Widget> Function(double width, double lastWidth) chips,
}) {
Widget _choiceGrid({required List<Widget> 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<StopVerificationPage> {
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<StopVerificationPage> {
// 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<StopVerificationPage> {
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<StopVerificationPage> {
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<StopVerificationPage> {
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<StopVerificationPage> {
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<StopVerificationPage> {
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<StopVerificationPage> {
'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<StopVerificationPage> {
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<StopVerificationPage> {
style: TextStyle(
fontSize: 12.sp,
height: 1.35,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w700,
color: accent,
fontFamily: FontConstants.fontFamily,
),

View File

@@ -533,7 +533,7 @@ class _ProfilePageState extends State<ProfilePage> {
Text(
'·',
style: MilerType.label.copyWith(
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w800,
color: ColorConstants.borderStrong,
),
),
@@ -745,7 +745,7 @@ class _ProfilePageState extends State<ProfilePage> {
'Retry',
style: MilerType.micro
.on(ColorConstants.primary)
.copyWith(fontWeight: FontWeight.w700),
.copyWith(fontWeight: FontWeight.w800),
),
),
),

View File

@@ -175,7 +175,7 @@ class _FaqPageState extends State<FaqPage> {
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<FaqPage> {
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<FaqPage> {
'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,

View File

@@ -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<HelpCenterMessage> {
'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<HelpCenterMessage> {
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<HelpCenterMessage> {
validator: validator,
style: TextStyle(
fontSize: 14.5.sp,
fontWeight: FontWeight.w500,
fontWeight: FontWeight.w600,
color: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),

View File

@@ -118,7 +118,7 @@ class _NotificationsPageState extends State<NotificationsPage> {
'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<NotificationsPage> {
'Cancel',
style: TextStyle(
color: ColorConstants.onSurfaceVariant,
fontWeight: FontWeight.w500,
fontWeight: FontWeight.w600,
fontFamily: FontConstants.fontFamily,
),
),
@@ -151,7 +151,7 @@ class _NotificationsPageState extends State<NotificationsPage> {
'Clear',
style: TextStyle(
color: ColorConstants.primary,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w700,
fontFamily: FontConstants.fontFamily,
),
),
@@ -181,7 +181,7 @@ class _NotificationsPageState extends State<NotificationsPage> {
'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,
),

View File

@@ -167,7 +167,7 @@ class _OrderAlertSoundPageState extends State<OrderAlertSoundPage> {
_currentLabel,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w700,
color: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),

View File

@@ -155,7 +155,7 @@ class _ProfileState extends State<Profile> {
'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<Profile> {
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<Profile> {
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<Profile> {
prefixText: prefix,
prefixStyle: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w500,
fontWeight: FontWeight.w600,
color: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),

View File

@@ -177,7 +177,7 @@ class _RiderRewardsPageState extends State<RiderRewardsPage> {
'${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<RiderRewardsPage> {
'$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<RiderRewardsPage> {
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<RiderRewardsPage> {
'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<RiderRewardsPage> {
'${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<RiderRewardsPage> {
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<RiderRewardsPage> {
'${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<RiderRewardsPage> {
'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<RiderRewardsPage> {
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<RiderRewardsPage> {
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<RiderRewardsPage> {
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<RiderRewardsPage> {
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<RiderRewardsPage> {
'$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<RiderRewardsPage> {
'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<RiderRewardsPage> {
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<RiderRewardsPage> {
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<RiderRewardsPage> {
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<RiderRewardsPage> {
'$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<RiderRewardsPage> {
title,
style: TextStyle(
fontSize: 13.5.sp,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,

Some files were not shown because too many files have changed in this diff Show More