Files
doormile_milderapp/BOTTOM_SHEETS.md
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
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>
2026-08-22 05:40:35 +05:30

15 KiB
Raw Blame History

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