miler map
330
BOTTOM_SHEETS.md
@@ -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.**
|
||||
263
DESIGN_SYSTEM.md
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -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>
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 11 KiB |
4
android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 506 B After Width: | Height: | Size: 667 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 938 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 9.4 KiB |
BIN
launcher_icon/doormile_icon_foreground.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
launcher_icon/doormile_icon_legacy.png
Normal file
|
After Width: | Height: | Size: 125 KiB |
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -368,7 +368,7 @@ class NotificationServce {
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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: '
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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'] ??
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||