Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
15 KiB
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.
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
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 ── valuetable 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
_sheetWorkingand the real safe areas, not from a remembered pixel value. A hardcoded300.hagreed 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
- Open it with
showMilerSheet. Never configureshowModalBottomSheetyourself. - Wrap the content in
MilerSheetScaffold. It is the handle, the gutter and the inset. - No
SafeAreainside a sheet. The scaffold already paid the inset. Adding one is the double-inset bug. - 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.
- One header idiom —
MilerSheetHeader, badge tinted not filled. - No close button. The handle and the barrier dismiss. An
Xduplicates both, and this app had exactly one sheet with one. - Slide is reserved for custody and money — arrival, collection, delivery outcome, payment. Everything else commits with a button.
- A drag-only control needs
Semanticswith anonTapalternative that reaches the same confirmation. - Unequal choices look unequal. Forward path filled, exception amber, destructive distinct. Never three identical buttons.
- 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.dartusesGet.bottomSheetfor its eight error notices. It is a controller with noBuildContext, 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.assetresolves 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.
_PickupMapScreenis 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.