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