Redesign: Poppins, a two-step destination, and a splash that says what the app does

The effort pass, end to end. Every screen was run through one test — if I
remove this sentence, does the customer make a worse decision? — and the parts
that failed it are gone.

The flow

  Home ▸ BOOK ▸ Where is it going? ▸ When shall we collect? ▸ details ▸ booked

BOOK opens a sheet, not a form. The destination is browsed state-then-district
because a flat list of every serviceable district survives twelve and not
sixty, and search cuts across states because somebody who knows they are
sending to Chennai should not have to know which state it is in. Districts
multi-select, but only where the server allows it: BookingLimits advertises
maxDestinations: 1 until the Miler build keys on consignmentid, and a sheet
that ignored that would sell a booking the network cannot complete.

The pickup window is now a step the customer answers rather than a slot chosen
for them. A pickup window is a promise about somebody's afternoon.

What the screens stopped saying

Home lost the orb caption for returning customers and a four-cell live card.
Send lost the city strip, both address fields, the optional disclosure and
three sentences about charging — the route, the packages and the button are
what is left. Tracking lost a radar with a bike in it, a Milers-in-your-zone
count, a "Step 2 of 7" and a sentence describing the screen you were looking
at. The window sheet lost "Fastest pickup", "4 Milers nearby" and "Relaxed
evening handover".

Type

Poppins, which has no variable release — four static cuts, and the sans styles
set fontWeight alone because fontVariations on a static font is ignored in
silence. Every weight dropped a step and the tracking went deeper: Poppins is
built on near-circles and carries more ink than the humanist faces before it.

Objects

One lit sphere on Home, and the primary button now takes its gradient and rim
because a committing action that is not lit like the hero reads as a different
material. The tracking rail's connector is crimson as far as the parcel has
come, so the line is the progress bar. Confirmation is a white tick on green:
crimson is this app's action colour and that screen has nothing left to do.

Bugs found on the way

The OTP screen dropped digits. Four fields passing focus along lose a keystroke
that arrives mid-transition, so "1234" became "124" and the screen answered
"That code did not match" — blaming the customer for its own race. One field
now, four boxes that only draw.

Nothing ever asked for the customer's location: detectPickupLocation was the
OTP screen's job, so a restored session or an auto-login never triggered the
permission prompt and the pickup map had nothing to centre on.

The launcher icon and both splash screens pointed at a house drawn as two
vector paths — a placeholder that shipped.

The splash clock started when the widget was built rather than when it was
visible, so the truck got 0.45s of a 1.8s beat behind Android's own splash.
It waits on waitUntilFirstFrameRasterized now, raced against a timeout so a
binding that never reports one cannot strand the app.

Also: design/screens/ holds all 19 screens under readable names, tool/ has the
scripts that refresh them and rebrand the Lottie, and DESIGN.md is current.

flutter analyze clean. 88 tests, 1 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
This commit is contained in:
2026-09-22 17:45:54 +05:30
parent 8207e27a97
commit 06fa6b797a
107 changed files with 4597 additions and 2071 deletions

View File

@@ -170,7 +170,7 @@ Play does not filter the app off devices without it.
```bash
flutter pub get
flutter run # any connected device or simulator
flutter test # 79 tests covering the whole flow
flutter test # 88 tests covering the whole flow
```
`flutter run` talks to `https://api.doormile.com/api/v1`. Point it somewhere
@@ -180,6 +180,25 @@ else with a define:
flutter run --dart-define=DM_API_BASE=https://<staging-host>/api/v1
```
### Which build am I in?
Every debug launch prints it as its first line:
```
[BUILD] DEV DATA (offline) · staging · no network ← the fake, any code works
[BUILD] staging · https://api.doormile.com/api/v1 ← the real server
```
Check that line before anything else. The two builds are identical on screen,
and the whole reason the first offline mode was deleted was that nobody could
tell which one a handset was running. The Account screen says the same thing
under **BUILD**, but a build that cannot get past the login screen cannot show
you the Account screen — which is exactly when you need to know.
**`[API] 401 …/auth/otp/verify · invalid_otp` means you are on the real server**
and it refused the code. `1234` is not a real code; only the offline build
accepts anything. See below.
### Signing in for development
A shipping build has **one** API implementation, `LiveDoormileApi`, and it talks
@@ -194,10 +213,13 @@ in:
```bash
flutter run --dart-define=DM_MOCK=true
# login screen → 9876543210 → 1234 → in.
# add --dart-define=DM_DEV_LOGIN=true to skip the login screen entirely.
```
It opens **straight on Home**, already signed in as Joe Oommen — there is no
server to refuse anybody, so stopping at a login screen would ask for a phone
number and a code that mean nothing. Sign out and you get the real entrance,
where any 10-digit number and **any 4-digit code** get you back in.
This swaps in `DevDoormileApi`: in-memory serviceability, slots, fare and
bookings, so you can walk signup → book → track without a backend. **A booking
made here stays on the device — it reaches no server and no admin console.** The
@@ -212,6 +234,21 @@ flutter run --dart-define=DM_DEV_TOKEN=eyJ... \
--dart-define=DM_DEV_REFRESH_TOKEN=...
```
**Real sign-in, no login screen** — runs the actual OTP request and verify pair
at launch, so what it ends up holding is a token the server issued and a
booking it makes reaches `pickupbookings` and the admin console:
```bash
flutter run --dart-define=DM_LOGIN_AS=9876543210 \
--dart-define=DM_LOGIN_CODE=<a code the server will accept>
```
The code has to be one the server will take. Two ways to have one: set
`CX_STAGING_OTP` on a non-production backend (a fixed code, refused outright
when `ENV=production`), or read the code the backend logged — with no SMS
gateway registered its `logSender` writes every code to the application log and
reports the send as successful.
### Walking a booking through its stages
Against dev data, the tracking screen's stepper walks the in-memory booking.

View File

@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Shown from process start until the first Flutter frame. Matches the white
Login screen so the handover is invisible. -->
<!-- Shown from process start until the first Flutter frame.
Deliberately empty. It used to centre a mark on it, which meant the
customer saw a logo, then Flutter's own splash animation, then the logo
again — the same image twice with a jump between them. The window
background is now the surface the Flutter splash opens on, so the handover
is a single continuous screen. -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/dm_surface" />
<item
android:gravity="center"
android:width="120dp"
android:height="120dp"
android:drawable="@drawable/dm_mark_brand" />
</layer-list>

View File

@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Doormile mark in brand maroon, for the launch screen.
Art is authored on a 24x24 grid and scaled into the 66dp safe zone. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group android:scaleX="2.2" android:scaleY="2.2"
android:translateX="27.6" android:translateY="27.6">
<path
android:pathData="M3.6,9.2 L12,4 L20.4,9.2 L20.4,20 L3.6,20 Z"
android:strokeColor="#960019"
android:strokeWidth="1.8"
android:strokeLineJoin="round"
android:strokeLineCap="round" />
<path
android:pathData="M9.6,20 L9.6,13.4 L14.4,13.4 L14.4,20"
android:strokeColor="#960019"
android:strokeWidth="1.8"
android:strokeLineJoin="round"
android:strokeLineCap="round" />
</group>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Doormile mark in white, for the adaptive-icon foreground.
Art is authored on a 24x24 grid and scaled into the 66dp safe zone. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group android:scaleX="2.2" android:scaleY="2.2"
android:translateX="27.6" android:translateY="27.6">
<path
android:pathData="M3.6,9.2 L12,4 L20.4,9.2 L20.4,20 L3.6,20 Z"
android:strokeColor="#FFFFFF"
android:strokeWidth="1.8"
android:strokeLineJoin="round"
android:strokeLineCap="round" />
<path
android:pathData="M9.6,20 L9.6,13.4 L14.4,13.4 L14.4,20"
android:strokeColor="#FFFFFF"
android:strokeWidth="1.8"
android:strokeLineJoin="round"
android:strokeLineCap="round" />
</group>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 B

View File

@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Shown from process start until the first Flutter frame. Matches the white
Login screen so the handover is invisible. -->
<!-- Shown from process start until the first Flutter frame.
Deliberately empty. It used to centre a mark on it, which meant the
customer saw a logo, then Flutter's own splash animation, then the logo
again — the same image twice with a jump between them. The window
background is now the surface the Flutter splash opens on, so the handover
is a single continuous screen. -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/dm_surface" />
<item
android:gravity="center"
android:width="120dp"
android:height="120dp"
android:drawable="@drawable/dm_mark_brand" />
</layer-list>

View File

@@ -1,6 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Doormile mark on white.
The foreground was a house drawn as two vector paths — a placeholder that
shipped, and the reason the launcher showed a generic home icon next to
the name Doormile. It is the real mark now, rendered from
`assets/images/doormile-icon.png` into the 66dp safe zone.
White background rather than brand: the mark carries its own crimson ring,
and on a maroon field that ring disappears into it. -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/dm_brand" />
<foreground android:drawable="@drawable/dm_mark_white" />
<monochrome android:drawable="@drawable/dm_mark_white" />
<background android:drawable="@color/dm_surface" />
<foreground android:drawable="@drawable/dm_mark_fg" />
<monochrome android:drawable="@drawable/dm_mark_fg" />
</adaptive-icon>

View File

@@ -1,6 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Doormile mark on white.
The foreground was a house drawn as two vector paths — a placeholder that
shipped, and the reason the launcher showed a generic home icon next to
the name Doormile. It is the real mark now, rendered from
`assets/images/doormile-icon.png` into the 66dp safe zone.
White background rather than brand: the mark carries its own crimson ring,
and on a maroon field that ring disappears into it. -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/dm_brand" />
<foreground android:drawable="@drawable/dm_mark_white" />
<monochrome android:drawable="@drawable/dm_mark_white" />
<background android:drawable="@color/dm_surface" />
<foreground android:drawable="@drawable/dm_mark_fg" />
<monochrome android:drawable="@drawable/dm_mark_fg" />
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -1,11 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Android 12+ uses the platform splash screen API. -->
<!-- Android 12+ uses the platform splash screen API, and it cannot be
switched off: leave the icon unset and the platform draws the launcher
icon instead. So it is set to a fully transparent one, over the same
white the Flutter splash opens on, which makes the system beat a blank
white screen indistinguishable from the frame after it.
The customer sees two screens, not three: the truck, then the mark. -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowSplashScreenBackground">@color/dm_surface</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/dm_mark_brand</item>
<item name="android:windowSplashScreenIconBackgroundColor">@color/dm_surface</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/dm_splash_blank</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/dm_surface</item>

View File

@@ -1,11 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Android 12+ uses the platform splash screen API. -->
<!-- Android 12+ uses the platform splash screen API, and it cannot be
switched off: leave the icon unset and the platform draws the launcher
icon instead. So it is set to a fully transparent one, over the same
white the Flutter splash opens on, which makes the system beat a blank
white screen indistinguishable from the frame after it.
The customer sees two screens, not three: the truck, then the mark. -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowSplashScreenBackground">@color/dm_surface</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/dm_mark_brand</item>
<item name="android:windowSplashScreenIconBackgroundColor">@color/dm_surface</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/dm_splash_blank</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/dm_surface</item>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Doormile brand tokens, mirrored from lib/ui/tokens.dart -->
<color name="dm_brand">#960019</color>
<color name="dm_brand">#8F0F06</color>
<color name="dm_surface">#FFFFFF</color>
</resources>

View File

@@ -0,0 +1,16 @@
splash.json — the animation the splash screen plays.
"Delivery Truck / Loading / Exporting" from LottieFiles:
https://lottiefiles.com/free-animation/delivery-truck-loading-exporting-Q2WoVTiHUy
500x500, 60fps, 235 frames (3.9s), eleven layers, pure vector with no embedded
images. Downloaded by hand — LottieFiles sits behind a Cloudflare challenge
that answers 403 to curl and to every fetch tool, so this file cannot be
refreshed by a script. If it needs replacing, use a browser.
Check the licence against that listing before a release. LottieFiles' free
animations are normally usable commercially with attribution, but that is worth
reading rather than assuming.
SplashScreen falls back to a built-in road loader when this file is absent, so
deleting it breaks nothing.

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,4 +1,4 @@
Copyright 2018 The Manrope Project Authors (https://github.com/sharanda/manrope)
Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

BIN
design/screens/04-home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

48
design/screens/README.md Normal file
View File

@@ -0,0 +1,48 @@
# Screens
Every screen in the app, at 390×844 @3x, with the real fonts.
These are the golden files from `test/snapshots/`, copied here under readable
names so the set can be browsed, shared or dropped into a deck without anyone
having to know what a golden is. **`test/snapshots/` is the source of truth** —
this folder is a copy, and a stale copy is worse than none, so refresh it
whenever the design moves:
```bash
flutter test test/design_snapshot_test.dart --run-skipped --update-goldens
tool/screens.sh
```
They are rendered by the test harness rather than captured from a handset, so
they are identical on any machine and need no device attached. Two consequences
worth knowing: **map tiles are blank**, because the harness has no network and
CARTO is fetched live; and the data is `DevDoormileApi`'s, so the names,
addresses and references are invented.
| File | Screen | |
| --- | --- | --- |
| [`00-splash-animation.png`](00-splash-animation.png) | **Splash · first beat** | What the app does: a parcel on the road. |
| [`00b-splash-mark.png`](00b-splash-mark.png) | **Splash · second beat** | Whose app it is. Caught mid-cross-fade. |
| [`01-sign-in.png`](01-sign-in.png) | **Sign in** | One question, one field, one button. |
| [`02-sign-up.png`](02-sign-up.png) | **Create account** | The same shell, three fields. |
| [`03-verify-code.png`](03-verify-code.png) | **Verify the code** | One field behind four boxes that only draw. |
| [`04-home.png`](04-home.png) | **Home** | Welcome, the pickup address, BOOK, and the live booking. |
| [`05-pickup-search.png`](05-pickup-search.png) | **Collect from** | Changing the pickup address from the Home header. |
| [`06-orders-active.png`](06-orders-active.png) | **Orders · Active** | State, date, route, packages, reference. |
| [`07-orders-completed.png`](07-orders-completed.png) | **Orders · Completed** | Delivered orders and what they cost. |
| [`08-account.png`](08-account.png) | **Account** | Grouped rows, and the build this handset is running. |
| [`09-tracking.png`](09-tracking.png) | **Live tracking** | The live card, the map, the Miler, the journey rail. |
| [`10-cancel-pickup.png`](10-cancel-pickup.png) | **Cancel this pickup** | The one destructive action, behind a reason. |
| [`11-destination-states.png`](11-destination-states.png) | **Where is it going? · states** | Step one, with the state marks. |
| [`12-destination-districts.png`](12-destination-districts.png) | **Where is it going? · districts** | Step two. Multi-select where the server allows it. |
| [`13-pickup-window.png`](13-pickup-window.png) | **Pickup window** | The times, and nothing else. |
| [`14-send-a-parcel.png`](14-send-a-parcel.png) | **Send a parcel** | The route, the packages, the two commitments. |
| [`15-pickup-map.png`](15-pickup-map.png) | **Pickup point** | The map editor, and who the Miler asks for. |
| [`16-pickup-booked.png`](16-pickup-booked.png) | **Pickup booked** | White tick on green. The reference and the live search. |
| [`17-receipt.png`](17-receipt.png) | **Order details** | What was collected, weighed, photographed and paid. |
## Not here yet
**First-run Home** — the sphere's caption and the "How it works" rail only
appear for a customer with no orders, and the dev backend seeds three. It needs
a flag on `DevFlags` to capture, which is not worth adding for a picture.

View File

@@ -442,6 +442,7 @@ class DevDoormileApi extends DoormileApi {
required List<DestinationGroup> destinations,
required String? slotId,
FareEstimate? fare,
String? contactPhone,
String? idempotencyKey,
}) => _respond(() {
if (destinations.isEmpty ||

View File

@@ -123,11 +123,17 @@ abstract class DoormileApi {
///
/// [idempotencyKey] must be **held across retries of the same intent**. A new
/// key is a new booking, which is exactly what a double tap must not create.
///
/// [contactPhone] is who the Miler calls at the pickup door. Null means the
/// signed-in customer, which is what it was always sending — the parameter
/// exists because the person handing over the parcel is not always the
/// person who booked it.
Future<Booking> createBooking({
required Place pickup,
required List<DestinationGroup> destinations,
required String? slotId,
FareEstimate? fare,
String? contactPhone,
String? idempotencyKey,
});

View File

@@ -382,6 +382,7 @@ class LiveDoormileApi extends DoormileApi {
required List<DestinationGroup> destinations,
required String? slotId,
FareEstimate? fare,
String? contactPhone,
String? idempotencyKey,
}) async {
if (slotId == null || slotId.isEmpty) {
@@ -404,7 +405,10 @@ class LiveDoormileApi extends DoormileApi {
'slotId': slotId,
'pickup': pickup.toBookingJson(
contactName: customer?.name,
contactPhone: customer?.phone,
// The customer's own number unless they said somebody else is
// handing the parcel over. Same field either way — the contract has
// always carried it; it simply had one possible source.
contactPhone: contactPhone ?? customer?.phone,
),
'destinations': [for (final d in destinations) d.toBookingJson()],
// The delivery instructions the customer typed per destination. The

View File

@@ -94,22 +94,52 @@ enum JourneyStage {
static JourneyStage at(int i) =>
JourneyStage.values[i.clamp(0, JourneyStage.values.length - 1)];
/// What a status pill should say. Operational stages roll up so the customer
/// never reads "Order created" as their status.
String get milestoneLabel => CustomerMilestone.of(this).label;
/// What a status pill should say.
///
/// Deliberately **not** [CustomerMilestone.of]. The rail collapses the whole
/// pickup visit into "Booking created", which is right for a journey read
/// over days — but a pill on Orders, or the live row on Home, is answering
/// "what is happening to my parcel *now*", and "Booking created" is a poor
/// answer while a named Miler is two kilometres from the door. The two
/// surfaces want different granularity from the same stage, so they get
/// their own vocabulary.
String get milestoneLabel => switch (this) {
JourneyStage.booked => 'Pickup booked',
JourneyStage.assigned => 'Miler assigned',
JourneyStage.onTheWay => 'Miler on the way',
JourneyStage.arrived => 'Miler at your door',
JourneyStage.pickedUp => 'Package collected',
JourneyStage.orderCreated => 'Order created',
JourneyStage.inTransit => 'In transit',
JourneyStage.outForDelivery => 'Out for delivery',
JourneyStage.delivered => 'Delivered',
};
}
/// What the customer sees on the timeline.
///
/// The backend keeps nine operational states; the customer gets seven
/// meaningful ones. Operational detail ("Miler on the way", "Order created")
/// The backend keeps nine operational states; the rail shows five. Operational detail ("Miler on the way", "Order created")
/// appears as context under the current milestone rather than as another
/// permanent row.
/// The shipment's journey, as five milestones.
///
/// ── Why the pickup is one milestone and not four ──
///
/// This had seven: booked, assigned, pickup in progress, collected, in
/// transit, out for delivery, delivered. Four of them described the *visit* —
/// a Miler being found, riding over, arriving, weighing — which is an hour of
/// somebody's afternoon, and the tracking screen already narrates all of it
/// live in the crimson card at the top, by name, with a distance and an ETA.
/// The rail was repeating that hour in four rows and then giving the whole
/// rest of the journey — the part measured in days — the other three.
///
/// The rail's job is the shipment. Everything before the order exists is
/// **Booking created**; the moment the Miler creates the order the parcel is a
/// consignment with a tracking number, and the four milestones after that are
/// the ones a customer checks back for over the following days.
enum CustomerMilestone {
booked('Pickup booked'),
assigned('Miler assigned'),
pickupInProgress('Pickup in progress'),
collected('Package collected'),
booked('Booking created'),
orderCreated('Order created'),
inTransit('In transit'),
outForDelivery('Out for delivery'),
delivered('Delivered');
@@ -119,13 +149,17 @@ enum CustomerMilestone {
final String label;
/// Which milestone an internal stage rolls up into.
///
/// Everything up to and including the handover is the booking: the customer
/// has arranged a visit, and until the order exists there is nothing else to
/// call it.
static CustomerMilestone of(JourneyStage stage) => switch (stage) {
JourneyStage.booked => CustomerMilestone.booked,
JourneyStage.assigned => CustomerMilestone.assigned,
JourneyStage.onTheWay || JourneyStage.arrived =>
CustomerMilestone.pickupInProgress,
JourneyStage.pickedUp || JourneyStage.orderCreated =>
CustomerMilestone.collected,
JourneyStage.booked ||
JourneyStage.assigned ||
JourneyStage.onTheWay ||
JourneyStage.arrived ||
JourneyStage.pickedUp => CustomerMilestone.booked,
JourneyStage.orderCreated => CustomerMilestone.orderCreated,
JourneyStage.inTransit => CustomerMilestone.inTransit,
JourneyStage.outForDelivery => CustomerMilestone.outForDelivery,
JourneyStage.delivered => CustomerMilestone.delivered,

View File

@@ -1,17 +1,31 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'data/app_config.dart';
import 'state/app_scope.dart';
import 'state/app_state.dart';
import 'ui/screens/auth/login_screen.dart';
import 'ui/screens/shell_screen.dart';
import 'ui/screens/splash_screen.dart';
import 'ui/tokens.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
// What this build is, in the log, on every launch.
//
// Not a debugging leftover: the offline build and the auto-login build look
// identical to the real one from the outside, and the whole reason the first
// mock was deleted was that nobody could tell which mode a handset was in.
// The Account screen says it too, but a build that cannot get past the login
// screen cannot show you the Account screen — which is exactly the session
// where you most need to know. Release builds print nothing.
if (!kReleaseMode) debugPrint('[BUILD] ${AppConfig.describe}');
// Draw behind the system bars — expected on Android 15+ and the reason the
// screens manage their own SafeArea insets.
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
@@ -97,7 +111,7 @@ ThemeData buildDoormileTheme() {
return ThemeData(
useMaterial3: true,
fontFamily: 'Manrope',
fontFamily: 'Poppins',
colorScheme: scheme,
scaffoldBackgroundColor: DmColors.bg,
splashFactory: InkSparkle.splashFactory,
@@ -128,8 +142,25 @@ ThemeData buildDoormileTheme() {
);
}
/// Forward navigation slides in from the right and fades, matching the
/// prototype's screen transition.
/// Forward navigation, as a shared axis.
///
/// ── What was wrong with the old one ──
///
/// The incoming page slid 6% from the right and faded, and the page behind it
/// did nothing at all — it sat still until it was covered. That is the single
/// thing that makes a push feel like a slide projector rather than like
/// movement: both surfaces have to travel, or the eye reads the new one as
/// pasted over a photograph of the old one.
///
/// Now the outgoing page slides the other way and dims as it leaves, so the
/// pair share one axis of motion; the incoming page carries a small scale-up
/// so it arrives *towards* the viewer rather than merely across; and the fade
/// is held back until the travel is a third done, which is what stops the two
/// pages muddying into each other mid-transition.
///
/// The timing lives on [DmPageRoute]: 420ms in, 340ms back. Going back is
/// always quicker than going forward — the customer already knows what is
/// behind them.
class _DmPageTransitions extends PageTransitionsBuilder {
const _DmPageTransitions();
@@ -141,19 +172,45 @@ class _DmPageTransitions extends PageTransitionsBuilder {
Animation<double> secondaryAnimation,
Widget child,
) {
final curved = CurvedAnimation(
final enter = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
curve: DmMotion.enter,
reverseCurve: DmMotion.enter.flipped,
);
return FadeTransition(
opacity: curved,
child: SlideTransition(
position: Tween(
begin: const Offset(0.06, 0),
end: Offset.zero,
).animate(curved),
child: child,
final exit = CurvedAnimation(
parent: secondaryAnimation,
curve: DmMotion.enter,
reverseCurve: DmMotion.enter.flipped,
);
return SlideTransition(
// The page being covered leaves to the left, a third of the distance the
// arriving one travels. Parallax: the thing further away moves less.
position: Tween(
begin: Offset.zero,
end: const Offset(-0.09, 0),
).animate(exit),
child: FadeTransition(
opacity: Tween(begin: 1.0, end: 0.6).animate(exit),
child: SlideTransition(
position: Tween(
begin: const Offset(0.14, 0),
end: Offset.zero,
).animate(enter),
child: FadeTransition(
// Held back: at 0 the page is transparent, at 0.35 of the travel
// it is solid. Fading over the whole distance leaves both pages
// half-visible through each other for most of it.
opacity: CurvedAnimation(
parent: animation,
curve: const Interval(0, 0.35, curve: Curves.easeOut),
),
child: ScaleTransition(
scale: Tween(begin: 0.97, end: 1.0).animate(enter),
child: child,
),
),
),
),
);
}
@@ -164,16 +221,32 @@ class _DmPageTransitions extends PageTransitionsBuilder {
/// Three states, and the middle one is the reason this widget exists: until the
/// stored session has been looked for we do not yet know whether this customer
/// is signed in, and showing them a login form that vanishes a moment later is
/// worse than showing them nothing. The Android 12 splash is still up for most
/// of it.
class _Launch extends StatelessWidget {
/// worse than showing them nothing.
///
/// That middle state used to be a blank canvas — correct, and a hole where the
/// app's first impression goes. It is [SplashScreen] now, which owns both the
/// wait and its own minimum, and tells this widget when it is finished. The
/// native window background carries the same mark from process start, so the
/// handover into Flutter is invisible.
class _Launch extends StatefulWidget {
const _Launch();
@override
State<_Launch> createState() => _LaunchState();
}
class _LaunchState extends State<_Launch> {
bool _splashDone = false;
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
if (app.restoringSession) {
return const Scaffold(backgroundColor: DmColors.canvas, body: SizedBox());
if (!_splashDone) {
return SplashScreen(
ready: !app.restoringSession,
onDone: () => setState(() => _splashDone = true),
);
}
return app.isSignedIn ? const ShellScreen() : const LoginScreen();
}

View File

@@ -93,7 +93,7 @@ class AppState extends ChangeNotifier {
final restored = await api.restoreSession();
if (restored != null) {
customer = restored;
unawaited(refreshOrders());
_afterSignIn();
} else if (AppConfig.devAutoLogin) {
// ── Offline builds open on Home, not on a login screen ──
//
@@ -106,7 +106,7 @@ class AppState extends ChangeNotifier {
// Real builds are untouched: `useDevData` is false in every one of
// them, so the login screen a customer meets is the real one.
customer = await api.verifyOtp('+91 9876543210', '1234');
unawaited(refreshOrders());
_afterSignIn();
} else if (AppConfig.hasAutoLogin) {
await _autoSignIn();
}
@@ -146,7 +146,7 @@ class AppState extends ChangeNotifier {
// fails once on a cold Redis and falls through to the request below.
try {
customer = await api.verifyOtp(identifier, code);
unawaited(refreshOrders());
_afterSignIn();
debugPrint('[AUTH] DM_LOGIN_AS — signed in as $identifier');
return;
} catch (_) {
@@ -156,7 +156,7 @@ class AppState extends ChangeNotifier {
try {
await api.sendOtp(identifier);
customer = await api.verifyOtp(identifier, code);
unawaited(refreshOrders());
_afterSignIn();
debugPrint('[AUTH] DM_LOGIN_AS — signed in as $identifier after a request');
} catch (e) {
debugPrint(
@@ -166,6 +166,20 @@ class AppState extends ChangeNotifier {
}
}
/// The two things every signed-in session needs, whichever door it came in
/// through.
///
/// Finding the customer's own location was the OTP screen's job, which meant
/// it only happened on the one path that shows an OTP screen. A restored
/// session, a dev auto-login and `DM_LOGIN_AS` all skipped it — so the app
/// opened on Home reading "Set a pickup address", never asked for the
/// location permission, and the pickup map had nothing to centre on. It is
/// not the entrance's job; it is the session's.
void _afterSignIn() {
unawaited(refreshOrders());
unawaited(detectPickupLocation());
}
/// Called by the API layer when a refresh fails — the chain is dead, so the
/// customer is signed out wherever they happen to be standing.
void onSessionLost() {
@@ -473,6 +487,13 @@ class AppState extends ChangeNotifier {
}
}
/// Who the Miler asks for at the pickup door.
///
/// Null means the signed-in customer, which is the answer nearly every time
/// — so the field on the pickup screen opens prefilled with their number and
/// this stays null until they change it.
String? draftContactPhone;
Future<Booking> confirmBooking() async {
final key = _bookingIdempotencyKey ??= _newIdempotencyKey();
try {
@@ -481,6 +502,7 @@ class AppState extends ChangeNotifier {
destinations: draftDestinations,
slotId: draftSlotId,
fare: draftFare,
contactPhone: draftContactPhone,
idempotencyKey: key,
);
// The intent is spent. A further booking needs a new key or the server
@@ -764,6 +786,23 @@ class AppState extends ChangeNotifier {
selectDistrictFor(index, city.district);
}
/// Replaces the draft's destinations with exactly these, in order.
///
/// The destination sheet answers with a list — one place, or several out of
/// one Miler visit — and this is the only thing that writes it onto the
/// draft. Clamped to [BookingLimits.maxDestinations] rather than trusted:
/// the sheet already obeys the cap, but the cap is a server answer that can
/// change between the sheet opening and this being called, and a draft that
/// exceeds it is a booking the network will refuse.
void setDestinations(List<CityOption> cities) {
if (cities.isEmpty) return;
final take = cities.take(limits.maxDestinations).toList();
draftDestinations = [for (var i = 0; i < take.length; i++) DestinationGroup()];
for (var i = 0; i < take.length; i++) {
selectCity(take[i], index: i);
}
}
/// Names of districts in this state that are not open yet, for a quiet
/// "Coming soon" line. Empty until the districts have been fetched.
List<String> upcomingDistricts(String stateCode) => (districtCache[stateCode] ?? const [])

View File

@@ -71,7 +71,7 @@ class AccountScreen extends StatelessWidget {
child: ListView(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 4, DmSpace.pad, 28),
children: [
const DmMicroHead('Preferences', top: 16),
const DmMicroHead('Preferences', top: 16, brand: false),
DmRowGroup(
children: [
DmRow(

View File

@@ -1,7 +1,9 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter/services.dart';
import '../../../data/app_config.dart';
import '../../tokens.dart';
import '../../widgets/chrome.dart';
@@ -177,6 +179,33 @@ class AuthScaffold extends StatelessWidget {
),
child: footer!,
),
// Which build this is, on the one screen that cannot
// reach the Account panel that normally says so.
//
// The offline build and the real one are identical
// here, and the difference decides whether 1234 signs
// you in or the server refuses it — which has cost two
// rounds of "the code does not work" already. Debug
// only: `kReleaseMode` takes it out of anything that
// could ship.
if (!kReleaseMode)
Padding(
padding: EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
6 + _lift,
),
child: Text(
AppConfig.describe,
textAlign: TextAlign.center,
style: DmText.monoSm.copyWith(
fontSize: 10,
color: DmColors.ink4,
),
maxLines: 2,
),
),
],
),
),

View File

@@ -43,29 +43,39 @@ class OtpScreen extends StatefulWidget {
class _OtpScreenState extends State<OtpScreen> {
late OtpChallenge _challenge = widget.challenge;
late final _boxes = List.generate(
_challenge.codeLength,
(_) => TextEditingController(),
);
late final _focus = List.generate(
_challenge.codeLength,
(_) => FocusNode(),
);
// ── One field, four boxes that only draw ──
//
// This was four separate `TextField`s, each holding one digit and moving
// focus to the next on change. It dropped digits. Type "1234" at any speed a
// real thumb manages and a keystroke lands while focus is still in flight
// between two boxes: it either goes nowhere, or it arrives in the box you
// just left, making that box's value two characters long — which the handler
// read as a *paste* and used to overwrite the whole row. The code ended up
// three digits, and three digits is refused with "That code did not match",
// so the screen blamed the customer for its own bug.
//
// There is one controller now. The boxes below are display: they read a
// character out of [_code] and draw it. Nothing moves focus, so nothing can
// be dropped between two things that have it. Paste works because pasting
// into one field is just text, and [AutofillHints.oneTimeCode] can fill it
// from an SMS for the same reason.
final _code = TextEditingController();
final _node = FocusNode();
Timer? _ticker;
late int _resendIn = _challenge.resendAfterSeconds;
bool _verifying = false;
int get _length => _boxes.length;
int get _length => _challenge.codeLength;
String get _code => _boxes.map((c) => c.text).join();
bool get _complete => _code.length == _length;
String get _digits => _code.text;
bool get _complete => _digits.length == _length;
@override
void initState() {
super.initState();
_startResendTimer();
WidgetsBinding.instance.addPostFrameCallback((_) => _focus.first.requestFocus());
WidgetsBinding.instance.addPostFrameCallback((_) => _node.requestFocus());
}
void _startResendTimer() {
@@ -82,12 +92,8 @@ class _OtpScreenState extends State<OtpScreen> {
@override
void dispose() {
_ticker?.cancel();
for (final c in _boxes) {
c.dispose();
}
for (final f in _focus) {
f.dispose();
}
_code.dispose();
_node.dispose();
super.dispose();
}
@@ -120,7 +126,7 @@ class _OtpScreenState extends State<OtpScreen> {
final app = AppScope.read(context);
final navigator = Navigator.of(context);
try {
await app.verifyOtp(widget.identifier, _code, name: widget.name);
await app.verifyOtp(widget.identifier, _digits, name: widget.name);
if (!mounted) return;
app.detectPickupLocation();
unawaited(HapticFeedback.mediumImpact());
@@ -131,27 +137,13 @@ class _OtpScreenState extends State<OtpScreen> {
} on ApiException catch (e) {
if (!mounted) return;
setState(() => _verifying = false);
for (final c in _boxes) {
c.clear();
}
_focus.first.requestFocus();
_code.clear();
_node.requestFocus();
DmToast.show(context, e.message);
}
}
/// Accepts a pasted code by spreading it across the boxes.
void _onChanged(int index, String value) {
if (value.length > 1) {
final digits = value.replaceAll(RegExp(r'\D'), '');
for (var i = 0; i < _boxes.length; i++) {
_boxes[i].text = i < digits.length ? digits[i] : '';
}
_focus[digits.length.clamp(0, _length - 1)].requestFocus();
} else if (value.isNotEmpty && index < 3) {
_focus[index + 1].requestFocus();
} else if (value.isEmpty && index > 0) {
_focus[index - 1].requestFocus();
}
void _onChanged(String _) {
setState(() {});
if (_complete && !_verifying) _verify();
}
@@ -221,18 +213,50 @@ class _OtpScreenState extends State<OtpScreen> {
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Row(
child: Stack(
children: [
for (var i = 0; i < _length; i++) ...[
if (i > 0) const SizedBox(width: 11),
Expanded(
child: _OtpBox(
controller: _boxes[i],
focusNode: _focus[i],
onChanged: (v) => _onChanged(i, v),
Row(
children: [
for (var i = 0; i < _length; i++) ...[
if (i > 0) const SizedBox(width: 11),
Expanded(
child: _OtpBox(
digit: i < _digits.length ? _digits[i] : '',
// The box the next digit will land in, which is the
// one a cursor would be sitting in. Focus is on the
// field underneath, not on any box.
active: _node.hasFocus && i == _digits.length,
),
),
],
],
),
// The real field, over the top and invisible. Over, so a tap
// anywhere on the row opens the keyboard; invisible, because
// the boxes are already drawing everything it holds.
Positioned.fill(
child: Opacity(
opacity: 0,
child: TextField(
controller: _code,
focusNode: _node,
onChanged: _onChanged,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.oneTimeCode],
enableInteractiveSelection: false,
showCursor: false,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(_length),
],
decoration: const InputDecoration(
counterText: '',
border: InputBorder.none,
),
),
),
],
),
],
),
),
@@ -273,57 +297,35 @@ class _OtpScreenState extends State<OtpScreen> {
}
}
/// One digit of the code.
/// One digit of the code — a drawing, not a field.
///
/// Was a filled `TextField` with no border, which read as a disabled field:
/// four grey slabs with nothing to say which one you were typing into. It is
/// the only thing on the screen, so it carries the state instead — empty is
/// outlined, focused takes the brand edge and lifts, filled tints. The
/// `TextField` inside is bare; the container owns every visual.
class _OtpBox extends StatefulWidget {
const _OtpBox({
required this.controller,
required this.focusNode,
required this.onChanged,
});
/// It used to be a real `TextField`, one of four, and the focus dance between
/// them is what dropped digits. It holds no controller and no focus node now:
/// [digit] is what to show and [active] is whether the next keystroke lands
/// here. Everything typed lives in the single field stacked over the row.
///
/// The states are unchanged — empty is outlined, active takes the brand edge
/// and lifts, filled tints — because it is still the only thing on the screen
/// and still has to carry them.
class _OtpBox extends StatelessWidget {
const _OtpBox({required this.digit, required this.active});
final TextEditingController controller;
final FocusNode focusNode;
final ValueChanged<String> onChanged;
@override
State<_OtpBox> createState() => _OtpBoxState();
}
class _OtpBoxState extends State<_OtpBox> {
@override
void initState() {
super.initState();
widget.focusNode.addListener(_onFocus);
}
@override
void dispose() {
widget.focusNode.removeListener(_onFocus);
super.dispose();
}
void _onFocus() => setState(() {});
final String digit;
final bool active;
@override
Widget build(BuildContext context) {
final focused = widget.focusNode.hasFocus;
final filled = widget.controller.text.isNotEmpty;
final filled = digit.isNotEmpty;
return AnimatedContainer(
duration: DmMotion.fast,
curve: DmMotion.ease,
height: 60,
decoration: BoxDecoration(
color: filled && !focused ? DmColors.brandSoft : DmColors.surface,
color: filled && !active ? DmColors.brandSoft : DmColors.surface,
borderRadius: DmRadius.all(DmRadius.md),
border: Border.all(
color: focused
color: active
? DmColors.brand
: filled
? DmColors.brandLine
@@ -331,7 +333,7 @@ class _OtpBoxState extends State<_OtpBox> {
),
// The focus ring, exactly as the fields draw it — this is the same
// control, one digit wide.
boxShadow: focused
boxShadow: active
? const [
BoxShadow(
color: DmColors.brandSoft,
@@ -341,29 +343,8 @@ class _OtpBoxState extends State<_OtpBox> {
]
: null,
),
child: Center(
child: TextField(
controller: widget.controller,
focusNode: widget.focusNode,
onChanged: widget.onChanged,
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
cursorColor: DmColors.brand,
cursorWidth: 2,
showCursor: true,
style: DmText.otp,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
counterText: '',
isDense: true,
filled: false,
contentPadding: EdgeInsets.zero,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
),
),
),
alignment: Alignment.center,
child: Text(digit, style: DmText.otp),
);
}
}

View File

@@ -1,12 +1,10 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/doormile_api.dart';
import '../../../state/app_scope.dart';
import '../../widgets/buttons.dart';
import '../../widgets/feedback.dart';
import '../../widgets/inputs.dart';
import '../../widgets/pieces.dart';
import 'auth_scaffold.dart';
import 'otp_screen.dart';
@@ -82,8 +80,11 @@ class _SignUpScreenState extends State<SignUpScreen> {
// sign up are one doorway with two doors, so they cannot be laid out
// differently — a customer who bounces between them should see the
// screen change its words, not its shape.
heroTitle: 'Create your account.',
heroSub: 'A few details and you can book your first pickup.',
// One line, no supporting sentence. "A few details and you can book
// your first pickup" described the three fields directly underneath it,
// on a screen whose whole content is those three fields.
heroTitle: 'Create your account',
badge: false,
// Pinned for the same reason as sign in: a three-field form plus the
// keyboard leaves no room, and the CTA must not be the thing that loses.
footer: Column(
@@ -106,17 +107,22 @@ class _SignUpScreenState extends State<SignUpScreen> {
],
),
children: [
// Floating labels, and no hints behind them: a caption above an empty
// field and an example inside it are two ways of asking the same
// question, and three of those stacked is six rows of chrome around
// three answers. The label sits where the answer will go and floats
// out once there is something to caption.
DmTextField(
label: 'Full name',
floating: true,
controller: _name,
hint: 'Joe Oommen',
keyboardType: TextInputType.name,
textInputAction: TextInputAction.next,
),
DmTextField(
label: 'Phone number',
floating: true,
controller: _phone,
hint: '98765 43210',
prefix: '+91',
keyboardType: TextInputType.phone,
digitsOnly: true,
@@ -125,21 +131,24 @@ class _SignUpScreenState extends State<SignUpScreen> {
),
DmTextField(
label: 'Email',
floating: true,
optional: true,
controller: _email,
hint: 'you@example.com',
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _valid ? _continue() : null,
),
// The same reassurance sign in carries, in the same wash card, under
// the field it is about.
const DmInfoBanner(
icon: LucideIcons.shieldCheck,
title: 'We verify every number',
message: "A 4-digit code confirms it's you and keeps your parcels "
'secure.',
),
// ── The reassurance banner is gone ──
//
// "We verify every number · A 4-digit code confirms it's you and keeps
// your parcels secure." It was the last thing in a scrolling list
// under a pinned footer, so on a 720p phone the footer cut it in half
// and the customer read "…keeps your parcels secu". A reassurance that
// is clipped reassures nobody.
//
// It also answered a question nobody had yet: the customer finds out
// about the code on the next screen, which is called "Verify your
// number" and says so.
],
);
}

View File

@@ -15,9 +15,26 @@ class BookingRoutes {
}
/// A page route tagged with its step name.
///
/// [DmPageRoute] rather than [MaterialPageRoute] so the flow's own transition
/// gets the time it needs — see `_DmPageTransitions` in `main.dart`. Material's
/// 300ms is tuned for a page that slides the full width; this one travels a
/// short distance and reads as clipped at that speed.
Route<T> bookingRoute<T>(String name, Widget Function(BuildContext) builder) {
return MaterialPageRoute<T>(
return DmPageRoute<T>(
settings: RouteSettings(name: name),
builder: builder,
);
}
/// The app's page route. Same transition as every other push — it is the
/// duration that differs from Material's.
class DmPageRoute<T> extends MaterialPageRoute<T> {
DmPageRoute({required super.builder, super.settings});
@override
Duration get transitionDuration => const Duration(milliseconds: 420);
@override
Duration get reverseTransitionDuration => const Duration(milliseconds: 340);
}

View File

@@ -28,6 +28,25 @@ import 'booking_routes.dart';
/// route as a thread, and the live search for a Miler as a row on that same
/// card. The map is gone: the customer chose this route two screens ago and a
/// second picture of it says nothing new while the parcel has not moved.
///
/// ── The third pass: the tick left the red card ──
///
/// See the comment on the header below. Crimson is the app's action colour and
/// this is the one screen with nothing left to do, so the payoff is a white
/// tick on green, centred, with no box around it.
///
/// ── The second pass, 2026-09-22 ──
///
/// The crimson card still carried a two-line sentence — "We've reserved your
/// slot. A nearby Miler is being assigned to collect your package." — under a
/// heading that already said *Pickup booked*, and the searching row underneath
/// was saying the second half of it again, live. The window took its place:
/// after "did it work?", *when are they coming?* is the only question this
/// screen is asked, and it was four surfaces down in a tinted strip.
///
/// The "Keep the parcel ready" banner went too. It is advice for later, on the
/// screen where the customer is least likely to be still reading, and tracking
/// repeats it when it matters.
class ConfirmedScreen extends StatefulWidget {
const ConfirmedScreen({super.key});
@@ -53,7 +72,6 @@ class _ConfirmedScreenState extends State<ConfirmedScreen>
final app = AppScope.of(context);
final booking = app.trackedBooking;
final slot = booking == null ? null : app.slotById(booking.slotId);
final packages = booking?.totalPackages ?? 1;
return Scaffold(
backgroundColor: DmColors.canvas,
@@ -61,52 +79,69 @@ class _ConfirmedScreenState extends State<ConfirmedScreen>
child: ListView(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 22, DmSpace.pad, 24),
children: [
DmCard(
tone: DmCardTone.fill,
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(18, 22, 18, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ScaleTransition(
scale: CurvedAnimation(
parent: _pop,
curve: Curves.easeOutBack,
),
child: Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: DmColors.onBrandRaise,
shape: BoxShape.circle,
// ── The payoff is centred and unboxed ──
//
// It was a filled crimson card: a translucent disc holding a tick,
// then "Pickup booked", then the window, all inside the same red
// rectangle. Three problems. Crimson is the app's *action* colour —
// the sphere, the button, the live tracking card — so a screen
// whose entire job is "this is finished" was wearing the colour
// that means "do something". A tick on a wash of its own background
// reads as a watermark rather than a confirmation. And a card here
// is a box drawn around the one thing on the screen that has
// nothing to be separated from.
//
// A white tick on a green disc is what every app that has to say
// "this worked" settles on, and green is already in this design
// system for exactly one meaning: completed. It is used here for
// the first time outside a delivered order, which is the only other
// place the app gets to say the same word.
Center(
child: Column(
children: [
ScaleTransition(
scale: CurvedAnimation(
parent: _pop,
curve: Curves.easeOutBack,
),
child: Container(
width: 76,
height: 76,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: DmColors.ok,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Color(0x3D0A6B56),
blurRadius: 24,
spreadRadius: -6,
offset: Offset(0, 10),
),
child: const Icon(
LucideIcons.check,
size: 24,
color: Colors.white,
),
),
],
),
const SizedBox(height: 16),
Text(
'Pickup booked',
style: DmText.display.copyWith(color: Colors.white),
child: const Icon(
LucideIcons.check,
size: 38,
color: Colors.white,
),
const SizedBox(height: 8),
Text(
booking == null
? "We've reserved your slot."
: "We've reserved your slot. A nearby Miler is being "
'assigned to collect '
'${packages == 1 ? 'your package' : 'your packages'}.',
style: DmText.lede.copyWith(color: DmColors.onBrand2),
),
],
),
),
),
],
const SizedBox(height: 22),
Text('Pickup booked', style: DmText.display),
const SizedBox(height: 6),
Text(
slot == null
? 'Your slot is reserved'
: '${slot.day} · ${slot.window}',
style: DmText.heading.copyWith(
fontSize: 18,
color: DmColors.ink2,
),
),
const SizedBox(height: 26),
],
),
),
if (booking != null) ...[
const SizedBox(height: 10),
@@ -144,46 +179,6 @@ class _ConfirmedScreenState extends State<ConfirmedScreen>
],
),
),
DmCardCell(
background: DmColors.brandSoft,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
children: [
const Icon(
LucideIcons.calendarCheck,
size: 15,
color: DmColors.brand,
),
const SizedBox(width: 10),
Expanded(
child: Text(
// The day rides with the label so the window itself
// stays on one line. "Scheduled window" against
// "Today · 2:00 – 4:00 PM" broke across two, and
// wrapped a time range mid-range.
slot == null
? 'Scheduled window'
: 'Scheduled ${slot.day.toLowerCase()}',
style: DmText.small.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 10),
Flexible(
child: Text(
slot?.window ?? '—',
textAlign: TextAlign.right,
style: DmText.cardTitle.copyWith(
color: DmColors.brandPress,
),
),
),
],
),
),
// The search is live from the moment this screen opens, so it
// belongs on the card rather than in a banner underneath it.
DmCardCell(
@@ -218,13 +213,6 @@ class _ConfirmedScreenState extends State<ConfirmedScreen>
),
],
),
const SizedBox(height: 10),
const DmInfoBanner(
icon: LucideIcons.package,
tone: DmTagTone.neutral,
title: 'Keep the parcel ready',
message: 'Sealed and labelled with the reference above.',
),
],
],
),

View File

@@ -5,6 +5,7 @@ import '../../../data/location_service.dart';
import '../../../state/app_scope.dart';
import '../../../state/app_state.dart';
import '../../tokens.dart';
import '../../widgets/inputs.dart';
import '../../widgets/buttons.dart';
import '../../widgets/cards.dart';
import '../../widgets/chrome.dart';
@@ -28,9 +29,22 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
/// True between the first touch on the map and the address coming back.
bool _pinMoving = false;
/// Who the Miler asks for at this door.
///
/// Opens on the account's number, because that is the answer nearly every
/// time and typing it again is work for nothing. It is a field rather than a
/// line of text because it is often *not* the answer: the parcel is with a
/// colleague, a shop, a parent — somebody who is actually at the address
/// when the Miler arrives, and who the customer would otherwise have to
/// explain in a remarks box nobody reads.
final _contact = TextEditingController();
@override
void initState() {
super.initState();
final app = AppScope.read(context);
_contact.text = _digitsOf(app.draftContactPhone ?? app.customer?.phone);
// Arriving without a pin — ask the device where we are.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
@@ -41,6 +55,29 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
});
}
@override
void dispose() {
_contact.dispose();
super.dispose();
}
/// The ten digits, whatever shape the number arrived in — the account's is
/// "+91 98765 43210" and the field carries its own prefix.
static String _digitsOf(String? phone) {
final digits = (phone ?? '').replaceAll(RegExp(r'\D'), '');
return digits.length <= 10 ? digits : digits.substring(digits.length - 10);
}
/// Null when it is still the customer's own number, so the request carries
/// the account rather than a copy of it that can drift.
void _commitContact(AppState app) {
final typed = _contact.text.trim();
app.draftContactPhone =
typed.isEmpty || typed == _digitsOf(app.customer?.phone)
? null
: '+91 $typed';
}
Future<void> _search(AppState app) async {
final picked = await showPlaceSearchSheet(context);
if (picked == null || !mounted) return;
@@ -113,6 +150,24 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
resolving: resolving,
onChange: () => _search(app),
),
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
16,
DmSpace.pad,
0,
),
child: DmTextField(
label: 'Who the Miler asks for',
controller: _contact,
prefix: '+91',
keyboardType: TextInputType.phone,
digitsOnly: true,
maxLength: 10,
mono: true,
textInputAction: TextInputAction.done,
),
),
],
),
),
@@ -147,6 +202,7 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
? null
: () {
app.draftPickup = place;
_commitContact(app);
Navigator.of(context).maybePop();
},
),

View File

@@ -5,41 +5,63 @@ import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/doormile_api.dart';
import '../../../data/models.dart';
import '../../../state/app_scope.dart';
import '../../../state/app_state.dart';
import '../../tokens.dart';
import '../../widgets/buttons.dart';
import '../../widgets/cards.dart';
import '../../widgets/chrome.dart';
import '../../widgets/feedback.dart';
import '../../widgets/inputs.dart';
import '../../widgets/pieces.dart';
import '../../widgets/states.dart';
import '../../widgets/summary.dart';
import '../sheets/destination_sheet.dart';
import '../sheets/window_sheet.dart';
import 'booking_routes.dart';
import 'confirmed_screen.dart';
import 'pickup_location_screen.dart';
/// Send a parcel — the whole booking, on one screen.
/// The rest of the booking, once the destination is known.
///
/// ── Why this replaced four screens ──
/// ── What this screen stopped asking ──
///
/// Booking used to be pickup point → destination → window → review: four page
/// turns, three of which asked one question each, and a review step whose job
/// was to show you what you had just typed on the three screens before it. The
/// customer could not see what the send would cost until screen four, and could
/// not see the shape of what they were arranging at all.
/// It used to ask for the destination too, as a horizontal strip of cards
/// halfway down, which meant it opened as a form the customer could not yet
/// complete under a disabled button reading "Pick a city to see the price".
/// The destination is chosen in a sheet before this screen exists, so this one
/// opens with a price already on it.
///
/// It is one screen now. Everything the contract needs is here — where it is
/// going, the street address, who receives it, the window, how many packages —
/// and the price sits in the footer beside the button that agrees to it. The
/// two escape hatches stay as they were: the map pin editor for the pickup
/// point, and the window sheet for a slot other than the first.
/// It also used to carry: a crimson head over each of four groups, a wash card
/// with an icon tile and three lines for the pickup address, a "Street address"
/// head above a field labelled "Flat or house number", a card holding the
/// window beside the package count, and a three-sentence paragraph about
/// charging. All of that said things that did not change what the customer was
/// about to do.
///
/// The state hierarchy is flattened on the way in (see [AppState.loadCities]):
/// nobody picks "Tamil Nadu" on the way to picking "Chennai".
/// ── And then the address fields went too ──
///
/// "Flat or house number" and "Street and area" were the last two things this
/// screen asked for, and they were asked on the screen where the customer has
/// already decided and just wants to be finished. They are optional on the
/// contract — every test that books without them has always passed — and the
/// Miler gets the door from the recipient's phone number at the destination
/// end, which is behind the one disclosure that remains.
///
/// [DeliveryDetails.building] and `.street` still exist and still go out on
/// the request. They are simply left unset. If they come back, they belong on
/// a screen of their own after the booking is made, not in front of it.
///
/// ── And then the disclosure went ──
///
/// "Add landmark or recipient details" folded three more optional fields away,
/// which was an improvement on showing them and still one more thing between
/// the customer and the button. The contact the Miler actually needs is the
/// one at the *pickup* door, and that is asked for where the pickup is chosen,
/// prefilled from the account. Per-destination recipient details belong to
/// whoever is receiving, on a screen after the booking exists.
///
/// ── What is left ──
///
/// The route — where it comes from, where it goes, one row per destination.
/// How many packages. And a footer carrying the two commitments, the window
/// and the price, above the button that agrees to them.
class SendScreen extends StatefulWidget {
const SendScreen({super.key});
@@ -48,76 +70,19 @@ class SendScreen extends StatefulWidget {
}
class _SendScreenState extends State<SendScreen> {
final _building = TextEditingController();
final _street = TextEditingController();
final _landmark = TextEditingController();
final _recipient = TextEditingController();
final _phone = TextEditingController();
bool _busy = false;
/// Whether the optional block — landmark and recipient — is open.
///
/// Starts closed, and deliberately stays closed on a screen the customer
/// comes back to: reopening it because something is filled in would undo the
/// point of folding it away, and the summary row already says what is there.
bool _showMore = false;
/// How many of the three optional fields have something in them.
int _filledOptional() => [
_landmark,
_recipient,
_phone,
].where((c) => c.text.trim().isNotEmpty).length;
/// The one row that stands in for three fields.
///
/// Counts rather than lists, because the count is what a customer scrolling
/// past needs — "is anything in there?" — and the labels are one tap away.
String _optionalSummary() {
final n = _filledOptional();
if (n == 0) return 'Add landmark or recipient details';
return '$n of 3 extra details added';
}
@override
void dispose() {
for (final c in [_building, _street, _landmark, _recipient, _phone]) {
c.dispose();
}
super.dispose();
}
/// Everything the customer typed, onto the draft, in one move.
///
/// Written at book time rather than on every keystroke: none of it changes
/// the price or unlocks the button, so rebuilding the screen as someone types
/// their street name would be work with nothing to show for it.
void _commitDetails(AppState app) {
app.draftDestinations.first.details
..building = _clean(_building.text)
..street = _clean(_street.text)
..landmark = _clean(_landmark.text)
..recipientName = _clean(_recipient.text)
..recipientPhone = _phone.text.trim().isEmpty
? null
: '+91 ${_phone.text.trim()}';
}
static String? _clean(String v) {
final t = v.trim();
return t.isEmpty ? null : t;
}
Future<void> _book(AppState app) async {
_commitDetails(app);
setState(() => _busy = true);
final navigator = Navigator.of(context);
try {
await app.confirmBooking();
unawaited(HapticFeedback.mediumImpact());
navigator.pushAndRemoveUntil(
bookingRoute<void>(BookingRoutes.confirmed, (_) => const ConfirmedScreen()),
bookingRoute<void>(
BookingRoutes.confirmed,
(_) => const ConfirmedScreen(),
),
(route) => route.isFirst,
);
} on ApiException catch (e) {
@@ -143,220 +108,89 @@ class _SendScreenState extends State<SendScreen> {
final slot = app.slotById(app.draftSlotId);
final fare = app.draftFare;
final hasCity = city.districtCode != null;
final ready = hasCity && app.draftSlotId != null && app.draftReady;
final ready = city.districtCode != null &&
app.draftSlotId != null &&
app.draftReady;
return Scaffold(
backgroundColor: DmColors.canvas,
appBar: const DmTopBar(title: 'Send a parcel'),
appBar: const DmTopBar(),
body: ListView(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 28),
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 24),
children: [
// Where it comes from, settled before this screen opened.
DmCard(
tone: DmCardTone.wash,
children: [
DmCardRow(
tone: DmCardTone.wash,
leading: const DmTile(
icon: LucideIcons.mapPin,
tone: DmTileTone.wash,
),
eyebrow: 'Collecting from',
title: pickup?.title ?? 'Set a pickup address',
subtitle: pickup?.sub,
trailing: DmTextAction(
label: 'Change',
onPressed: () => Navigator.of(context).push(
bookingRoute<void>(
BookingRoutes.pickup,
(_) => const PickupLocationScreen(),
),
),
),
// Where it comes from and where it goes, as one object.
//
// These were two separate blocks — an eyebrow, a 25pt crimson city
// and a Change action, then a bordered line with a label, an
// address and a second Change. Seven strings and two identical
// words to say what a thread between two points says without any.
_RouteHead(
from: pickup?.title ?? 'Set a pickup address',
to: [for (final g in app.draftDestinations) g.destination.label],
onTapFrom: () => Navigator.of(context).push(
bookingRoute<void>(
BookingRoutes.pickup,
(_) => const PickupLocationScreen(),
),
],
),
const DmMicroHead('Sending to', top: 18),
_CityStrip(
selectedCode: city.districtCode,
onPick: (picked) {
app.selectCity(picked);
// The window is worth nothing without a destination and is
// almost always the first one, so it is chosen here rather than
// asked for — the row below says which, and stays changeable.
unawaited(_ensureSlot(app));
),
onTapTo: () async {
final picked = await showDestinationSheet(context);
if (picked != null && picked.isNotEmpty && context.mounted) {
app.setDestinations(picked);
}
},
),
// ── Neutral from here down ──
//
// This head was crimson, and so were "Collecting from", "Sending to"
// and "Recipient" — four accent heads on one screen, above a wash
// card that is itself tinted. An accent used five times is not an
// accent; it flattens into decoration and stops pointing at anything.
// Crimson now marks one thing on this screen — the destination, which
// is the only choice that changes the price — and everything
// structural is quiet.
const DmMicroHead('Street address', top: 18, brand: false),
DmTextField(
label: 'Flat or house number',
controller: _building,
hint: '12B, Nehru Apartments',
textInputAction: TextInputAction.next,
),
DmTextField(
label: 'Street and area',
controller: _street,
hint: 'Anna Nagar West, 3rd Street',
textInputAction: TextInputAction.next,
),
// ── The optional half, folded away ──
//
// Landmark, recipient name and recipient phone are all optional, and
// all three were drawn at exactly the weight of the two fields that
// are not. That put six rows of "you may skip this" between the
// address and the button, so the common path — city, flat, street,
// book — scrolled past everything it did not need in order to reach
// the thing it did.
//
// They are one row now. It counts what has been filled in rather than
// just saying "optional", because a customer who has added a phone
// number and scrolled away needs to see that it is still there
// without opening anything.
const SizedBox(height: 4),
const SizedBox(height: 14),
DmCard(
// One row, no card around it: a card holding a single control is a
// border drawn for its own sake.
Row(
children: [
DmCardRow(
leading: Icon(
_showMore
? LucideIcons.chevronDown
: LucideIcons.chevronRight,
size: 16,
color: DmColors.ink3,
const Icon(
LucideIcons.package,
size: 17,
color: DmColors.ink3,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'${group.packageCount} '
'${group.packageCount == 1 ? 'package' : 'packages'}',
style: DmText.cardTitle,
),
title: _optionalSummary(),
titleStyle: DmText.cardTitle.copyWith(
color: _filledOptional() == 0 ? DmColors.ink3 : null,
),
onTap: () => setState(() => _showMore = !_showMore),
),
DmStepper(
value: group.packageCount,
min: 1,
canIncrease: app.canAddPackageTo(0),
onChanged: (v) => app.setPackageCount(0, v),
),
],
),
if (_showMore) ...[
const SizedBox(height: 4),
DmTextField(
label: 'Landmark',
optional: true,
controller: _landmark,
hint: 'Near the bus stand',
textInputAction: TextInputAction.next,
),
DmTextField(
label: 'Recipient name',
optional: true,
controller: _recipient,
hint: 'Meera Anand',
keyboardType: TextInputType.name,
textInputAction: TextInputAction.next,
),
DmTextField(
label: 'Recipient phone',
optional: true,
controller: _phone,
hint: '90031 44518',
prefix: '+91',
keyboardType: TextInputType.phone,
digitsOnly: true,
maxLength: 10,
mono: true,
textInputAction: TextInputAction.done,
),
],
const SizedBox(height: 10),
// The two facts that are already decided, as rows rather than steps.
DmCard(
children: [
DmCardRow(
leading: const Icon(
LucideIcons.clock,
size: 16,
color: DmColors.ink3,
),
title: slot == null ? 'Choose a pickup window' : slot.label,
titleStyle: slot == null
? DmText.cardTitle.copyWith(color: DmColors.ink3)
: null,
trailing: DmTextAction(
label: slot == null ? 'Choose' : 'Change',
onPressed: () async {
await showWindowSheet(context);
},
),
),
DmCardRow(
leading: const Icon(
LucideIcons.package,
size: 16,
color: DmColors.ink3,
),
title:
'${group.packageCount} '
'${group.packageCount == 1 ? 'package' : 'packages'}',
trailing: DmStepper(
value: group.packageCount,
min: 1,
canIncrease: app.canAddPackageTo(0),
onChanged: (v) => app.setPackageCount(0, v),
),
),
],
),
const SizedBox(height: 12),
DmNote(
child: Text(
'This books one Miler visit. Nothing is charged now — the final '
'amount is confirmed after the Miler weighs your parcel at the '
'door, and cancelling is free until then.',
style: DmText.small.copyWith(height: 1.5),
),
const SizedBox(height: 20),
// Was three sentences about weighing, charging and cancelling. The
// customer is about to commit, so the only part that changes the
// decision is that committing costs nothing yet.
Text(
'Nothing is charged now · Free cancellation',
textAlign: TextAlign.center,
style: DmText.tiny.copyWith(color: DmColors.ink3),
),
],
),
bottomNavigationBar: DmFooter(
edge: true,
children: [
// The price sits directly above the button that agrees to it.
if (ready && fare != null)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Expanded(child: Text('Estimated total', style: DmText.small)),
const SizedBox(width: 12),
Text(
fare.range,
style: DmText.monoLg.copyWith(
fontSize: 20,
color: DmColors.brand,
),
),
],
),
),
_Commitments(
window: slot == null ? null : '${slot.day} · ${slot.window}',
price: fare?.range,
onChangeWindow: () => showWindowSheet(context),
),
const SizedBox(height: 12),
DmButton(
label: switch (true) {
_ when !hasCity => 'Pick a city to see the price',
_ when app.draftSlotId == null => 'Choose a pickup window',
_ => 'Book pickup',
},
label: 'Book pickup',
icon: ready ? LucideIcons.arrowRight : null,
busy: _busy,
busyLabel: 'Booking…',
@@ -367,136 +201,133 @@ class _SendScreenState extends State<SendScreen> {
);
}
/// Picks the first window the backend still has, if none is chosen yet.
Future<void> _ensureSlot(AppState app) async {
if (app.draftSlotId != null) return;
try {
final slots = await app.loadSlots();
final first = slots.where((s) => s.available).firstOrNull;
if (first != null && mounted && app.draftSlotId == null) {
app.selectSlot(first);
}
} on ApiException {
// No windows is a state the row already renders; it is not an error to
// interrupt someone's typing over.
}
}
}
/// The serviceable cities, as one horizontal strip.
class _CityStrip extends StatelessWidget {
const _CityStrip({required this.selectedCode, required this.onPick});
/// Where it comes from, where it goes, and both of them tappable.
///
/// Deliberately not [DmRouteRail]: that one is a read-only thread used on
/// receipts, orders and tracking, and giving it tap targets would make every
/// other use of it look pressable. This is two controls that happen to be
/// drawn as a thread.
///
/// There are no "Change" labels. A chevron at the end of a row is the whole
/// sentence, and this screen had two of the word stacked one above the other.
class _RouteHead extends StatelessWidget {
const _RouteHead({
required this.from,
required this.to,
required this.onTapFrom,
required this.onTapTo,
});
final String? selectedCode;
final ValueChanged<CityOption> onPick;
final String from;
/// One row per destination. A pickup that fans out to three places is three
/// rows on one thread, not "3 destinations" standing in for them — the whole
/// point of the screen is that the customer can see what they arranged.
final List<String> to;
final VoidCallback onTapFrom;
final VoidCallback onTapTo;
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
// The height belongs to the LIST, not to the section: an empty or errored
// strip renders a full-size state with a retry, and boxing that into 104pt
// overflowed it by 227.
return DmAsyncList<CityOption>(
key: const ValueKey('cityStrip'),
skeletonRows: 1,
load: ({bool refresh = false}) => app.loadCities(refresh: refresh),
emptyIcon: LucideIcons.mapPinOff,
emptyTitle: 'No cities open yet',
emptyMessage:
"We're not accepting new pickups right now. Please check back "
'shortly.',
builder: (context, cities) => SizedBox(
// Grows with the type inside it. The card holds a glyph, a city and a
// promise; at 1.8x accessibility text a fixed 104 clipped the promise
// by four points.
height: 104 +
(MediaQuery.textScalerOf(context).scale(13.5) - 13.5) * 2,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.zero,
itemCount: cities.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (context, i) => _CityCard(
city: cities[i],
selected: cities[i].district.code == selectedCode,
onTap: () => onPick(cities[i]),
return Container(
padding: const EdgeInsets.symmetric(vertical: 4),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: DmColors.border)),
),
child: Column(
children: [
_RouteRow(
icon: LucideIcons.locateFixed,
eyebrow: 'FROM',
value: from,
onTap: onTapFrom,
),
),
for (var i = 0; i < to.length; i++)
_RouteRow(
icon: LucideIcons.mapPin,
// Numbered only when there is more than one, because "TO 1" on a
// single-destination booking is a count of one.
eyebrow: to.length == 1 ? 'TO' : 'TO · ${i + 1}',
value: to[i],
onTap: onTapTo,
strong: true,
),
],
),
);
}
}
class _CityCard extends StatelessWidget {
const _CityCard({
required this.city,
required this.selected,
class _RouteRow extends StatelessWidget {
const _RouteRow({
required this.icon,
required this.eyebrow,
required this.value,
required this.onTap,
this.strong = false,
});
final CityOption city;
final bool selected;
final IconData icon;
final String eyebrow;
final String value;
final VoidCallback onTap;
/// The destination is set in the brand colour: it is the only choice on this
/// screen that moves the price.
final bool strong;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
selected: selected,
label: '$eyebrow $value',
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
HapticFeedback.selectionClick();
onTap();
},
child: AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
// Wide enough for the promise under the name — "Next-day delivery"
// ellipsized on every card at 110, which made the one fact that
// distinguishes two cities unreadable on both of them.
width: 132,
padding: const EdgeInsets.fromLTRB(11, 12, 11, 10),
decoration: BoxDecoration(
// A wash and a crimson edge rather than a fill: a strip of these is
// scanned side by side, and one solid block pulls the eye off the
// neighbours still being compared.
color: selected ? DmColors.brandSoft : DmColors.surface,
border: Border.all(
color: selected ? DmColors.brand : DmColors.border,
),
borderRadius: DmRadius.all(DmRadius.md),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
onTap: onTap,
child: Container(
constraints: const BoxConstraints(minHeight: DmSpace.tap),
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(
selected ? LucideIcons.mapPin : LucideIcons.building2,
size: 22,
color: selected ? DmColors.brand : DmColors.ink3,
icon,
size: 18,
color: strong ? DmColors.brand : DmColors.ink3,
),
const Spacer(),
Text(
city.district.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.cardTitle.copyWith(
fontSize: 13.5,
color: selected ? DmColors.brand : DmColors.ink,
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(eyebrow, style: DmText.eyebrow),
Text(
value,
// One weight down from where this started. At w700
// crimson, five destinations stacked read as five
// headings — the page shouted its own contents back. A
// list of places the customer chose is a list, not a
// series of titles.
style: DmText.body.copyWith(
fontSize: 15.5,
fontWeight: FontWeight.w600,
color: strong ? DmColors.brand : DmColors.ink,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
if (city.note != null)
Text(
city.note!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.tiny.copyWith(
fontSize: 11,
color: selected ? DmColors.brandPress : DmColors.ink3,
),
),
const SizedBox(width: 10),
const Icon(
LucideIcons.chevronRight,
size: 17,
color: DmColors.ink4,
),
],
),
),
@@ -504,3 +335,83 @@ class _CityCard extends StatelessWidget {
);
}
}
/// The two things the button is about to agree to, side by side above it.
///
/// The window used to be a row in a card in the middle of the form and the
/// price used to appear in the footer only once everything was valid, which
/// meant the customer had to scroll back up to check what time they had been
/// given. Both live here now, and the left half is tappable.
class _Commitments extends StatelessWidget {
const _Commitments({
required this.window,
required this.price,
required this.onChangeWindow,
});
final String? window;
final String? price;
final VoidCallback onChangeWindow;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Semantics(
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onChangeWindow,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('PICKUP', style: DmText.eyebrow),
const SizedBox(height: 3),
Row(
children: [
Flexible(
child: Text(
window ?? 'Choose a window',
style: DmText.cardTitle.copyWith(
color: window == null ? DmColors.brand : null,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 4),
const Icon(
LucideIcons.chevronDown,
size: 15,
color: DmColors.ink3,
),
],
),
],
),
),
),
),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Text('ESTIMATED', style: DmText.eyebrow),
const SizedBox(height: 3),
Text(
price ?? '—',
style: DmText.monoLg.copyWith(
fontSize: 18,
color: DmColors.brand,
),
),
],
),
],
);
}
}

View File

@@ -1,41 +1,96 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../data/models.dart';
import '../../state/app_scope.dart';
import '../format.dart';
import '../tokens.dart';
import '../widgets/buttons.dart';
import '../widgets/cards.dart';
import '../widgets/book_orb.dart';
import '../widgets/chrome.dart';
import '../widgets/misc.dart';
import '../widgets/route_rail.dart';
import 'booking/booking_routes.dart';
import 'booking/send_screen.dart';
import 'shell_screen.dart';
import 'sheets/destination_sheet.dart';
import 'sheets/place_search_sheet.dart';
import 'sheets/window_sheet.dart';
import 'tracking_screen.dart';
/// Home — one question, one dominant action, and whatever is live right now.
/// Nothing else competes; history belongs to the Orders tab.
class HomeScreen extends StatelessWidget {
/// Home — two questions, and nothing else on the screen.
///
/// *Can I book?* The sphere.
/// *Is anything happening?* One row, when there is.
///
/// ── What was removed ──
///
/// A two-line caption under the sphere that a returning customer had read a
/// hundred times; a four-cell card for the live booking carrying a status pill,
/// a reference, a route rail, a courier sentence, a window and a Track button —
/// six facts where the customer wanted two; and a three-row explainer with a
/// subtitle under every row.
///
/// The caption is first-run only now and one line. The live booking is one row.
/// The explainer lost its subtitles. Home went from roughly sixty words to
/// under twenty for a new customer, and to five for a returning one.
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
/// True while the destination sheet is up.
///
/// Home does not just sit behind the scrim — it scales back a little and
/// fades, so the sheet reads as having been pushed up by the sphere rather
/// than as a new screen that arrived over the top of an unrelated one.
bool _handingOver = false;
/// BOOK. The one action on this screen, and the whole flow's front door.
///
/// Two questions are asked here, as sheets over Home, before any form:
///
/// where is it going? → when shall we collect? → the details
///
/// Both used to be answered somewhere else. The destination was a strip of
/// cards halfway down the form; the window was picked *for* the customer,
/// silently, the moment they chose a city — they arrived at a booking with a
/// time on it that they had never agreed to. A pickup window is a promise
/// about somebody's afternoon, and it is not ours to assume.
///
/// Dismissing the window sheet is allowed: the send screen carries "Choose a
/// window" in the footer and its button stays disabled until there is one.
Future<void> _book() async {
final app = AppScope.read(context);
final navigator = Navigator.of(context);
setState(() => _handingOver = true);
try {
final places = await showDestinationSheet(context);
if (places == null || places.isEmpty || !mounted) return;
app.startBooking();
app.setDestinations(places);
await showWindowSheet(context);
if (!mounted) return;
await navigator.push(
bookingRoute<void>(BookingRoutes.send, (_) => const SendScreen()),
);
} finally {
if (mounted) setState(() => _handingOver = false);
}
}
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final active = app.activeBookings.isEmpty ? null : app.activeBookings.first;
final firstRun = app.orders.isEmpty;
// The pickup point is already confirmed on this screen, so booking starts
// at the destination — two taps from here to a reserved slot.
void startBooking() {
AppScope.read(context).startBooking();
Navigator.of(context).push(
bookingRoute<void>(BookingRoutes.send, (_) => const SendScreen()),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -45,7 +100,10 @@ class HomeScreen extends StatelessWidget {
action: InkResponse(
onTap: () => ShellScreen.of(context)?.goToTab(2),
radius: 26,
child: DmAvatar(initials: initialsOf(app.customer?.name), onBrand: true),
child: DmAvatar(
initials: initialsOf(app.customer?.name),
onBrand: true,
),
),
// The address is the one fact every booking starts from, so it sits
// in the header as a live value the customer can change in place —
@@ -70,23 +128,62 @@ class HomeScreen extends StatelessWidget {
),
),
Expanded(
child: RefreshIndicator(
color: DmColors.brand,
backgroundColor: DmColors.surface,
onRefresh: () async {
await Future.wait([app.detectPickupLocation(), app.refreshOrders()]);
},
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 16, DmSpace.pad, 28),
children: [
_BookCta(onTap: startBooking),
if (active != null) ...[
const DmMicroHead('Happening now'),
_ActiveCard(booking: active),
],
if (firstRun) const _HowItWorks(),
],
child: AnimatedScale(
scale: _handingOver ? 0.96 : 1,
duration: DmMotion.base,
curve: DmMotion.ease,
child: AnimatedOpacity(
opacity: _handingOver ? 0.45 : 1,
duration: DmMotion.base,
curve: DmMotion.ease,
child: RefreshIndicator(
color: DmColors.brand,
backgroundColor: DmColors.surface,
onRefresh: () async {
await Future.wait([
app.detectPickupLocation(),
app.refreshOrders(),
]);
},
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
16,
DmSpace.pad,
28,
),
children: [
const SizedBox(height: 6),
DmBookOrb(
onTap: _book,
held: _handingOver,
// A returning customer knows what BOOK does. The line is
// for the first run only, and one line is enough to say
// what kind of app this is.
caption: firstRun
? 'A Miler collects from your door.'
: null,
).animate().fadeIn(duration: 420.ms).scale(
begin: const Offset(0.82, 0.82),
end: const Offset(1, 1),
duration: 620.ms,
// A shade of overshoot, so it arrives rather than
// appears.
curve: Curves.easeOutBack,
),
if (active != null)
Padding(
padding: const EdgeInsets.only(top: 22),
child: _LiveRow(booking: active),
).animate().fadeIn(duration: 380.ms, delay: 160.ms).slideY(
begin: 0.12,
curve: DmMotion.ease,
),
if (firstRun) const _HowItWorks(),
],
),
),
),
),
),
@@ -100,96 +197,89 @@ class HomeScreen extends StatelessWidget {
}
}
/// The screen's one action, as a filled crimson card.
/// The live booking, as one row.
///
/// It was a 68pt bar reading BOOK in tracked capitals — a word with no object,
/// which on first run told a new customer nothing about what the app does. The
/// card says what will happen ("a Miler collects from your door in a window you
/// choose") and puts the action inside it, so the promise and the button are
/// one object rather than a shout followed by an explanation elsewhere.
class _BookCta extends StatelessWidget {
const _BookCta({required this.onTap});
/// This was a card with four cells. A customer glancing at Home wants two
/// things from it — *what is happening* and *how long* — and everything else it
/// carried was available one tap away on the screen this row opens. The route,
/// the reference and the package count all went back there.
class _LiveRow extends StatelessWidget {
const _LiveRow({required this.booking});
final VoidCallback onTap;
final Booking booking;
@override
Widget build(BuildContext context) {
return DmCard(
tone: DmCardTone.fill,
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Book a pickup',
style: DmText.heading.copyWith(fontSize: 19, color: Colors.white),
),
const SizedBox(height: 4),
Text(
'A Miler collects from your door in a window you choose.',
style: DmText.small.copyWith(color: DmColors.onBrand2),
),
const SizedBox(height: 14),
// White on crimson: inside a filled card the primary action has
// to invert, or it disappears into its own background.
_WhiteButton(label: 'Start booking', onTap: onTap),
],
),
),
],
);
}
}
final app = AppScope.read(context);
final slot = app.slotById(booking.slotId);
class _WhiteButton extends StatefulWidget {
const _WhiteButton({required this.label, required this.onTap});
// One supporting line, chosen by what the customer can act on right now:
// before the handover that is when the Miler is coming, after it that is
// where the parcel is going.
final support = switch (booking.stage) {
JourneyStage.booked || JourneyStage.assigned || JourneyStage.onTheWay
when slot != null =>
'${slot.day} · ${slot.window}',
JourneyStage.arrived => 'At your door now',
_ =>
booking.isMultiDestination
? 'To ${booking.destinations.length} destinations'
: 'To ${booking.destination.label}',
};
final String label;
final VoidCallback onTap;
@override
State<_WhiteButton> createState() => _WhiteButtonState();
}
class _WhiteButtonState extends State<_WhiteButton> {
bool _down = false;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: widget.label,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => setState(() => _down = true),
onTapUp: (_) => setState(() => _down = false),
onTapCancel: () => setState(() => _down = false),
onTap: widget.onTap,
child: AnimatedContainer(
duration: DmMotion.fast,
curve: DmMotion.ease,
// A minimum, not a height: at large accessibility text the label
// needs the extra line, and a fixed 46 would clip it.
constraints: const BoxConstraints(minHeight: 46),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
onTap: () {
app.openTracking(booking.reference);
Navigator.of(context).push(
bookingRoute<void>(
BookingRoutes.tracking,
(_) => const TrackingScreen(),
),
);
},
child: Container(
constraints: const BoxConstraints(minHeight: 64),
padding: const EdgeInsets.fromLTRB(16, 14, 12, 14),
decoration: BoxDecoration(
color: _down ? DmColors.brandSoft : Colors.white,
borderRadius: DmRadius.all(DmRadius.sm),
color: DmColors.brandSoft,
borderRadius: DmRadius.all(DmRadius.md),
border: Border.all(color: DmColors.brandLine),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
widget.label,
textAlign: TextAlign.center,
style: DmText.button.copyWith(fontSize: 15, color: DmColors.brand),
const DmLiveDot(size: 7),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
booking.stage.milestoneLabel,
style: DmText.cardTitle.copyWith(
color: DmColors.brandPress,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
support,
style: DmText.small,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 9),
const Icon(LucideIcons.arrowRight, size: 18, color: DmColors.brand),
const SizedBox(width: 8),
const Icon(
LucideIcons.chevronRight,
size: 18,
color: DmColors.brand,
),
],
),
),
@@ -198,15 +288,19 @@ class _WhiteButtonState extends State<_WhiteButton> {
}
}
/// Shown on first run only: three numbered rows that match the booking flow
/// exactly, so the first send has no surprises in it.
/// How it works, on first run only.
///
/// This was a bordered card holding three numbered rows, each with a subtitle.
/// It is a timeline now — glyph, hairline, title — and the subtitles are gone:
/// they described the step the title had already named, on the one screen where
/// a new customer is least willing to read.
class _HowItWorks extends StatelessWidget {
const _HowItWorks();
static const _steps = [
('Tell us where it goes', 'City, address and recipient'),
('Pick a two-hour window', 'With live Miler availability'),
('Hand it over and track', 'Door to hub to doorstep, with proof'),
(LucideIcons.mapPin, 'Tell us where it goes'),
(LucideIcons.clock3, 'Pick a two-hour window'),
(LucideIcons.packageCheck, 'Hand it over and track'),
];
@override
@@ -214,129 +308,69 @@ class _HowItWorks extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const DmMicroHead('How it works'),
DmCard(
children: [
for (var i = 0; i < _steps.length; i++)
DmCardRow(
leading: DmTile(label: '${i + 1}', tone: DmTileTone.brand),
title: _steps[i].$1,
subtitle: _steps[i].$2,
),
],
),
const SizedBox(height: 30),
for (var i = 0; i < _steps.length; i++)
_Step(
icon: _steps[i].$1,
title: _steps[i].$2,
last: i == _steps.length - 1,
),
],
).animate(delay: 320.ms).fadeIn(duration: 520.ms).slideY(
begin: 0.06,
curve: DmMotion.ease,
);
}
}
class _ActiveCard extends StatelessWidget {
const _ActiveCard({required this.booking});
class _Step extends StatelessWidget {
const _Step({required this.icon, required this.title, required this.last});
final Booking booking;
final IconData icon;
final String title;
final bool last;
@override
Widget build(BuildContext context) {
final app = AppScope.read(context);
final slot = app.slotById(booking.slotId);
final beforeHandover = booking.stage.index <= JourneyStage.pickedUp.index;
final windowLabel = slot == null
? 'Pickup scheduled'
: '${slot.day} · ${slot.window}';
final courierLine = switch (booking.stage) {
JourneyStage.booked => 'Matching the nearest Miler',
JourneyStage.assigned when booking.miler != null =>
'${booking.miler!.name} will collect your parcel',
JourneyStage.onTheWay when booking.miler != null =>
'${booking.miler!.name} is on route'
'${booking.milerDistanceKm == null ? '' : ' · ${booking.milerDistanceKm} km away'}',
JourneyStage.arrived when booking.miler != null =>
'${booking.miler!.name} is at your door',
_ =>
booking.isMultiDestination
? 'To ${booking.destinations.length} destinations'
: 'To ${booking.destination.label}',
};
return DmCard(
onTap: () {
app.openTracking(booking.reference);
Navigator.of(context).push(
bookingRoute<void>(BookingRoutes.tracking, (_) => const TrackingScreen()),
);
},
children: [
DmCardCell(
background: DmColors.brandSoft,
padding: const EdgeInsets.fromLTRB(14, 11, 14, 11),
child: Row(
children: [
Flexible(child: DmPill(booking.stage.milestoneLabel)),
const SizedBox(width: 10),
Expanded(
child: Text(
booking.displayId,
textAlign: TextAlign.right,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.mono,
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// The rail: a washed disc per step, joined by a hairline that runs
// the height of that step's text. Sized by [IntrinsicHeight] so the
// connector always meets the next disc, whatever the text wraps to.
SizedBox(
width: 34,
child: Column(
children: [
Container(
width: 34,
height: 34,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: DmColors.brandSoft,
),
child: Icon(icon, size: 15, color: DmColors.brand),
),
),
],
),
),
DmCardCell(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DmRouteRail(
compact: true,
pickup: booking.pickup.title,
drop: booking.isMultiDestination
? '${booking.destinations.length} destinations'
: booking.destination.label,
),
const SizedBox(height: 12),
Text(courierLine, style: DmText.small),
],
),
),
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 12, 10, 12),
child: Row(
children: [
Icon(
beforeHandover ? LucideIcons.clock : LucideIcons.truck,
size: 15,
color: DmColors.ink3,
),
const SizedBox(width: 10),
Expanded(
child: Text(
beforeHandover ? windowLabel : 'On its way',
style: DmText.small,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
DmTextAction(
label: 'Track',
icon: LucideIcons.arrowRight,
onPressed: () {
app.openTracking(booking.reference);
Navigator.of(context).push(
bookingRoute<void>(
BookingRoutes.tracking,
(_) => const TrackingScreen(),
if (!last)
const Expanded(
child: SizedBox(
width: 1,
child: ColoredBox(color: DmColors.border),
),
);
},
),
],
),
],
),
),
),
],
const SizedBox(width: 14),
Expanded(
child: Padding(
padding: EdgeInsets.only(top: 8, bottom: last ? 0 : 20),
child: Text(title, style: DmText.cardTitle),
),
),
],
),
);
}
}

View File

@@ -17,13 +17,19 @@ import 'tracking_screen.dart';
/// its own tracking number and journey, so it gets its own card with a quiet
/// line back to the visit they were collected in.
///
/// ── Status first, reference beside it ──
/// ── Status first, reference quiet ──
///
/// The state and the identifier sit together in a washed strip across the top,
/// and the route sits underneath. A customer scanning this list is asking one
/// question — what is happening to my parcel — and the answer is now the first
/// thing on every card, at the same height on each, instead of being a pill
/// tucked under a route of variable length.
/// A customer scanning this list is asking one question — what is happening to
/// my parcel — so the state is the first thing on every card, at the same
/// height on each.
///
/// The reference used to share a crimson-washed strip with it, which gave a
/// string nobody reads while scanning the same weight as the only word they do.
/// It is a quiet identifier on the same line now, and the strip is gone: one
/// cell, not two.
///
/// "Collected on DM-471200" went with it. It explained why two orders share a
/// pickup, which is a question asked on the order, not in a list of them.
class OrderCard extends StatelessWidget {
const OrderCard({super.key, required this.entry});
@@ -62,12 +68,8 @@ class OrderCard extends StatelessWidget {
final packages = entry.isPickup ? booking.totalPackages : group!.packageCount;
final meta = [
'$packages ${packages == 1 ? 'package' : 'packages'}',
if (entry.isPickup && booking.isMultiDestination)
'${booking.destinations.length} destinations',
if (booking.status == BookingStatus.completed && booking.amountPaid != null)
'₹${booking.amountPaid} paid'
else
dateLabel(booking.createdAt),
'₹${booking.amountPaid} paid',
].join(' · ');
return DmCard(
@@ -88,51 +90,59 @@ class OrderCard extends StatelessWidget {
},
children: [
DmCardCell(
background: DmColors.brandSoft,
padding: const EdgeInsets.fromLTRB(14, 11, 14, 11),
child: Row(
children: [
Flexible(child: DmPill(label, tone: tone)),
const SizedBox(width: 10),
Expanded(
child: Text(
reference,
textAlign: TextAlign.right,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.mono,
),
),
],
),
),
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 15, 16, 15),
padding: const EdgeInsets.fromLTRB(16, 14, 16, 15),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
// The date belongs at the right edge, on the same line on
// every card, so the eye can run down the column. Left to the
// default packing it sits wherever the status text happens to
// end, which is a different place on all four.
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Flexible, not Expanded: the pill is sized by its own text
// and only gives way if the row runs out of room. Expanded
// made it fill every spare pixel, which drew "In transit"
// as a 600pt bar with one word in it. Expanding the *date*
// instead — the version before that — clipped the only word
// on the row that matters, to make space for "Yesterday".
// Neither of them is flexible; the pill is.
Flexible(child: DmPill(label, tone: tone)),
Padding(
padding: const EdgeInsets.only(left: 10),
child: Text(
dateLabel(booking.createdAt),
maxLines: 1,
style: DmText.tiny,
),
),
],
),
const SizedBox(height: 13),
DmRouteRail(
compact: true,
pickup: booking.pickup.title,
drop: drop,
),
const SizedBox(height: 11),
Text(meta, style: DmText.tiny),
if (entry.collectedTogether) ...[
const SizedBox(height: 4),
Text.rich(
TextSpan(
style: DmText.tiny.copyWith(color: DmColors.ink4),
children: [
const TextSpan(text: 'Collected on '),
TextSpan(
text: booking.reference,
style: DmText.monoSm.copyWith(color: DmColors.ink4),
),
],
Row(
// The date belongs at the right edge, on the same line on
// every card, so the eye can run down the column. Left to the
// default packing it sits wherever the status text happens to
// end, which is a different place on all four.
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: Text(meta, style: DmText.tiny)),
const SizedBox(width: 10),
Text(
reference,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.monoSm.copyWith(color: DmColors.ink4),
),
),
],
],
),
],
),
),

View File

@@ -0,0 +1,446 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/models.dart';
import '../../../state/app_scope.dart';
import '../../state_marks.dart';
import '../../tokens.dart';
import '../../widgets/buttons.dart';
import '../../widgets/cards.dart';
import '../../widgets/feedback.dart';
import '../../widgets/inputs.dart';
import '../../widgets/states.dart';
/// "Where is it going?" — the first and only question after BOOK.
///
/// ── State, then district ──
///
/// It was one flat list of every serviceable district, on the argument that
/// "nobody picks Tamil Nadu on the way to picking Chennai". True of twelve
/// districts and false of sixty: the list this app will have in a year is a
/// scroll, not a glance.
///
/// Browsing is two steps, and the **states carry the marks**. Districts do not:
/// at state level a mark is a bounded editorial problem, and at district level
/// it is an opinion about every district in India that nobody can keep honest.
///
/// **Search cuts straight through.** Typing at the first step searches
/// districts across every state and returns them flat — somebody who knows
/// they are sending to Chennai should not have to know which state it is in.
///
/// ── Several places in one pickup, but only when the server says so ──
///
/// One Miler visit can collect for more than one destination, and this sheet
/// is where that is chosen: districts toggle, the selection survives moving
/// between states, and the footer says how many are in.
///
/// It is single-select whenever [BookingLimits.allowsMultipleDestinations] is
/// false, which is what the live backend advertises today. That is not a
/// nicety. Multi-destination is undeliverable until the Miler build keys its
/// work on `consignmentid`, so a sheet that let a customer pick three places
/// against a server capped at one would sell them a booking the network cannot
/// complete. The cap is read from the server on every launch and this sheet
/// obeys it: one place means one tap and no confirm button, because a
/// "Continue" under a single-select list asks the customer to say the same
/// thing twice.
Future<List<CityOption>?> showDestinationSheet(BuildContext context) {
return showDmSheet<List<CityOption>>(
context: context,
builder: (context) => const _DestinationSheet(),
);
}
class _DestinationSheet extends StatefulWidget {
const _DestinationSheet();
@override
State<_DestinationSheet> createState() => _DestinationSheetState();
}
class _DestinationSheetState extends State<_DestinationSheet> {
final _query = TextEditingController();
String _q = '';
/// The state being browsed, or null while the states themselves are.
ServiceArea? _openState;
/// Chosen districts, by district code, in the order they were picked.
final _chosen = <String, CityOption>{};
@override
void dispose() {
_query.dispose();
super.dispose();
}
void _open(ServiceArea area) {
HapticFeedback.selectionClick();
setState(() {
_openState = area;
_query.clear();
_q = '';
});
}
void _back() {
setState(() {
_openState = null;
_query.clear();
_q = '';
});
}
void _tap(CityOption city, {required bool multi, required int cap}) {
HapticFeedback.selectionClick();
if (!multi) {
Navigator.of(context).pop([city]);
return;
}
final code = city.district.code;
setState(() {
if (_chosen.remove(code) != null) return;
if (_chosen.length >= cap) {
DmToast.show(
context,
'One pickup can serve up to $cap places',
);
return;
}
_chosen[code] = city;
});
}
/// The states, derived from the district list rather than fetched.
///
/// `loadCities` already returns every *serviceable* district with its state
/// attached, so grouping it is one request instead of two and is the only
/// version that cannot show a state with nothing open inside it.
List<ServiceArea> _statesOf(List<CityOption> cities) {
final seen = <String, ServiceArea>{};
for (final city in cities) {
seen.putIfAbsent(city.state.code, () => city.state);
}
return seen.values.toList();
}
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final browsing = _openState;
final cap = app.limits.maxDestinations;
final multi = app.allowsMultipleDestinations;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Back inside the sheet, not out of it: the customer is one level
// into a question they are still answering, and dismissing the
// whole thing to change their mind about a state would throw away
// both the step they got right and everything they have ticked.
if (browsing != null)
Padding(
padding: const EdgeInsets.only(right: 4),
child: InkResponse(
onTap: _back,
radius: 22,
highlightShape: BoxShape.circle,
splashColor: DmColors.brandSoft,
highlightColor: DmColors.brandSoft,
child: const SizedBox(
width: 36,
height: 36,
child: Icon(
LucideIcons.arrowLeft,
size: 20,
color: DmColors.ink,
),
),
),
),
Expanded(
child: DmSheetHeader(
title: browsing?.name ?? 'Where is it going?',
),
),
],
),
DmSearchField(
controller: _query,
hint: browsing == null
? 'Search for a city'
: 'Search in ${browsing.name}',
onChanged: (v) => setState(() => _q = v.trim().toLowerCase()),
),
const SizedBox(height: 6),
Flexible(
child: DmAsyncList<CityOption>(
key: const ValueKey('destinationList'),
skeletonRows: 4,
load: ({bool refresh = false}) => app.loadCities(refresh: refresh),
emptyIcon: LucideIcons.mapPinOff,
emptyTitle: 'No cities open yet',
emptyMessage:
"We're not accepting new pickups right now. Please check "
'back shortly.',
builder: (context, cities) => SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
..._body(cities, multi: multi, cap: cap),
const SizedBox(height: 4),
],
),
),
),
),
if (multi && _chosen.isNotEmpty) ...[
const SizedBox(height: 10),
DmButton(
label: _chosen.length == 1
? 'Send to ${_chosen.values.first.district.name}'
: 'Send to ${_chosen.length} places',
icon: LucideIcons.arrowRight,
onPressed: () =>
Navigator.of(context).pop(_chosen.values.toList()),
),
],
],
);
}
List<Widget> _body(
List<CityOption> cities, {
required bool multi,
required int cap,
}) {
final browsing = _openState;
Widget district(CityOption city) => _DistrictRow(
city: city,
multi: multi,
chosen: _chosen.containsKey(city.district.code),
onTap: () => _tap(city, multi: multi, cap: cap),
);
// Searching from the top level goes straight to districts, across every
// state. Searching inside one stays inside it.
if (_q.isNotEmpty) {
final pool = browsing == null
? cities
: cities.where((c) => c.state.code == browsing.code);
final matches = pool
.where((c) => c.label.toLowerCase().contains(_q))
.toList();
if (matches.isEmpty) {
return const [
DmEmptyState(
icon: LucideIcons.searchX,
title: 'No match',
message: 'Try another city.',
),
];
}
return [for (final city in matches) district(city)];
}
if (browsing != null) {
return [
for (final city in cities.where((c) => c.state.code == browsing.code))
district(city),
];
}
return [
for (final area in _statesOf(cities))
_StateRow(
area: area,
// The count is the reason to open it, and it is free: the list it
// came from is the list the next step shows.
count: cities.where((c) => c.state.code == area.code).length,
// How many of this state's districts are already in, so a customer
// who has ticked two in Tamil Nadu can see that from the top.
chosen: cities
.where(
(c) =>
c.state.code == area.code &&
_chosen.containsKey(c.district.code),
)
.length,
onTap: () => _open(area),
),
];
}
}
/// One state: its mark, its name, and how many cities are open inside it.
class _StateRow extends StatelessWidget {
const _StateRow({
required this.area,
required this.count,
required this.chosen,
required this.onTap,
});
final ServiceArea area;
final int count;
final int chosen;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return _Row(
onTap: onTap,
leading: DmTile(icon: stateMark(area.code), tone: DmTileTone.wash),
title: area.name,
subtitle:
'$count ${count == 1 ? 'city' : 'cities'}'
'${area.transitTag == null ? '' : ' · ${area.transitTag}'}',
trailing: chosen == 0
? null
: Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
decoration: BoxDecoration(
color: DmColors.brand,
borderRadius: DmRadius.all(DmRadius.pill),
),
child: Text(
'$chosen',
style: DmText.label.copyWith(color: Colors.white),
),
),
);
}
}
/// One district. No mark — the name and the promise are the whole row, and a
/// glyph beside every one of them would be decoration competing with the only
/// two things on it that differ.
class _DistrictRow extends StatelessWidget {
const _DistrictRow({
required this.city,
required this.multi,
required this.chosen,
required this.onTap,
});
final CityOption city;
final bool multi;
final bool chosen;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return _Row(
onTap: onTap,
title: city.district.name,
subtitle: city.note,
selected: chosen,
// A tick box only where ticking is possible. Where the server caps the
// booking at one place the row is a choice, not a checklist, and a box
// would promise a second pick the backend will refuse.
trailing: multi ? _Check(on: chosen) : null,
);
}
}
class _Check extends StatelessWidget {
const _Check({required this.on});
final bool on;
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: DmMotion.fast,
curve: DmMotion.ease,
width: 22,
height: 22,
decoration: BoxDecoration(
color: on ? DmColors.brand : Colors.transparent,
shape: BoxShape.circle,
border: Border.all(
color: on ? DmColors.brand : DmColors.border,
width: 1.5,
),
),
child: on
? const Icon(LucideIcons.check, size: 14, color: Colors.white)
: null,
);
}
}
/// The shape both rows share: a target, a title, a quiet line, a trailing mark.
class _Row extends StatelessWidget {
const _Row({
required this.onTap,
required this.title,
this.subtitle,
this.leading,
this.trailing,
this.selected = false,
});
final VoidCallback onTap;
final String title;
final String? subtitle;
final Widget? leading;
final Widget? trailing;
final bool selected;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
selected: selected,
child: InkWell(
onTap: onTap,
child: Container(
constraints: const BoxConstraints(minHeight: DmSpace.tap),
padding: EdgeInsets.symmetric(vertical: leading == null ? 14 : 10),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: DmColors.hairline)),
),
child: Row(
children: [
if (leading != null) ...[leading!, const SizedBox(width: 14)],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: DmText.cardTitle.copyWith(
color: selected ? DmColors.brand : null,
),
),
if (subtitle != null)
Text(
subtitle!,
style: DmText.tiny,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 10),
trailing ??
const Icon(
LucideIcons.chevronRight,
size: 16,
color: DmColors.ink4,
),
],
),
),
),
);
}
}

View File

@@ -9,6 +9,7 @@ import '../../../state/app_state.dart';
import '../../tokens.dart';
import '../../widgets/cards.dart';
import '../../widgets/feedback.dart';
import '../../widgets/inputs.dart';
import '../../widgets/states.dart';
/// Alias used from Review, where the sheet edits the pickup point in place.
@@ -56,37 +57,11 @@ class _PlaceSearchBodyState extends State<_PlaceSearchBody> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const DmSheetHeader(
title: 'Pickup location',
subtitle: 'Search or pick a saved place.',
),
TextField(
const DmSheetHeader(title: 'Collect from'),
DmSearchField(
controller: _query,
hint: 'Search street, area or landmark',
onChanged: _onChanged,
autofocus: false,
cursorColor: DmColors.brand,
style: DmText.field,
decoration: InputDecoration(
hintText: 'Search street, area or landmark',
hintStyle: DmText.field.copyWith(
color: DmColors.ink3,
fontWeight: FontWeight.w500,
),
prefixIcon: const Icon(
LucideIcons.search,
size: 20,
color: DmColors.ink3,
),
filled: true,
fillColor: DmColors.surface,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
border: _border(DmColors.lineStrong, 1),
enabledBorder: _border(DmColors.lineStrong, 1),
focusedBorder: _border(DmColors.brand, 2),
),
),
const SizedBox(height: 8),
Flexible(
@@ -125,10 +100,6 @@ class _PlaceSearchBodyState extends State<_PlaceSearchBody> {
);
}
OutlineInputBorder _border(Color color, double width) => OutlineInputBorder(
borderRadius: DmRadius.all(DmRadius.sm),
borderSide: BorderSide(color: color, width: width),
);
}
class _PlaceRow extends StatelessWidget {

View File

@@ -50,14 +50,7 @@ class _WindowSheetState extends State<_WindowSheet> {
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: DmSheetHeader(
title: 'Pickup window',
subtitle: (app.draftPickup ?? app.pickup) == null
? null
: 'Collecting from ${(app.draftPickup ?? app.pickup)!.title}',
),
),
const Expanded(child: DmSheetHeader(title: 'Pickup window')),
DmIconButton(
icon: LucideIcons.x,
tooltip: 'Close',
@@ -80,7 +73,13 @@ class _WindowSheetState extends State<_WindowSheet> {
}
final day = days.contains(_day) ? _day! : days.first;
final shown = slots.where((s) => s.day == day).toList();
final selected = _picked ?? _slotById(slots, app.draftSlotId);
// Opens on something valid: the slot already chosen, or the
// first one still available. A sheet whose only button is
// disabled on arrival has made the customer work to reach the
// state it could have started in.
final selected = _picked ??
_slotById(slots, app.draftSlotId) ??
shown.where((s) => s.available).firstOrNull;
return SingleChildScrollView(
child: Column(
@@ -158,27 +157,24 @@ class _SlotRow extends StatelessWidget {
Widget build(BuildContext context) {
final enabled = slot.available;
// ── The time, and nothing else ──
//
// Each row carried up to four things: a "Fastest pickup" tag, a
// "4 Milers nearby" tag, the window, and a caption like "Relaxed evening
// handover". Three of those were the backend selling a slot to somebody
// who had already decided to book. The only question this sheet asks is
// *when*, and the answer is a time — so the time is the row.
//
// An unavailable slot keeps its note, because "Fully booked" is the reason
// the row cannot be tapped and removing it would leave a dead row with no
// explanation.
return DmOptionTile(
title: slot.window,
titleStyle: DmText.display.copyWith(fontSize: 19, height: 1.2),
subtitle: enabled ? slot.caption : (slot.note ?? 'Unavailable'),
titleStyle: DmText.display.copyWith(fontSize: 21, height: 1.2),
subtitle: enabled ? null : (slot.note ?? 'Unavailable'),
selected: selected,
enabled: enabled,
onTap: onTap,
tags: [
if (slot.tag != null)
DmTag(
slot.tag!,
tone: selected ? DmTagTone.onFill : DmTagTone.brand,
icon: LucideIcons.zap,
),
if (slot.milersNearby > 0)
DmTag(
'${slot.milersNearby} Milers nearby',
tone: selected ? DmTagTone.onFill : DmTagTone.neutral,
icon: LucideIcons.userRound,
),
],
trailing: enabled
? null
: DmTextAction(

View File

@@ -60,6 +60,22 @@ class ShellScreenState extends State<ShellScreen> {
/// object moving rather than three tabs changing colour in place — and the
/// label under it stays crimson, so the tab is still identifiable in the
/// half-second the pill is in flight.
///
/// ── What the 2026-09-22 pass changed ──
///
/// **The pill stopped glowing.** It carried [DmShadow.liftSm] — a 65% crimson
/// shadow built for the committing button on a screen — which on a bar resting
/// against the bottom edge read as a smear under the tab rather than as lift.
/// It is a quarter of that strength now and sits closer to the shape.
///
/// **The glyph arrives.** The icon pops to 1.14 and settles as the pill lands
/// under it, on the same overshoot curve the pill travels on, so the two read
/// as one gesture instead of a moving rectangle and an icon that recolours when
/// it happens to be covered.
///
/// **A tab gives way when pressed.** The whole column scales to 0.9 under the
/// finger. Without it the only feedback for a tap was the pill starting to
/// move, which on the tab you are already on is no feedback at all.
class _TabBar extends StatelessWidget {
const _TabBar({required this.index, required this.onChanged});
@@ -80,6 +96,18 @@ class _TabBar extends StatelessWidget {
static const double _pillWidth = 62;
static const double _pillHeight = 34;
/// The pill's own lift. Not [DmShadow.liftSm]: that is the glow under a
/// committing action, and at the bottom edge of the screen it read as dirt
/// under the tab.
static const _pillShadow = [
BoxShadow(
color: Color(0x2E8F0F06),
blurRadius: 10,
spreadRadius: -2,
offset: Offset(0, 4),
),
];
@override
Widget build(BuildContext context) {
return DecoratedBox(
@@ -115,7 +143,7 @@ class _TabBar extends StatelessWidget {
decoration: BoxDecoration(
color: DmColors.brand,
borderRadius: DmRadius.all(DmRadius.pill),
boxShadow: DmShadow.liftSm,
boxShadow: _pillShadow,
),
),
),
@@ -143,7 +171,7 @@ class _TabBar extends StatelessWidget {
}
}
class _TabButton extends StatelessWidget {
class _TabButton extends StatefulWidget {
const _TabButton({
required this.label,
required this.icon,
@@ -156,44 +184,74 @@ class _TabButton extends StatelessWidget {
final bool selected;
final VoidCallback onTap;
@override
State<_TabButton> createState() => _TabButtonState();
}
class _TabButtonState extends State<_TabButton> {
bool _down = false;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
selected: selected,
selected: widget.selected,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
SizedBox(
height: _TabBar._pillHeight,
child: Center(
child: AnimatedSwitcher(
duration: DmMotion.base,
child: Icon(
icon,
key: ValueKey(selected),
size: 22,
color: selected ? Colors.white : DmColors.ink3,
onTapDown: (_) => setState(() => _down = true),
onTapUp: (_) => setState(() => _down = false),
onTapCancel: () => setState(() => _down = false),
onTap: widget.onTap,
child: AnimatedScale(
// The whole column, including the label: a press that only moved the
// glyph would read as the icon coming loose from its own tab.
scale: _down ? 0.9 : 1,
duration: DmMotion.fast,
curve: DmMotion.ease,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
SizedBox(
height: _TabBar._pillHeight,
child: Center(
child: AnimatedScale(
// Lands with the pill, on the pill's own curve. The
// overshoot is what ties the two together — the glyph is
// reacting to the thing arriving under it.
scale: widget.selected ? 1.14 : 1,
duration: DmMotion.slow,
curve: DmMotion.glide,
child: AnimatedSwitcher(
duration: DmMotion.base,
child: Icon(
widget.icon,
key: ValueKey(widget.selected),
size: 21,
color: widget.selected ? Colors.white : DmColors.ink3,
),
),
),
),
),
),
const SizedBox(height: 5),
AnimatedDefaultTextStyle(
duration: DmMotion.base,
curve: DmMotion.ease,
style: DmText.label.copyWith(
fontSize: 11.5,
height: 1.0,
color: selected ? DmColors.brand : DmColors.ink3,
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
const SizedBox(height: 5),
AnimatedDefaultTextStyle(
duration: DmMotion.base,
curve: DmMotion.ease,
style: DmText.label.copyWith(
fontSize: 11.5,
height: 1.0,
color: widget.selected ? DmColors.brand : DmColors.ink3,
fontWeight:
widget.selected ? FontWeight.w700 : FontWeight.w600,
),
child: Text(
widget.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis),
),
],
],
),
),
),
);

View File

@@ -0,0 +1,383 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lottie/lottie.dart';
import '../tokens.dart';
/// What the app opens on: the mark, then something moving.
///
/// ── Why there is a splash at all ──
///
/// The Android window background already shows the mark from process start, so
/// this is not covering a blank screen. It exists because the first thing that
/// happens after it is a *question* — a login form, or a Home screen the
/// customer has to read — and arriving at either one mid-stride is worse than
/// arriving at it after a beat that says whose app this is.
///
/// It also has real work to hide behind it. `restoreSession` reads the keychain
/// and may refresh a 60-day token against the network, which is the one moment
/// the app cannot say whether the customer is signed in. Showing a login screen
/// that vanishes a moment later is the failure this replaces.
///
/// ── It never makes anybody wait longer than it has to ──
///
/// Two clocks, and the screen leaves on the later of them: [_minimum], so the
/// animation is not a flicker, and whatever the session restore takes. A cold
/// start with a stored session usually finishes inside the minimum, so the
/// splash costs nothing; a slow network extends it rather than flashing.
///
/// [_maximum] is the safety net. If the restore hangs — a captive portal, a
/// server not answering — the app moves on rather than holding a customer on a
/// logo forever, and `_Launch` shows the login screen, which is the right place
/// for somebody whose session could not be read.
///
/// ── Two beats, in this order ──
///
/// The animation first, alone, and the mark after it. It was the other way
/// round — mark, then the animation appearing underneath it — which read as a
/// logo with a loading bar stuck to the bottom. Running them in sequence makes
/// the first beat about *what this app does* and the second about *whose app
/// it is*, and neither is competing with the other for the same moment.
///
/// ── The animation is a drop-in, and it is recoloured ──
///
/// If `assets/animations/splash.json` exists it is played. If it does not, the
/// road loader below runs instead.
///
/// The file shipped as black line art, which is not a colour this app owns —
/// the darkest thing in the palette is `DmColors.ink`, and the splash is the
/// one screen that is nothing but brand. `tool/lottie_brand.py` rewrites its
/// near-black fills and strokes to the crimson; it is idempotent, so run it
/// again after replacing the file. Both are the same size and the same beat, so
/// which one is present changes nothing about the screen's timing — see
/// `assets/animations/README.txt`.
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key, required this.ready, required this.onDone});
/// True once the session restore has finished, however it finished.
final bool ready;
/// Called once, when the splash has served its purpose.
final VoidCallback onDone;
/// Long enough for both beats to read: the animation, then the mark.
static const _minimum = Duration(milliseconds: 2800);
/// Past this the app moves on whatever the session is doing.
static const _maximum = Duration(seconds: 7);
/// When the animation gives way to the mark.
///
/// The truck runs six seconds end to end, so this shows the opening of it
/// rather than the whole thing. That is the right trade: a launch screen
/// that waits out a six-second loop is a launch screen nobody thanks you
/// for, and the part worth seeing — the truck arriving — is at the front.
static const _handover = Duration(milliseconds: 1800);
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin {
/// Started at the handover, not at launch: the mark is the second beat.
late final AnimationController _mark = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 620),
);
/// False while the animation has the screen; true once the mark takes it.
bool _onMark = false;
bool _started = false;
bool _elapsed = false;
bool _left = false;
Timer? _minTimer;
Timer? _maxTimer;
Timer? _holdTimer;
/// The Lottie file, if somebody dropped one in. Resolved once, at launch:
/// a `FutureBuilder` on every frame of a splash is work for nothing.
Future<LottieComposition?>? _composition;
@override
void initState() {
super.initState();
// ── The clock starts when the splash is on screen, not when it is built ──
//
// These timers used to start here, and the truck got 0.45s of a 1.8s beat.
// Two things happen between `initState` and the customer seeing anything:
// Android holds its own splash over the window until Flutter's first
// frame, and parsing a 300KB Lottie takes a few hundred milliseconds on a
// mid-range phone. Both were being spent against a clock that was already
// running, so most of the first beat elapsed behind a white rectangle and
// the animation looked like it flashed past on the way to the logo.
//
// Now the clock starts once three things are true: the composition has
// settled, the engine has **rasterized** its first frame, and the system
// splash has had time to fade out over it.
//
// Rasterized, not built. `addPostFrameCallback` fires on the first frame
// Flutter *assembles*, which on a debug build happens over a second before
// anything reaches the glass — that was the first attempt at this fix, and
// it moved the problem without solving it. `waitUntilFirstFrameRasterized`
// is the signal Android itself waits on before taking its splash down.
_composition = _loadComposition();
unawaited(_waitForScreen());
// The safety net runs from launch regardless. If the composition never
// resolves, the app must still move on.
_maxTimer = Timer(SplashScreen._maximum, _leave);
}
/// Blocks until there is a customer looking at this, then starts the clock.
///
/// The rasterizer wait is raced against a timeout so a binding that never
/// reports one — the widget-test harness, a platform that does not
/// implement it — cannot leave the app sitting on a splash forever. The
/// extra beat after it covers the system splash's own exit animation.
Future<void> _waitForScreen() async {
await _composition;
await WidgetsBinding.instance.waitUntilFirstFrameRasterized.timeout(
const Duration(milliseconds: 900),
onTimeout: () {},
);
await Future<void>.delayed(const Duration(milliseconds: 220));
_start();
}
/// Begins the two beats. Idempotent: whichever of the waits above finishes
/// last is the one that counts.
void _start() {
if (_started || !mounted) return;
_started = true;
_holdTimer = Timer(SplashScreen._handover, () {
if (!mounted) return;
setState(() => _onMark = true);
_mark.forward();
});
_minTimer = Timer(SplashScreen._minimum, () {
_elapsed = true;
_leaveIfDone();
});
}
/// Null when there is no file, which is not an error — it is the default.
Future<LottieComposition?> _loadComposition() async {
try {
return await AssetLottie('assets/animations/splash.json').load();
} catch (_) {
return null;
}
}
@override
void didUpdateWidget(SplashScreen old) {
super.didUpdateWidget(old);
if (widget.ready && !old.ready) _leaveIfDone();
}
void _leaveIfDone() {
if (_elapsed && widget.ready) _leave();
}
void _leave() {
if (_left || !mounted) return;
_left = true;
widget.onDone();
}
@override
void dispose() {
_minTimer?.cancel();
_maxTimer?.cancel();
_holdTimer?.cancel();
_mark.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final entrance = CurvedAnimation(parent: _mark, curve: Curves.easeOutBack);
return Scaffold(
backgroundColor: DmColors.surface,
body: AnnotatedRegion<SystemUiOverlayStyle>(
value: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
systemNavigationBarColor: DmColors.surface,
systemNavigationBarIconBrightness: Brightness.dark,
),
child: Semantics(
label: 'Doormile',
child: Center(
// One box, two beats, cross-faded. A `Column` holding both would
// reserve height for the one that is not showing and push the
// other off centre.
// Sized to the screen, not to a number: the splash is the one
// surface with nothing else on it, so both beats are given most
// of the width rather than sitting small in the middle of it.
child: SizedBox(
height: 320,
child: AnimatedSwitcher(
duration: DmMotion.base,
switchInCurve: DmMotion.ease,
switchOutCurve: DmMotion.ease,
child: _onMark
? ScaleTransition(
key: const ValueKey('mark'),
scale: Tween(begin: 0.82, end: 1.0).animate(entrance),
child: FadeTransition(
opacity: _mark,
child: Image.asset(
'assets/images/doormile-icon.png',
width: 210,
height: 210,
filterQuality: FilterQuality.medium,
),
),
)
: FutureBuilder<LottieComposition?>(
key: const ValueKey('animation'),
future: _composition,
builder: (context, snap) {
final composition = snap.data;
if (composition == null) return const _RoadLoader();
return Lottie(
composition: composition,
width: 340,
height: 320,
fit: BoxFit.contain,
repeat: true,
);
},
),
),
),
),
),
),
);
}
}
/// The built-in animation: a parcel travelling a road.
///
/// Not a spinner. A spinner says "waiting"; this says what the waiting is for,
/// in the one shape the mark already contains — the road curving through the D.
/// It is also two primitives and no dependency, so the splash works on a fresh
/// checkout with nothing downloaded.
class _RoadLoader extends StatefulWidget {
const _RoadLoader();
@override
State<_RoadLoader> createState() => _RoadLoaderState();
}
class _RoadLoaderState extends State<_RoadLoader>
with SingleTickerProviderStateMixin {
late final AnimationController _run = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat();
@override
void dispose() {
_run.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Center(
child: SizedBox(
width: 190,
height: 40,
child: AnimatedBuilder(
animation: _run,
builder: (_, _) => CustomPaint(painter: _RoadPainter(_run.value)),
),
),
);
}
}
class _RoadPainter extends CustomPainter {
const _RoadPainter(this.t);
/// 0→1, repeating.
final double t;
@override
void paint(Canvas canvas, Size size) {
final y = size.height * 0.72;
// The road: a full-width track at the weight of a hairline, so the parcel
// is the only thing on it with any presence.
canvas.drawLine(
Offset(0, y),
Offset(size.width, y),
Paint()
..strokeWidth = 2
..strokeCap = StrokeCap.round
..color = DmColors.border,
);
// Lane markings, travelling backwards under the parcel. They are what
// makes it read as movement rather than as a dot sliding on a line.
const dash = 14.0;
const gap = 12.0;
final shift = -t * (dash + gap);
for (var x = shift; x < size.width; x += dash + gap) {
final a = x.clamp(0.0, size.width);
final b = (x + dash).clamp(0.0, size.width);
if (b <= a) continue;
canvas.drawLine(
Offset(a, y),
Offset(b, y),
Paint()
..strokeWidth = 2
..strokeCap = StrokeCap.round
// Strong enough to read as movement. At `brandLine` on white they
// were technically present and invisible, which made the parcel look
// like it was sliding on nothing.
..color = DmColors.brand.withValues(alpha: 0.28),
);
}
// The parcel. Eased at both ends so it leaves and arrives rather than
// scrolling past at a constant speed.
final eased = Curves.easeInOutCubic.transform(t);
final cx = 16 + eased * (size.width - 32);
final rect = RRect.fromRectAndRadius(
Rect.fromCenter(center: Offset(cx, y - 13), width: 22, height: 18),
const Radius.circular(4),
);
canvas.drawRRect(
rect.shift(const Offset(0, 3)),
Paint()
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4)
..color = DmColors.brand.withValues(alpha: 0.22),
);
canvas.drawRRect(rect, Paint()..color = DmColors.brand);
// The seam across the box, which is what makes it a parcel and not a
// rectangle.
canvas.drawLine(
Offset(cx, y - 22),
Offset(cx, y - 4),
Paint()
..strokeWidth = 1.6
..color = Colors.white.withValues(alpha: 0.55),
);
}
@override
bool shouldRepaint(_RoadPainter old) => old.t != t;
}

View File

@@ -81,79 +81,122 @@ class TrackingScreen extends StatelessWidget {
style: DmText.mono,
),
),
body: ListView(
padding: const EdgeInsets.only(bottom: 24),
children: [
_StatusHeader(booking: booking, slot: slot, focused: focused),
body: RefreshIndicator(
color: DmColors.brand,
backgroundColor: DmColors.surface,
onRefresh: () => app.refreshBooking(booking.reference),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 24),
children: [
_StatusHeader(booking: booking, slot: slot, focused: focused),
if (assigning)
const _AssigningHero()
else if (!cancelled)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
10,
),
// The map is a cell of a card, not a floating rounded rectangle
// of its own: it gets the card's edge, and the line under it
// carries the fact the picture cannot state precisely.
child: DmCard(
children: [
DmRouteMap(
height: 178,
radius: 0,
pickup: booking.pickup,
destination: (focused ?? booking.destinations.first).hub,
showMiler: milerEnRoute,
progress: _routeProgress(stage),
pickupLabel: booking.pickup.title,
destinationLabel: booking.destination.districtName,
badge: milerEnRoute && booking.milerDistanceKm != null
? _Badge(
icon: LucideIcons.bike,
label: booking.milerDistanceKm == 0
? 'At your door'
: '${booking.miler?.name ?? 'Your Miler'}, '
'${booking.milerDistanceKm} km away',
)
: null,
),
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 12, 10, 12),
child: Row(
children: [
const Icon(
LucideIcons.clock,
size: 15,
color: DmColors.ink3,
),
const SizedBox(width: 10),
Expanded(
child: Text(
// Short enough to sit beside Refresh without
// ellipsizing, which is the whole job of this line.
milerEnRoute && booking.milerEtaMinutes != null
? 'Pickup in about ${booking.milerEtaMinutes} min'
: 'Tracking your parcel live',
style: DmText.small,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
DmTextAction(
label: 'Refresh',
onPressed: app.refreshOrders,
),
],
// ── No radar while a Miler is being found ──
//
// This showed a 200pt card holding a pulsing radar with a bike
// glyph at its centre, then a banner counting "verified Milers
// active in your zone". Both were animation standing in for an
// answer: the radar is not looking at anything, and the count is a
// number the customer cannot use. The rail below says *Booking
// created* with the line stopped there, and the crimson card above
// says what is happening in words. That is the answer, and it is
// the same rail whether a Miler has been found or not.
if (!assigning && !cancelled)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
10,
),
// The map is a cell of a card, not a floating rounded rectangle
// of its own: it gets the card's edge, and the line under it
// carries the fact the picture cannot state precisely.
child: DmCard(
children: [
DmRouteMap(
height: 178,
radius: 0,
pickup: booking.pickup,
destination: (focused ?? booking.destinations.first).hub,
showMiler: milerEnRoute,
progress: _routeProgress(stage),
pickupLabel: booking.pickup.title,
destinationLabel: booking.destination.districtName,
badge: milerEnRoute && booking.milerDistanceKm != null
? _Badge(
icon: LucideIcons.bike,
label: booking.milerDistanceKm == 0
? 'At your door'
: '${booking.miler?.name ?? 'Your Miler'}, '
'${booking.milerDistanceKm} km away',
)
: null,
),
),
],
// Only when there is something to say. This cell used to
// read "Tracking your parcel live" whenever no ETA existed,
// beside a Refresh action — a sentence that described the
// screen the customer was already looking at, and a button
// for a gesture the list now handles by being pulled down.
if (milerEnRoute && booking.milerEtaMinutes != null)
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
children: [
const Icon(
LucideIcons.clock,
size: 15,
color: DmColors.ink3,
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Pickup in about '
'${booking.milerEtaMinutes} min',
style: DmText.cardTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
),
if (milerEnRoute)
_MilerCard(person: booking.miler!, role: 'Your Miler'),
if (!cancelled &&
stage == JourneyStage.outForDelivery &&
booking.deliveryAgent != null)
_MilerCard(
person: booking.deliveryAgent!,
role: 'Delivery Miler',
),
if (!cancelled)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
10,
),
child: _Journey(booking: booking),
),
if (!cancelled && booking.hasOrders && booking.isMultiDestination)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
10,
),
child: _YourPackages(booking: booking, focused: focused),
),
),
if (assigning && booking.milersInZone > 0)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
@@ -161,57 +204,19 @@ class TrackingScreen extends StatelessWidget {
DmSpace.pad,
10,
),
child: DmInfoBanner(
icon: LucideIcons.users,
title:
'${booking.milersInZone} verified Milers active in your zone',
message: 'Most pickups are accepted within a few minutes.',
child: _RouteOutline(
booking: booking,
slot: slot,
focused: focused,
),
),
if (milerEnRoute)
_MilerCard(person: booking.miler!, role: 'Your Miler'),
if (!cancelled &&
stage == JourneyStage.outForDelivery &&
booking.deliveryAgent != null)
_MilerCard(person: booking.deliveryAgent!, role: 'Delivery Miler'),
if (!cancelled)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
10,
),
child: _Journey(booking: booking),
),
if (!cancelled && booking.hasOrders && booking.isMultiDestination)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
10,
),
child: _YourPackages(booking: booking, focused: focused),
),
Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10),
child: _RouteOutline(
booking: booking,
slot: slot,
focused: focused,
),
),
// Stands in for backend push updates. Real stage changes arrive
// from `GET /customer/bookings/{reference}`, so this exists only
// where there is no server to send them.
if (AppConfig.showStageStepper) _StageStepper(booking: booking),
],
// Stands in for backend push updates. Real stage changes arrive
// from `GET /customer/bookings/{reference}`, so this exists only
// where there is no server to send them.
if (AppConfig.showStageStepper) _StageStepper(booking: booking),
],
),
),
bottomNavigationBar: DmFooter(
edge: true,
@@ -233,7 +238,11 @@ class TrackingScreen extends StatelessWidget {
/// full-width outlined button in the danger colour — the most prominent
/// control on a screen whose job is to reassure someone that their parcel is
/// fine.
List<Widget> _footerActions(BuildContext context, AppState app, Booking booking) {
List<Widget> _footerActions(
BuildContext context,
AppState app,
Booking booking,
) {
if (booking.isCancelled || booking.stage == JourneyStage.delivered) {
return [
DmButton(
@@ -384,7 +393,9 @@ class _StatusHeader extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: DmText.small.copyWith(color: DmColors.onBrand3),
style: DmText.small.copyWith(
color: DmColors.onBrand3,
),
),
),
],
@@ -392,25 +403,35 @@ class _StatusHeader extends StatelessWidget {
const SizedBox(height: 12),
Text(
headline,
style: DmText.display.copyWith(fontSize: 27, color: Colors.white),
style: DmText.display.copyWith(
fontSize: 27,
color: Colors.white,
),
),
// The supporting sentence is gone. It restated the headline
// — "Arun Kumar is assigned" over "Your Miler will arrive in
// your pickup window" — and sat above a milestone label
// saying "Miler assigned" and a counter saying "Step 2 of 7".
// Four lines to answer a question the first one had answered.
//
// What is left is the one fact the headline does not carry:
// when. It is set at heading size because after "what is
// happening" it is the only thing the customer came for.
const SizedBox(height: 14),
Text(
_footnote(stage),
style: DmText.heading.copyWith(
fontSize: 17,
color: DmColors.onBrand2,
),
),
const SizedBox(height: 6),
Text(sub, style: DmText.small.copyWith(color: DmColors.onBrand2)),
const SizedBox(height: 16),
DmStepBar(
progress: step / total,
steps: total,
current: step,
onFill: true,
// The milestone, not the window: the window is already the
// line underneath, and at this width it clipped to
// "Window 2:00 – 4:…".
label: CustomerMilestone.of(stage).label,
),
const SizedBox(height: 10),
Text(
_footnote(stage),
style: DmText.tiny.copyWith(color: DmColors.onBrand3),
caption: false,
),
],
),
@@ -436,26 +457,23 @@ class _StatusHeader extends StatelessWidget {
}
}
/// The journey, compressed to what just happened.
class _Journey extends StatefulWidget {
/// The journey. All of it, always.
///
/// It used to show the three most recent milestones behind a "Full timeline"
/// action, which was the right compression for seven and is the wrong one for
/// five: the whole shipment now fits in the space the summary occupied, and a
/// customer who opens tracking to ask "where is my parcel" should not have to
/// tap to see the answer's context.
class _Journey extends StatelessWidget {
const _Journey({required this.booking});
final Booking booking;
@override
State<_Journey> createState() => _JourneyState();
}
class _JourneyState extends State<_Journey> {
bool _all = false;
@override
Widget build(BuildContext context) {
return DmMilestones(
booking: widget.booking,
title: widget.booking.trackingId == null ? 'Recent updates' : 'Live journey',
maxRows: _all ? null : 3,
onExpand: () => setState(() => _all = true),
booking: booking,
title: booking.trackingId == null ? 'Journey' : 'Live journey',
);
}
}
@@ -497,42 +515,6 @@ class _Badge extends StatelessWidget {
}
/// Radar shown while the backend matches a Miler.
class _AssigningHero extends StatelessWidget {
const _AssigningHero();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 20),
child: Container(
height: 200,
alignment: Alignment.center,
decoration: BoxDecoration(
color: DmColors.surface,
border: Border.all(color: DmColors.border),
borderRadius: DmRadius.all(DmRadius.md),
),
child: DmRadarPulse(
size: 170,
child: Container(
width: 62,
height: 62,
decoration: const BoxDecoration(
color: DmColors.brand,
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.bike,
size: 28,
color: Colors.white,
),
),
),
),
);
}
}
/// Who is collecting, how good they are, and how to reach them.
///
/// A wash rather than a plain card: this is the one row on the screen about a
@@ -608,7 +590,8 @@ class _MilerCard extends StatelessWidget {
label: 'Call',
icon: LucideIcons.phone,
filled: true,
onPressed: () => DmToast.show(context, 'Calling ${person.name}…'),
onPressed: () =>
DmToast.show(context, 'Calling ${person.name}…'),
),
const SizedBox(width: 8),
DmMiniButton(
@@ -645,37 +628,37 @@ class _RouteOutline extends StatelessWidget {
return DmCard(
children: [
DmCardCell(
child: DmRouteRail(
pickup: booking.pickup.title,
pickupNote: booking.pickup.sub,
drop: group != null
? group.destination.label
: booking.isMultiDestination
? '${booking.destinations.length} destinations'
: booking.destination.label,
dropNote: group != null
? '${group.packageCount} '
'${group.packageCount == 1 ? 'package' : 'packages'}'
: booking.isMultiDestination
? booking.packageSummary
: booking.details.exactAddress ??
(recipient == null
? 'Exact address collected at pickup'
: 'Recipient · $recipient'),
),
),
DmSummaryRow(label: 'Pickup window', value: slot?.label ?? '—'),
DmSummaryRow(
label: (group?.trackingId ?? booking.trackingId) == null
? 'Booking reference'
: 'Tracking number',
value: group?.trackingId ?? booking.displayId,
mono: true,
secondary: group == null
? null
: 'Collected together · Pickup ${booking.reference}',
DmCardCell(
child: DmRouteRail(
pickup: booking.pickup.title,
pickupNote: booking.pickup.sub,
drop: group != null
? group.destination.label
: booking.isMultiDestination
? '${booking.destinations.length} destinations'
: booking.destination.label,
dropNote: group != null
? '${group.packageCount} '
'${group.packageCount == 1 ? 'package' : 'packages'}'
: booking.isMultiDestination
? booking.packageSummary
: booking.details.exactAddress ??
(recipient == null
? 'Exact address collected at pickup'
: 'Recipient · $recipient'),
),
),
DmSummaryRow(label: 'Pickup window', value: slot?.label ?? '—'),
DmSummaryRow(
label: (group?.trackingId ?? booking.trackingId) == null
? 'Booking reference'
: 'Tracking number',
value: group?.trackingId ?? booking.displayId,
mono: true,
secondary: group == null
? null
: 'Collected together · Pickup ${booking.reference}',
),
],
);
}
@@ -722,10 +705,7 @@ class _YourPackages extends StatelessWidget {
),
),
for (final group in booking.destinations)
_PackageRow(
group: group,
isFocused: identical(group, focused),
),
_PackageRow(group: group, isFocused: identical(group, focused)),
],
);
}
@@ -760,7 +740,8 @@ class _PackageRow extends StatelessWidget {
style: DmText.monoSm,
),
TextSpan(
text: ' · ${group.packageCount} '
text:
' · ${group.packageCount} '
'${group.packageCount == 1 ? 'package' : 'packages'}',
),
],

46
lib/ui/state_marks.dart Normal file
View File

@@ -0,0 +1,46 @@
import 'package:flutter/widgets.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
/// A glyph per state, keyed by the backend's state code.
///
/// ── Why the state and not the district ──
///
/// The first version of this marked every *district*: Chennai a capital,
/// Coimbatore a factory, Tiruppur knitwear, and so on down a flat list of
/// twelve. It scanned well at twelve. It does not survive sixty — and more to
/// the point, it put the app in the business of having an opinion about every
/// district in India, which is a table nobody can keep honest and a mistake
/// somebody eventually finds offensive.
///
/// There are twenty-eight states. Giving each one a mark is a bounded problem,
/// the marks stay recognisable, and the district list underneath is left as
/// what it should be: names and delivery promises, nothing competing with them.
///
/// ── The rule each glyph follows ──
///
/// One thing about the place that a person living in India would recognise,
/// and never a religious building — a temple state drawn as a church is worse
/// than no icon at all. These are editorial choices, not data: they are meant
/// to be argued with and changed, which is why they are one table in one file.
///
/// ── The fallback is the contract ──
///
/// [stateMark] answers for every code, including ones that do not exist yet.
/// The serviceable list is the backend's, so a state opened there appears here
/// immediately — with the pin, looking deliberate, until somebody adds a line.
/// Nothing renders blank and nothing throws. Adding a state is one entry;
/// forgetting to is not a bug.
const Map<String, IconData> _marks = {
'TN': LucideIcons.landmark, // the gopuram silhouette
'KL': LucideIcons.treePalm, // the backwaters
'KA': LucideIcons.crown, // the Mysuru palace
'AP': LucideIcons.mountain, // the eastern ghats
'TG': LucideIcons.castle, // the Golconda fort
'MH': LucideIcons.ship, // the Bombay harbour
'GA': LucideIcons.sailboat,
'PY': LucideIcons.waves,
};
/// The mark for a state code, or a pin for one nobody has named yet.
IconData stateMark(String? stateCode) =>
_marks[stateCode] ?? LucideIcons.mapPin;

View File

@@ -17,7 +17,7 @@ import 'package:flutter/material.dart';
/// meaning — `ok` survives only for the delivered state, `danger` only for
/// destructive text.
///
/// **Manrope for voice, Geist Mono for data.** Headings run at w800 with tight
/// **Poppins for voice, Geist Mono for data.** Headings run at w800 with tight
/// negative tracking; anything a customer might read back aloud or check
/// digit-by-digit — a booking reference, a phone number, a fare, a plate, a
/// countdown — is set in mono so the glyphs line up and a transposed character
@@ -98,28 +98,38 @@ class DmColors {
class DmRadius {
DmRadius._();
// Every corner was opened up on 2026-09-22. At 8–12 the app measured
// correct and looked like a form: a screen of tight rectangles, which is
// exactly the "boxy" the redesign was called in to fix. Four points across
// the scale is the difference between a panel and a card, and it costs
// nothing but the token.
/// Tiles, chips, the small square that holds a glyph.
static const xs = 8.0;
static const xs = 10.0;
/// Buttons, fields, icon buttons. The design's most-used corner.
static const sm = 9.0;
static const sm = 12.0;
/// Cards and every panel that holds rows.
static const md = 10.0;
static const md = 14.0;
/// Sheets' grabber area, the segmented control, the header's bottom sweep.
static const lg = 12.0;
static const lg = 16.0;
/// Bottom sheets and the auth sheet that overlaps the hero.
static const xl = 16.0;
/// Bottom sheets and the auth sheet that overlaps the hero. Moved with the
/// scale — at 16 it had collided with [lg], and a sheet that shares a corner
/// with the cards inside it has stopped being a separate surface.
static const xl = 20.0;
/// The brand header's bottom corners.
static const header = 18.0;
static const header = 26.0;
static const pill = 999.0;
/// Kept for call sites that mean "a primary bar's corner".
static const book = sm;
/// A primary bar's corner. Softer than a field's, because the committing
/// action is the one element on a screen allowed to look like an object
/// rather than part of the form.
static const book = 16.0;
static BorderRadius all(double r) => BorderRadius.circular(r);
}
@@ -199,27 +209,59 @@ class DmMotion {
/// The bottom nav's travelling pill — a touch of overshoot, which reads as
/// the indicator catching up rather than teleporting.
static const glide = Cubic(0.34, 1.06, 0.36, 1);
/// Screen transitions. An emphasised decelerate: it leaves quickly and
/// settles slowly, so a push feels driven at the start and arrived-at by the
/// end. `easeOutCubic` is too even across a distance this short — it reads
/// as a constant-speed slide.
static const enter = Cubic(0.05, 0.7, 0.1, 1);
}
/// Manrope for voice, Geist Mono for data.
/// Poppins for voice, Geist Mono for data.
///
/// Both ship as single variable-font files, which is why every style below sets
/// `fontVariations` as well as `fontWeight` — see the note inside the class.
/// ── Why the sans styles look different from the mono ones ──
///
/// Poppins has no variable release. Google Fonts ships it as static cuts only,
/// so the sans styles below set `fontWeight` and nothing else: there is no
/// `wght` axis to move, and `fontVariations` on a static font is ignored in
/// silence, which is worse than useless — it reads as if it were doing the
/// work. `pubspec.yaml` declares one file per weight and Flutter matches on the
/// enum. Geist Mono is still variable and its three styles still carry the axis.
///
/// Only the four weights the design uses are bundled — 500, 600, 700, 800 —
/// because each one is a separate ~155 KB file. Asking for w900 anywhere would
/// not fail; Flutter would quietly synthesise it from Bold, and it would look
/// like it.
///
/// ── Every weight is one step lighter than it was ──
///
/// Poppins is a geometric face built on near-circles, and it carries far more
/// ink at the same nominal weight than the humanist faces before it. Styles
/// that were w900 are w800, w800 are w700, w700 are w600. Set at the old
/// numbers the headings looked inflated rather than strong.
///
/// ── And the tracking is deeper again ──
///
/// The opposite correction from the last face. Poppins is wide, round and has
/// generous default sidebearings, so display sizes need pulling in harder than
/// anything this app has used — the header runs at −1.2. Body sizes stay near
/// zero, because the same roundness is what makes it legible small and tracking
/// it in takes that back. Tracking belongs to a typeface, not to a design.
class DmText {
DmText._();
static const _f = 'Manrope';
static const _f = 'Poppins';
static const _mono = 'GeistMono';
// The weight axis. Manrope and Geist Mono both ship as variable fonts, so a
// The weight axis, for Geist Mono only. It ships as one variable file, so a
// style has to move `wght` to change weight — `fontWeight` alone picks the
// nearest named instance and, on a single-file VF, that is always the
// default. Both are set on every style: the axis does the work, the enum
// keeps the semantics (and the system-font fallback) correct.
// default. Both are set on its styles: the axis does the work, the enum keeps
// the semantics (and the system-font fallback) correct.
//
// Poppins needs none of this. It is four static files and Flutter matches
// them on `fontWeight`.
static const _w500 = [FontVariation('wght', 500)];
static const _w600 = [FontVariation('wght', 600)];
static const _w700 = [FontVariation('wght', 700)];
static const _w800 = [FontVariation('wght', 800)];
// Letter spacing is quoted in em by the design and converted here at each
// size, because -0.035em is a different number of pixels on a 31pt display
@@ -230,23 +272,23 @@ class DmText {
/// Screen-dominating statement — "Pickup booked", "Arun is 12 minutes away".
static const display = TextStyle(
fontFamily: _f, fontSize: 31, height: 1.12,
fontWeight: FontWeight.w800, fontVariations: _w800,
letterSpacing: -1.09, color: DmColors.ink,
fontFamily: _f, fontSize: 31, height: 1.1,
fontWeight: FontWeight.w800,
letterSpacing: -0.95, color: DmColors.ink,
);
/// The brand header's own title, sitting on crimson.
static const headerTitle = TextStyle(
fontFamily: _f, fontSize: 34, height: 1.0,
fontWeight: FontWeight.w800, fontVariations: _w800,
letterSpacing: -1.36, color: Colors.white,
fontWeight: FontWeight.w800,
letterSpacing: -1.2, color: Colors.white,
);
/// Screen headings on a canvas.
static const title = TextStyle(
fontFamily: _f, fontSize: 25, height: 1.18,
fontWeight: FontWeight.w800, fontVariations: _w800,
letterSpacing: -0.75, color: DmColors.ink,
fontFamily: _f, fontSize: 25, height: 1.16,
fontWeight: FontWeight.w800,
letterSpacing: -0.65, color: DmColors.ink,
);
/// Tab-root page titles where no brand header is used.
@@ -255,14 +297,14 @@ class DmText {
/// The heading above a group of cards, and a sheet's own title.
static const heading = TextStyle(
fontFamily: _f, fontSize: 18, height: 1.25,
fontWeight: FontWeight.w700, fontVariations: _w700,
letterSpacing: -0.45, color: DmColors.ink,
fontWeight: FontWeight.w700,
letterSpacing: -0.4, color: DmColors.ink,
);
static const sheetTitle = TextStyle(
fontFamily: _f, fontSize: 19, height: 1.2,
fontWeight: FontWeight.w800, fontVariations: _w800,
letterSpacing: -0.57, color: DmColors.ink,
fontFamily: _f, fontSize: 21, height: 1.2,
fontWeight: FontWeight.w800,
letterSpacing: -0.55, color: DmColors.ink,
);
// ── Rows ─────────────────────────────────────────────────────────────────
@@ -270,8 +312,8 @@ class DmText {
/// A row's own line — `t1` in the design, and the workhorse of the app.
static const cardTitle = TextStyle(
fontFamily: _f, fontSize: 15, height: 1.4,
fontWeight: FontWeight.w700, fontVariations: _w700,
letterSpacing: -0.18, color: DmColors.ink,
fontWeight: FontWeight.w600,
letterSpacing: -0.15, color: DmColors.ink,
);
/// Alias: the same weight of statement, used mid-form.
@@ -279,55 +321,55 @@ class DmText {
static const body = TextStyle(
fontFamily: _f, fontSize: 15, height: 1.5,
fontWeight: FontWeight.w500, fontVariations: _w500,
letterSpacing: -0.12, color: DmColors.ink,
fontWeight: FontWeight.w500,
letterSpacing: -0.1, color: DmColors.ink,
);
/// Supporting line under a row title — `t2`.
static const small = TextStyle(
fontFamily: _f, fontSize: 13.5, height: 1.45,
fontWeight: FontWeight.w500, fontVariations: _w500,
fontWeight: FontWeight.w500,
color: DmColors.ink2,
);
/// The quietest line — `t3`. Timestamps, counts, "collected on".
static const tiny = TextStyle(
fontFamily: _f, fontSize: 13, height: 1.4,
fontWeight: FontWeight.w500, fontVariations: _w500,
fontWeight: FontWeight.w500,
color: DmColors.ink3,
);
/// Paragraph under a screen heading.
static const lede = TextStyle(
fontFamily: _f, fontSize: 14.5, height: 1.55,
fontWeight: FontWeight.w500, fontVariations: _w500,
fontWeight: FontWeight.w500,
color: DmColors.ink2,
);
/// The right-hand value in a settings row.
static const value = TextStyle(
fontFamily: _f, fontSize: 13.5, height: 1.4,
fontWeight: FontWeight.w600, fontVariations: _w600,
fontWeight: FontWeight.w600,
color: DmColors.ink2,
);
/// Field labels, tags, chips, inline actions.
static const label = TextStyle(
fontFamily: _f, fontSize: 12.5, height: 1.3,
fontWeight: FontWeight.w700, fontVariations: _w700,
fontWeight: FontWeight.w600,
color: DmColors.ink2,
);
static const button = TextStyle(
fontFamily: _f, fontSize: 15.5, height: 1.2,
fontWeight: FontWeight.w700, fontVariations: _w700,
letterSpacing: -0.23,
fontFamily: _f, fontSize: 16, height: 1.2,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
);
/// The text-only action under a primary button ("Back to home").
static const textButton = TextStyle(
fontFamily: _f, fontSize: 14.5, height: 1.2,
fontWeight: FontWeight.w700, fontVariations: _w700,
fontWeight: FontWeight.w600,
color: DmColors.ink2,
);
@@ -335,22 +377,22 @@ class DmText {
/// it heads brand content, [DmColors.ink3] when it heads a neutral group.
static const eyebrow = TextStyle(
fontFamily: _f, fontSize: 10.5, height: 1.3,
fontWeight: FontWeight.w700, fontVariations: _w700,
fontWeight: FontWeight.w600,
letterSpacing: 1.16, color: DmColors.ink3,
);
/// The eyebrow as it appears on the crimson header.
static const headerEyebrow = TextStyle(
fontFamily: _f, fontSize: 11.5, height: 1.3,
fontWeight: FontWeight.w800, fontVariations: _w800,
fontWeight: FontWeight.w700,
letterSpacing: 1.84, color: DmColors.onBrand3,
);
/// Sentence-case heading above a group of rows.
static const sectionHead = TextStyle(
fontFamily: _f, fontSize: 15, height: 1.3,
fontWeight: FontWeight.w700, fontVariations: _w700,
letterSpacing: -0.3, color: DmColors.ink,
fontWeight: FontWeight.w700,
letterSpacing: -0.25, color: DmColors.ink,
);
/// Alias kept for existing call sites.
@@ -359,7 +401,7 @@ class DmText {
/// Field input text.
static const field = TextStyle(
fontFamily: _f, fontSize: 16, height: 1.3,
fontWeight: FontWeight.w600, fontVariations: _w600,
fontWeight: FontWeight.w600,
color: DmColors.ink,
);
@@ -389,7 +431,7 @@ class DmText {
/// The OTP digits.
static const otp = TextStyle(
fontFamily: _f, fontSize: 24, height: 1.1,
fontWeight: FontWeight.w700, fontVariations: _w700,
fontWeight: FontWeight.w600,
color: DmColors.ink,
);
}

View File

@@ -0,0 +1,405 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../tokens.dart';
/// The one action on Home, as a crimson sphere that breathes.
///
/// ── Why a circle, and why it says one word ──
///
/// Home was a filled rectangular card: a heading, a sentence and a white
/// button inside it. Correct, and completely inert — three stacked rectangles
/// on a screen of stacked rectangles, where the thing the app exists for
/// looked like the things it does not.
///
/// A disc is the one shape nothing else on the screen uses, so it needs no
/// border, no label and no arrow to be found. It is also the shape the gesture
/// wants: a thumb lands on a circle, and the press can scale from the centre
/// without the corners doing anything awkward.
///
/// ── Why it is lit, and not a flat fill ──
///
/// The first version of this was a flat crimson circle with two hairline rings
/// around it, and it read as exactly what it was: shapes a program drew. Three
/// things separate a designed object from a drawn one, and all three are here.
///
/// *Light has a direction.* The fill is a radial gradient with its hot point up
/// and to the left, falling to a much deeper crimson at the lower right, with a
/// soft specular bloom over the top of it and a rim that is bright along the
/// top edge and dark along the bottom. That is one light source, consistently
/// obeyed, and it is what makes a circle read as a sphere.
///
/// *Nothing terminates hard.* Every ring here is drawn through a blur, so it
/// has a soft shoulder instead of a 1px edge; the glow underneath is two
/// shadows at different radii, not one. Hairlines are for dividing content.
///
/// *Something is always moving, slowly.* A comet of light orbits the sphere
/// once every six seconds and the sphere itself breathes about 1%. Neither is
/// fast enough to notice directly — they are there so the screen is never
/// completely still under a thumb.
///
/// ── The rings are the product, not decoration ──
///
/// Three of them leave the sphere and fade outward. This app sends somebody to
/// your door, and the radar sweep is what that looks like — the same reason a
/// ride app pulses over a pickup pin. They are slow and low-contrast on
/// purpose: a fast pulse reads as an alarm, and this is meant to sit under a
/// thumb for as long as somebody is deciding.
///
/// ── What the word alone could not do ──
///
/// This screen carried a 68pt BOOK bar once and it was replaced, for a good
/// reason recorded at the time: "a word with no object, which on first run told
/// a new customer nothing about what the app does." That is still true of the
/// word by itself, so [caption] sits under the sphere and does the explaining.
/// The boldness is in the shape; the sentence is what stops it being a shout.
class DmBookOrb extends StatefulWidget {
const DmBookOrb({
super.key,
required this.onTap,
this.label = 'BOOK',
this.caption,
this.held = false,
});
final VoidCallback onTap;
final String label;
/// Keeps the sphere compressed while the surface it opened is on screen.
///
/// Without this the sphere springs back the instant the finger lifts, and
/// the sheet that rises a moment later reads as an unrelated screen arriving
/// over the top. Held down, it reads as the thing that pushed the sheet up —
/// which is the only reason the customer believes they are still on Home.
final bool held;
/// One line under the sphere saying what will happen. Optional only so a
/// second use of this widget elsewhere is not forced to repeat itself.
final String? caption;
@override
State<DmBookOrb> createState() => _DmBookOrbState();
}
class _DmBookOrbState extends State<DmBookOrb> with TickerProviderStateMixin {
/// Drives the pulses and the orbiting comet from one clock. Separate
/// controllers would drift apart over a long session and the pair would stop
/// reading as one rhythm.
late final AnimationController _radar = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 6000),
)..repeat();
/// The breath. Its own clock because it mirrors, and because 1% at four
/// seconds has no business being locked to the radar's six.
late final AnimationController _breath = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 4000),
)..repeat(reverse: true);
bool _down = false;
@override
void dispose() {
_radar.dispose();
_breath.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
const disc = 204.0;
const field = disc + 116;
return Semantics(
button: true,
label: widget.caption == null
? widget.label
: '${widget.label}. ${widget.caption}',
child: Column(
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => setState(() => _down = true),
onTapCancel: () => setState(() => _down = false),
onTap: () {
setState(() => _down = false);
// The press is a commitment — it opens the booking flow — and a
// sphere gives no edge feedback the way a bordered button does.
HapticFeedback.mediumImpact();
widget.onTap();
},
child: SizedBox(
width: field,
height: field,
child: Stack(
alignment: Alignment.center,
children: [
// Pulses and comet sit behind and are never hit-tested — they
// leave the sphere's edge, so a tap near them is a tap that
// was meant for it.
IgnorePointer(
child: AnimatedBuilder(
animation: _radar,
builder: (_, _) => CustomPaint(
size: const Size.square(field),
painter: _RadarPainter(_radar.value, disc / 2),
),
),
),
AnimatedScale(
scale: _down || widget.held ? 0.94 : 1,
// Just past the press, so the finger feels the give rather
// than watching it happen.
duration: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
child: ScaleTransition(
scale: Tween(begin: 1.0, end: 1.012).animate(
CurvedAnimation(parent: _breath, curve: Curves.easeInOut),
),
child: _Sphere(size: disc, label: widget.label),
),
),
],
),
),
),
if (widget.caption != null) ...[
const SizedBox(height: 4),
Text(
widget.caption!,
textAlign: TextAlign.center,
style: DmText.small.copyWith(color: DmColors.ink3, height: 1.45),
),
],
],
),
);
}
}
/// The lit body. Four layers, all obeying one light from the upper left:
/// gradient fill, specular bloom, rim, then the word.
class _Sphere extends StatelessWidget {
const _Sphere({required this.size, required this.label});
final double size;
final String label;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
// Up and to the left, which is where every lit object in this app
// takes its light from.
center: Alignment(-0.38, -0.52),
radius: 1.08,
colors: [Color(0xFFC0230F), DmColors.brand, Color(0xFF5C0802)],
stops: [0.0, 0.5, 1.0],
),
boxShadow: [
// Two radii rather than one: the tight shadow gives the sphere its
// contact with the page, the wide one is the light it throws. Both
// are pulled in hard — at full strength they bloom into a pink cloud
// that reads as a smudge under the object rather than as its shadow.
BoxShadow(
color: Color(0x478F0F06),
blurRadius: 26,
spreadRadius: -16,
offset: Offset(0, 14),
),
BoxShadow(
color: Color(0x1A8F0F06),
blurRadius: 52,
spreadRadius: -18,
offset: Offset(0, 20),
),
],
),
child: Stack(
alignment: Alignment.center,
children: [
// Specular — the sheen where the light actually lands. Small and
// soft; a hard highlight would read as plastic.
const Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
center: Alignment(-0.42, -0.58),
radius: 0.62,
colors: [Color(0x40FFFFFF), Color(0x00FFFFFF)],
),
),
),
),
// Rim: bright where the light grazes the top edge, dark where the
// body turns away at the bottom. A uniform border would flatten it.
const Positioned.fill(child: CustomPaint(painter: _RimPainter())),
// One hairline set in from the edge. Barely visible, and the reason
// the face reads as a made object rather than a filled circle.
Positioned.fill(
child: Padding(
padding: const EdgeInsets.all(15),
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: const Color(0x14FFFFFF)),
),
),
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: DmText.heading.copyWith(
fontSize: 32,
height: 1,
letterSpacing: 5,
fontWeight: FontWeight.w800,
fontVariations: const [FontVariation('wght', 800)],
color: Colors.white,
shadows: const [
Shadow(color: Color(0x4D5C0802), blurRadius: 12, offset: Offset(0, 2)),
],
),
),
const SizedBox(height: 6),
const Icon(
LucideIcons.arrowUpRight,
size: 18,
color: Color(0xB3FFFFFF),
),
],
),
],
),
);
}
}
/// A rim that changes with the light: white along the top arc, shadow along
/// the bottom, nothing at the sides.
class _RimPainter extends CustomPainter {
const _RimPainter();
@override
void paint(Canvas canvas, Size size) {
final rect = Offset.zero & size;
canvas.drawCircle(
rect.center,
size.width / 2 - 0.5,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.2
..shader = const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x59FFFFFF), Color(0x0DFFFFFF), Color(0x334A0602)],
stops: [0.0, 0.45, 1.0],
).createShader(rect),
);
}
@override
bool shouldRepaint(_RimPainter old) => false;
}
/// What surrounds the sphere: three pulses leaving it, and one comet of light
/// orbiting it.
class _RadarPainter extends CustomPainter {
const _RadarPainter(this.t, this.startRadius);
/// 0→1, repeating once every six seconds.
final double t;
/// Where a pulse is born: the sphere's edge, so it looks emitted rather than
/// drawn around.
final double startRadius;
@override
void paint(Canvas canvas, Size size) {
final centre = Offset(size.width / 2, size.height / 2);
_pulses(canvas, centre, size);
_comet(canvas, centre);
}
void _pulses(Canvas canvas, Offset centre, Size size) {
final travel = (size.width / 2) - startRadius;
for (final phase in const [0.0, 0.34, 0.67]) {
final p = (t + phase) % 1.0;
// Eased outward: quick to leave, slow to die, which is how a real pulse
// decays. Linear travel reads mechanical.
final eased = 1 - math.pow(1 - p, 2.4).toDouble();
final radius = startRadius + travel * eased;
// Fades the whole way out, so nothing ever sits at full strength at the
// edge where it would read as a second object.
final opacity = (1 - p) * (1 - p) * 0.42;
if (opacity <= 0.003) continue;
canvas.drawCircle(
centre,
radius,
Paint()
..style = PaintingStyle.stroke
// Thins as it expands, like a wavefront losing energy.
..strokeWidth = 2.2 - 1.2 * eased
// The blur is the point. A crisp 1px ring is a drawn shape; a ring
// with a soft shoulder is light.
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3.2)
..color = DmColors.brand.withValues(alpha: opacity),
);
}
}
/// A short arc of light running round the sphere's edge, with a bright head.
/// One slow-moving detail is what stops a static object looking switched off.
void _comet(Canvas canvas, Offset centre) {
final r = startRadius + 16;
final rect = Rect.fromCircle(center: centre, radius: r);
final angle = t * 2 * math.pi;
canvas.drawCircle(
centre,
r,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.6
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2.6)
..shader = SweepGradient(
// The tail runs behind the head and dies over a third of the circle.
colors: const [
Color(0x00000000),
Color(0x148F0F06),
Color(0x4D8F0F06),
Color(0x00000000),
],
stops: const [0.0, 0.2, 0.33, 0.34],
transform: GradientRotation(angle - math.pi / 2),
).createShader(rect),
);
// The head, a touch brighter than the arc it leads.
canvas.drawCircle(
centre + Offset(math.cos(angle - math.pi / 2), math.sin(angle - math.pi / 2)) * r,
2.6,
Paint()
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2.4)
..color = DmColors.brand.withValues(alpha: 0.75),
);
}
@override
bool shouldRepaint(_RadarPainter old) => old.t != t;
}

View File

@@ -28,6 +28,20 @@ enum DmButtonKind {
/// does not fade the crimson — it goes to the sunken neutral, because a
/// half-opacity red still reads as "press me" and this design uses exactly one
/// red for exactly one meaning.
///
/// ── Why the primary is lit (2026-09-22) ──
///
/// It was a flat crimson rectangle at the field radius, which is what a button
/// looks like when nobody has designed it. The BOOK sphere on Home is lit from
/// the upper left; a committing action that is not lit the same way reads as a
/// different material, and the app ends up with one crafted object and a row of
/// plain boxes underneath it.
///
/// So the primary now takes the sphere's treatment, dialled right down: the
/// same directional gradient, a white rim along the top edge where the light
/// would catch, and the crimson glow it already had. It also finally uses
/// [DmRadius.book] — the token that exists for exactly this and was never
/// wired up, leaving every CTA at the 12pt field corner.
class DmButton extends StatefulWidget {
const DmButton({
super.key,
@@ -78,7 +92,9 @@ class _DmButtonState extends State<DmButton> {
final Color foreground;
BoxBorder? border;
List<BoxShadow>? shadow;
Gradient? gradient;
double height = widget.height ?? DmSpace.button;
double radius = DmRadius.book;
switch (widget.kind) {
case DmButtonKind.primary:
@@ -87,6 +103,16 @@ class _DmButtonState extends State<DmButton> {
: (_down ? DmColors.brandPress : DmColors.brand);
foreground = _enabled ? Colors.white : DmColors.ink3;
shadow = _enabled && !_down ? DmShadow.lift : null;
// Lit like the sphere, and only while it can be pressed: a disabled
// control that still catches the light is a control that is lying.
gradient = _enabled && !_down
? const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFA6180B), DmColors.brand, Color(0xFF7A0C04)],
stops: [0.0, 0.55, 1.0],
)
: null;
case DmButtonKind.outline:
background = _down ? DmColors.surfaceAlt : DmColors.surface;
foreground = widget.danger ? DmColors.danger : DmColors.ink;
@@ -102,6 +128,7 @@ class _DmButtonState extends State<DmButton> {
background = Colors.transparent;
foreground = widget.danger ? DmColors.brand : DmColors.ink2;
height = widget.height ?? DmSpace.tap;
radius = DmRadius.sm;
}
final content = Row(
@@ -141,24 +168,45 @@ class _DmButtonState extends State<DmButton> {
],
);
// Presses translate rather than scale: the design's buttons sit on a
// hairline grid, and scaling a 1px border makes it shimmer.
final button = AnimatedSlide(
offset: _down && _enabled ? const Offset(0, 0.018) : Offset.zero,
final filled = widget.kind == DmButtonKind.primary;
// Presses translate rather than scale for anything with an edge: these
// buttons sit on a hairline grid, and scaling a 1px border makes it
// shimmer. The primary has no border, so it also takes a touch of scale —
// which is what makes a filled surface feel pressed rather than nudged.
final button = AnimatedScale(
scale: _down && _enabled && filled ? 0.985 : 1,
duration: DmMotion.fast,
curve: DmMotion.ease,
child: AnimatedContainer(
child: AnimatedSlide(
offset: _down && _enabled ? const Offset(0, 0.018) : Offset.zero,
duration: DmMotion.fast,
constraints: BoxConstraints(minHeight: height),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: background,
border: border,
borderRadius: DmRadius.all(DmRadius.sm),
boxShadow: shadow,
curve: DmMotion.ease,
child: AnimatedContainer(
duration: DmMotion.fast,
constraints: BoxConstraints(minHeight: height),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: gradient == null ? background : null,
gradient: gradient,
border: border,
borderRadius: DmRadius.all(radius),
boxShadow: shadow,
),
// The rim the light catches. Drawn in the foreground so the gradient
// cannot cover it, and only along the top — a full outline would
// read as a border and flatten the fill back out.
foregroundDecoration: gradient == null
? null
: BoxDecoration(
borderRadius: DmRadius.all(radius),
border: const Border(
top: BorderSide(color: Color(0x33FFFFFF), width: 1.2),
),
),
alignment: Alignment.center,
child: content,
),
alignment: Alignment.center,
child: content,
),
);
@@ -173,7 +221,14 @@ class _DmButtonState extends State<DmButton> {
onTapCancel: _enabled ? () => setState(() => _down = false) : null,
onTap: _enabled
? () {
HapticFeedback.selectionClick();
// A primary action commits to something. Everything else is
// navigation or a change of mind, and a medium impact for
// those makes the one that matters feel ordinary.
if (filled) {
HapticFeedback.mediumImpact();
} else {
HapticFeedback.selectionClick();
}
widget.onPressed!();
}
: null,

View File

@@ -3,12 +3,26 @@ import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../tokens.dart';
import 'buttons.dart';
/// Top bar for pushed screens: a square back affordance, a quiet title, an
/// optional trailing slot (usually the booking reference in mono), and the
/// booking step indicator.
class DmTopBar extends StatelessWidget implements PreferredSizeWidget {
///
/// ── It knows whether anything is under it ──
///
/// The bar used to be a flat slab of canvas, the same colour as the page, with
/// no line under it. Content scrolled beneath it and simply disappeared — an
/// address row would slide up and vanish behind a back arrow with nothing to
/// say it had gone anywhere. A bar that never changes is not a layer; it is
/// paint.
///
/// It listens to the [ScrollNotificationObserver] that [Scaffold] installs
/// around its body — the same mechanism Material's own `AppBar` uses for
/// `scrolledUnderElevation` — and the moment a list has scrolled under it, the
/// canvas turns to surface and a hairline fades in along the bottom. At the top
/// of a page it is invisible again. Nothing is announced, nothing moves; the
/// screen just stops looking like one plane.
class DmTopBar extends StatefulWidget implements PreferredSizeWidget {
const DmTopBar({
super.key,
this.title,
@@ -38,17 +52,67 @@ class DmTopBar extends StatelessWidget implements PreferredSizeWidget {
final VoidCallback? onBack;
final Color background;
/// The +1 is the hairline. A transparent [BorderSide] still measures 1pt, so
/// the bar is the same height scrolled or not — but it is a point taller than
/// the row inside it, and without this the Column overflows by exactly that
/// on every screen with a top bar.
@override
Size get preferredSize => Size.fromHeight(56 + (progress == null ? 0 : 42));
Size get preferredSize => Size.fromHeight(57 + (progress == null ? 0 : 42));
@override
State<DmTopBar> createState() => _DmTopBarState();
}
class _DmTopBarState extends State<DmTopBar> {
ScrollNotificationObserverState? _observer;
bool _under = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_observer?.removeListener(_onScroll);
_observer = ScrollNotificationObserver.maybeOf(context);
_observer?.addListener(_onScroll);
}
@override
void dispose() {
_observer?.removeListener(_onScroll);
_observer = null;
super.dispose();
}
void _onScroll(ScrollNotification notification) {
// The predicate is what keeps a horizontal strip inside the page — or a
// list inside a sheet over it — from driving the bar behind them.
if (!defaultScrollNotificationPredicate(notification)) return;
if (notification is! ScrollUpdateNotification &&
notification is! ScrollMetricsNotification) {
return;
}
// A few points of slack: a list that rests one pixel off the top, or a
// bounce at the end of an overscroll, should not flicker the line on.
final under = notification.metrics.extentBefore > 4;
if (under != _under && mounted) setState(() => _under = under);
}
@override
Widget build(BuildContext context) {
final current = progress == null || steps == null
final current = widget.progress == null || widget.steps == null
? 0
: (progress! * steps!).round().clamp(0, steps!);
: (widget.progress! * widget.steps!).round().clamp(0, widget.steps!);
return Material(
color: background,
return AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
decoration: BoxDecoration(
color: _under ? DmColors.surface : widget.background,
border: Border(
bottom: BorderSide(
color: _under ? DmColors.border : Colors.transparent,
),
),
),
child: SafeArea(
bottom: false,
child: Column(
@@ -58,39 +122,37 @@ class DmTopBar extends StatelessWidget implements PreferredSizeWidget {
height: 56,
child: Row(
children: [
const SizedBox(width: DmSpace.pad - 12),
DmIconButton(
icon: LucideIcons.arrowLeft,
tooltip: 'Back',
onPressed: onBack ?? () => Navigator.of(context).maybePop(),
),
const SizedBox(width: 6),
const SizedBox(width: DmSpace.pad - 14),
DmBackButton(onPressed: widget.onBack),
const SizedBox(width: 4),
Expanded(
child: title == null
child: widget.title == null
? const SizedBox.shrink()
: Text(
title!,
style: DmText.cardTitle.copyWith(
fontSize: 15.5,
letterSpacing: -0.31,
),
widget.title!,
style: DmText.heading.copyWith(fontSize: 17),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
?trailing,
?widget.trailing,
const SizedBox(width: DmSpace.pad),
],
),
),
if (progress != null)
if (widget.progress != null)
Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 16),
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
16,
),
child: DmStepBar(
progress: progress!,
steps: steps,
progress: widget.progress!,
steps: widget.steps,
current: current,
label: stepLabel,
label: widget.stepLabel,
),
),
],
@@ -100,6 +162,49 @@ class DmTopBar extends StatelessWidget implements PreferredSizeWidget {
}
}
/// Back.
///
/// ── Why it lost its box ──
///
/// It was a 40pt white square with a hairline border and a 12pt corner, which
/// on a canvas of white bordered cards read as *a card containing an arrow*
/// rather than as a way out of the screen. On the send screen, where the title
/// is now the route underneath it, that left a small empty card alone in the
/// top-left corner of an otherwise open page.
///
/// A back affordance is the one control in an app that needs no explaining and
/// no container. It is the glyph, a 48pt target around it, and a circular
/// ripple in the brand wash when it is pressed.
class DmBackButton extends StatelessWidget {
const DmBackButton({super.key, this.onPressed});
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: 'Back',
child: SizedBox(
width: DmSpace.tap,
height: DmSpace.tap,
child: InkResponse(
onTap: onPressed ?? () => Navigator.of(context).maybePop(),
radius: 24,
highlightShape: BoxShape.circle,
splashColor: DmColors.brandSoft,
highlightColor: DmColors.brandSoft,
child: const Icon(
LucideIcons.arrowLeft,
size: 22,
color: DmColors.ink,
),
),
),
);
}
}
/// Booking progress.
///
/// With [steps] it draws that many segments, captioned "Step n of m" in mono;
@@ -113,6 +218,7 @@ class DmStepBar extends StatelessWidget {
this.current = 0,
this.label,
this.onFill = false,
this.caption = true,
});
final double progress;
@@ -124,6 +230,14 @@ class DmStepBar extends StatelessWidget {
/// lightened white and the fill is solid white.
final bool onFill;
/// Whether to print "Step n of m" above the segments.
///
/// True inside the booking flow, where the customer is answering questions
/// and "how many left" is the thing they want. False on tracking, where the
/// same segments describe a journey nobody is working through — there,
/// counting the stages turns a parcel into a progress bar with homework.
final bool caption;
@override
Widget build(BuildContext context) {
final track = onFill ? const Color(0x52FFFFFF) : DmColors.border;
@@ -171,7 +285,7 @@ class DmStepBar extends StatelessWidget {
],
);
if (label == null && steps == null) return bar;
if (!caption || (label == null && steps == null)) return bar;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -311,8 +425,19 @@ class DmBrandHeader extends StatelessWidget {
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(DmRadius.header),
),
child: ColoredBox(
color: DmColors.brand,
child: DecoratedBox(
// Lit from the top-left like every other brand object in the app, so
// the crimson field has a direction instead of sitting flat. The spread
// is deliberately narrow — a header that reads as a gradient rather
// than as a colour has stopped being the brand.
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFA0150A), DmColors.brand, Color(0xFF7A0C04)],
stops: [0.0, 0.52, 1.0],
),
),
child: CustomPaint(
painter: const DmArcField(),
child: SafeArea(

View File

@@ -32,6 +32,7 @@ class DmTextField extends StatefulWidget {
this.digitsOnly = false,
this.mono = false,
this.onClear,
this.floating = false,
});
/// The caption above the field, or null for a field that stands alone.
@@ -59,6 +60,16 @@ class DmTextField extends StatefulWidget {
/// Shows a clear button on the right when non-null.
final VoidCallback? onClear;
/// Puts [label] inside the field while it is empty, and floats it above once
/// there is something to caption.
///
/// An empty field under its own caption is two rows to ask one question, and
/// on a screen with two of them that is four rows of chrome around two
/// slabs of white. Floating, the question sits where the answer will go — so
/// the field is never blank and the caption only appears when it has a job,
/// which is reminding the customer what they already typed.
final bool floating;
@override
State<DmTextField> createState() => _DmTextFieldState();
}
@@ -70,10 +81,16 @@ class _DmTextFieldState extends State<DmTextField> {
void initState() {
super.initState();
_node.addListener(() => setState(() {}));
if (widget.floating) {
widget.controller.addListener(_onText);
}
}
void _onText() => setState(() {});
@override
void dispose() {
if (widget.floating) widget.controller.removeListener(_onText);
_node.dispose();
super.dispose();
}
@@ -81,6 +98,12 @@ class _DmTextFieldState extends State<DmTextField> {
@override
Widget build(BuildContext context) {
final focused = _node.hasFocus;
// A floating label is above the field only once it has a reason to be:
// something typed, or a cursor in it.
final lifted = !widget.floating ||
focused ||
widget.controller.text.isNotEmpty;
final textStyle = widget.mono
? DmText.monoLg.copyWith(fontSize: 15.5, color: DmColors.ink)
: DmText.field;
@@ -90,25 +113,33 @@ class _DmTextFieldState extends State<DmTextField> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.label != null) ...[
Text.rich(
TextSpan(
style: DmText.label,
children: [
TextSpan(text: widget.label),
if (widget.optional)
const TextSpan(
text: ' Optional',
style: TextStyle(
color: DmColors.ink4,
fontWeight: FontWeight.w500,
if (widget.label != null)
AnimatedSize(
duration: DmMotion.fast,
curve: DmMotion.ease,
alignment: Alignment.bottomLeft,
child: !lifted
? const SizedBox(width: double.infinity)
: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text.rich(
TextSpan(
style: DmText.label,
children: [
TextSpan(text: widget.label),
if (widget.optional)
const TextSpan(
text: ' Optional',
style: TextStyle(
color: DmColors.ink4,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
],
),
),
const SizedBox(height: 8),
],
AnimatedContainer(
duration: DmMotion.fast,
curve: DmMotion.ease,
@@ -172,7 +203,12 @@ class _DmTextFieldState extends State<DmTextField> {
],
decoration: InputDecoration(
isDense: true,
hintText: widget.hint,
// While the label is down inside the field it IS the
// placeholder; [hint] only gets the slot once the label
// has floated out of it.
hintText: lifted
? widget.hint
: (widget.label ?? widget.hint),
hintStyle: DmText.field.copyWith(
color: DmColors.ink3,
fontWeight: FontWeight.w500,
@@ -231,8 +267,14 @@ class DmRow extends StatelessWidget {
final VoidCallback? onTap;
final bool showChevron;
/// The glyph in the row's leading tile. Account's rows all carry one, so the
/// list can be scanned by shape before it is read.
/// The row's leading glyph. Account's rows all carry one, so the list can be
/// scanned by shape before it is read.
///
/// It used to sit in a washed crimson tile. Four of those down a settings
/// list put four brand-coloured blocks on the quietest screen in the app,
/// and each one cost 56pt of the width the row's value needed — which is why
/// "38 Mettupalayam Road" rendered as "38 Mettupal…". The glyph alone scans
/// just as well.
final IconData? icon;
@override
@@ -249,15 +291,16 @@ class DmRow extends StatelessWidget {
child: Row(
children: [
if (icon != null) ...[
DmTile(icon: icon, tone: DmTileTone.wash),
const SizedBox(width: 12),
Icon(icon, size: 19, color: DmColors.ink3),
const SizedBox(width: 14),
],
// The label is what the row IS, so it never wraps and never
// gives up width; the value is context and ellipsizes. A saved
// address as a value would otherwise push "Saved addresses" onto
// two lines.
Expanded(
flex: 3,
// The label takes the width it needs and the value takes what
// is left. A fixed 3:2 split gave the label room it was not
// using — "Saved addresses" is short — and rendered the value
// beside it as "38 Mettupal…", which is a truncation nobody can
// read anything out of. The label stays Flexible so a long one
// at large text sizes can still give way.
Flexible(
child: Text(
label,
style: DmText.cardTitle,
@@ -266,9 +309,8 @@ class DmRow extends StatelessWidget {
),
),
if (value != null) ...[
const SizedBox(width: 10),
Flexible(
flex: 2,
const SizedBox(width: 12),
Expanded(
child: Text(
value!,
style: DmText.value,
@@ -328,3 +370,64 @@ class DmSwitch extends StatelessWidget {
);
}
}
/// The search field used by every picker sheet.
///
/// It was written inline inside the pickup sheet and then wanted again by the
/// destination sheet, which is the moment a style becomes a component. Filled
/// white on the canvas rather than outlined: a search field that is the only
/// control above a list does not need an edge to be found, and the fill is what
/// makes it read as a place to type rather than as the first row of the list.
class DmSearchField extends StatelessWidget {
const DmSearchField({
super.key,
required this.controller,
required this.hint,
this.onChanged,
this.autofocus = false,
});
final TextEditingController controller;
final String hint;
final ValueChanged<String>? onChanged;
final bool autofocus;
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
onChanged: onChanged,
autofocus: autofocus,
cursorColor: DmColors.brand,
style: DmText.field,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: hint,
hintStyle: DmText.field.copyWith(
color: DmColors.ink3,
fontWeight: FontWeight.w500,
),
prefixIcon: const Icon(
LucideIcons.search,
size: 20,
color: DmColors.ink3,
),
filled: true,
fillColor: DmColors.surface,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
border: _border(DmColors.border, 1),
enabledBorder: _border(DmColors.border, 1),
focusedBorder: _border(DmColors.brand, 1.5),
),
);
}
static OutlineInputBorder _border(Color color, double width) =>
OutlineInputBorder(
borderRadius: DmRadius.all(DmRadius.sm),
borderSide: BorderSide(color: color, width: width),
);
}

View File

@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../data/models.dart';
import '../format.dart';
import '../tokens.dart';
import 'buttons.dart';
import 'cards.dart';
/// The customer's journey: seven milestones, not nine backend states.
///
@@ -41,7 +41,7 @@ class DmMilestones extends StatelessWidget {
/// Show at most this many rows, newest first. Null shows the whole journey.
final int? maxRows;
/// Tapped from the "All n stages" action. Null hides the action.
/// Tapped from the "Full timeline" action. Null hides the action.
final VoidCallback? onExpand;
@override
@@ -51,58 +51,71 @@ class DmMilestones extends StatelessWidget {
final milestones = CustomerMilestone.values;
final currentIndex = currentMilestone.index;
// Newest first: the current milestone, then everything already done, then
// — only if there is room — what is still to come.
final ordered = [
for (var i = currentIndex; i >= 0; i--) i,
for (var i = currentIndex + 1; i < milestones.length; i++) i,
];
final shown = maxRows == null ? ordered : ordered.take(maxRows!).toList();
// ── Chronological, because the line is the progress ──
//
// This ran newest-first, on the argument that "where is my parcel right
// now" should not be at the bottom of the list. That was right when the
// rail was a list of rows and wrong now that it is a rail: the travelled
// part of the spine is drawn crimson and the rest grey, and a reversed
// order makes a filling line read backwards. The answer is also no longer
// down here — the crimson card at the top of tracking says it outright,
// so this is free to answer the other question, which is *how far along*.
//
// Compressed, it shows the current milestone and the two before it: recent
// activity stays on screen without the rail claiming the whole page.
final ordered = [for (var i = 0; i < milestones.length; i++) i];
final shown = maxRows == null
? ordered
: ordered
.sublist(
(currentIndex - maxRows! + 1).clamp(0, milestones.length),
(currentIndex + 1).clamp(1, milestones.length),
)
.toList();
return DmCard(
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 15, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Row(
children: [
Expanded(
child: Text(
title.toUpperCase(),
style: DmText.eyebrow.copyWith(color: DmColors.brand),
),
),
if (onExpand != null && shown.length < ordered.length)
DmTextAction(
label: 'All ${milestones.length} stages',
onPressed: onExpand,
minHeight: 24,
)
else
Text(
'Step ${currentIndex + 1} of ${milestones.length}',
style: DmText.monoSm,
),
],
Expanded(
child: Text(
title.toUpperCase(),
// Neutral. It heads a rail that is deliberately receding,
// beside a crimson action — two accents on a group nobody
// is meant to study.
style: DmText.eyebrow,
),
),
const SizedBox(height: 14),
for (var n = 0; n < shown.length; n++)
_MilestoneRow(
milestone: milestones[shown[n]],
done: shown[n] < currentIndex,
isCurrent: shown[n] == currentIndex,
last: n == shown.length - 1,
time: shown[n] <= currentIndex
? relativeTime(booking.timeOf(milestones[shown[n]].firstStage))
: '',
detail: shown[n] == currentIndex ? _detailFor(current) : null,
if (onExpand != null && shown.length < ordered.length)
DmTextAction(
// Was "All 7 stages". The number counts something the
// customer has no model for — they do not know a parcel has
// seven of anything — and it changes with the backend's own
// vocabulary.
label: 'Full timeline',
onPressed: onExpand,
minHeight: 24,
),
],
),
),
],
const SizedBox(height: 16),
for (var n = 0; n < shown.length; n++)
_MilestoneRow(
milestone: milestones[shown[n]],
done: shown[n] < currentIndex,
isCurrent: shown[n] == currentIndex,
first: n == 0,
last: n == shown.length - 1,
time: shown[n] <= currentIndex
? relativeTime(booking.timeOf(milestones[shown[n]].firstStage))
: '',
detail: shown[n] == currentIndex ? _detailFor(current) : null,
),
],
),
);
}
@@ -130,11 +143,29 @@ class DmMilestones extends StatelessWidget {
};
}
/// One milestone on the spine.
///
/// ── The line is the progress bar ──
///
/// Every connector used to be the same grey hairline, which made the rail a
/// list of dots that happened to be joined up: to work out how far along a
/// parcel was you had to find the filled dots and count them. The segment
/// *above* a node is now crimson when that node has been reached and grey when
/// it has not, so the line fills from the top as the parcel moves and "how far
/// along" is answered before a single word is read.
///
/// ── Identity left, time right ──
///
/// The same split the Miler app's route rail uses: what happened on the left,
/// when it happened on the right, on one baseline. Four stacked left-aligned
/// lines make the eye walk down the block to find the time; two columns answer
/// both in one fixation, and down a rail the times stack into a scan column.
class _MilestoneRow extends StatelessWidget {
const _MilestoneRow({
required this.milestone,
required this.done,
required this.isCurrent,
required this.first,
required this.last,
required this.time,
this.detail,
@@ -143,6 +174,7 @@ class _MilestoneRow extends StatelessWidget {
final CustomerMilestone milestone;
final bool done;
final bool isCurrent;
final bool first;
final bool last;
final String time;
final String? detail;
@@ -150,22 +182,42 @@ class _MilestoneRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final upcoming = !done && !isCurrent;
final reached = done || isCurrent;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 12,
width: 14,
child: Column(
children: [
const SizedBox(height: 3),
// The segment above this node, which is what says whether the
// parcel has got here. Absent on the first row: a line running
// up out of the top of the rail has nowhere to come from.
SizedBox(
height: 6,
child: first
? null
: Center(
child: Container(
width: 2,
color: reached
? DmColors.brand
: DmColors.border,
),
),
),
_Marker(done: done, isCurrent: isCurrent),
if (!last)
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Container(width: 1.5, color: DmColors.border),
child: Center(
child: Container(
width: 2,
// Crimson only as far as the parcel has come. The
// segment below the current node is the road ahead.
color: done ? DmColors.brand : DmColors.border,
),
),
),
],
@@ -174,30 +226,47 @@ class _MilestoneRow extends StatelessWidget {
const SizedBox(width: 14),
Expanded(
child: Padding(
padding: EdgeInsets.only(bottom: last ? 0 : 15),
padding: EdgeInsets.only(top: 2, bottom: last ? 0 : 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Expanded(
child: Text(
milestone.label,
style: upcoming
? DmText.small.copyWith(color: DmColors.ink3)
: DmText.cardTitle.copyWith(fontSize: 14.5),
// The one happening now is the only thing on the
// rail set in the brand colour, and the only one at
// full weight. Everything behind it is history and
// everything ahead of it has not happened.
style: switch ((isCurrent, upcoming)) {
(true, _) => DmText.cardTitle.copyWith(
fontSize: 15.5,
color: DmColors.brand,
),
(_, true) => DmText.small.copyWith(
color: DmColors.ink4,
),
_ => DmText.cardTitle.copyWith(fontSize: 14.5),
},
),
),
if (time.isNotEmpty) ...[
const SizedBox(width: 10),
Text(time, style: DmText.monoSm),
Text(
time,
style: DmText.monoSm.copyWith(
color: isCurrent ? DmColors.brand : DmColors.ink3,
),
),
],
],
),
if (detail != null) ...[
const SizedBox(height: 4),
const SizedBox(height: 3),
Text(detail!, style: DmText.small),
],
],
@@ -210,6 +279,11 @@ class _MilestoneRow extends StatelessWidget {
}
}
/// The node itself.
///
/// Three states and three shapes, so the rail is readable without colour: a
/// filled disc behind you, a ring with a lit centre where you are, a hollow
/// outline ahead.
class _Marker extends StatelessWidget {
const _Marker({required this.done, required this.isCurrent});
@@ -220,23 +294,50 @@ class _Marker extends StatelessWidget {
Widget build(BuildContext context) {
if (isCurrent) {
return Container(
width: 12,
height: 12,
width: 14,
height: 14,
alignment: Alignment.center,
decoration: BoxDecoration(
color: DmColors.surface,
shape: BoxShape.circle,
border: Border.all(color: DmColors.brand, width: 2),
boxShadow: const [
BoxShadow(color: DmColors.brandSoft, spreadRadius: 3.5),
],
),
child: Container(
width: 5,
height: 5,
decoration: const BoxDecoration(
color: DmColors.brand,
shape: BoxShape.circle,
),
),
);
}
if (done) {
return Container(
width: 14,
height: 14,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: DmColors.brand,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(color: DmColors.brandSoft, spreadRadius: 4),
],
),
child: const Icon(
LucideIcons.check,
size: 9,
color: Colors.white,
),
);
}
return Container(
width: 12,
height: 12,
width: 14,
height: 14,
decoration: BoxDecoration(
color: done ? DmColors.brand : DmColors.border,
color: DmColors.canvas,
shape: BoxShape.circle,
border: Border.all(color: DmColors.border, width: 2),
),
);
}

View File

@@ -394,67 +394,3 @@ class _MapLabel extends StatelessWidget {
);
}
}
/// Radar sweep shown while the backend is matching a Miler.
class DmRadarPulse extends StatefulWidget {
const DmRadarPulse({super.key, this.size = 150, this.child});
final double size;
final Widget? child;
@override
State<DmRadarPulse> createState() => _DmRadarPulseState();
}
class _DmRadarPulseState extends State<DmRadarPulse>
with SingleTickerProviderStateMixin {
late final AnimationController _c = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2600),
)..repeat();
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: widget.size,
height: widget.size,
child: AnimatedBuilder(
animation: _c,
builder: (context, child) {
return Stack(
alignment: Alignment.center,
children: [
for (var i = 0; i < 3; i++) _ring(i),
?child,
],
);
},
child: widget.child,
),
);
}
Widget _ring(int index) {
final t = (_c.value + index / 3) % 1;
return Opacity(
opacity: (1 - t) * 0.5,
child: Container(
width: widget.size * (0.35 + t * 0.65),
height: widget.size * (0.35 + t * 0.65),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: DmColors.brand.withValues(alpha: 0.45),
width: 1.5,
),
),
),
);
}
}

View File

@@ -142,6 +142,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_animate:
dependency: "direct main"
description:
name: flutter_animate
sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5"
url: "https://pub.dev"
source: hosted
version: "4.5.2"
flutter_driver:
dependency: transitive
description: flutter
@@ -211,6 +219,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
flutter_shaders:
dependency: transitive
description:
name: flutter_shaders
sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2"
url: "https://pub.dev"
source: hosted
version: "0.1.3"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -415,6 +431,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.0"
lottie:
dependency: "direct main"
description:
name: lottie
sha256: "0bd56a8c5c2944386313588408bf93a379e6fca69f736d585c538e35e72ccd5c"
url: "https://pub.dev"
source: hosted
version: "3.6.1"
lucide_icons_flutter:
dependency: "direct main"
description:
@@ -774,4 +798,4 @@ packages:
version: "3.1.4"
sdks:
dart: ">=3.13.2 <4.0.0"
flutter: ">=3.38.4"
flutter: ">=3.41.0"

View File

@@ -60,6 +60,8 @@ dependencies:
# so they belong in the keychain / EncryptedSharedPreferences, not in a
# plaintext preferences file.
flutter_secure_storage: ^9.2.4
flutter_animate: ^4.5.2
lottie: ^3.6.1
dev_dependencies:
flutter_test:
@@ -85,21 +87,35 @@ flutter:
# the material Icons class.
uses-material-design: true
# Both families ship as a single variable-font file rather than one file per
# weight: Manrope is used from 500 to 800 and Geist Mono at 500, which as
# static cuts would be six font files and roughly 2 MB in the bundle. The
# weight is selected in `DmText` through the `wght` axis (`fontVariations`),
# not by Flutter picking a named instance.
# Poppins (Indian Type Foundry, SIL Open Font License) is the app's voice as
# of 2026-09-22.
#
# Unlike the faces before it, Poppins has **no variable release** — Google
# Fonts ships it only as static cuts. So it is four files, one per weight the
# design actually uses, and `DmText` selects between them with `fontWeight`
# alone: there is no `wght` axis to move, and a `fontVariations` on a static
# font is silently ignored. Four cuts is ~625 KB against Manrope's single
# 165 KB variable file, which is the price of this typeface.
#
# Geist Mono stays variable, and its styles still carry `fontVariations`.
fonts:
- family: Manrope
- family: Poppins
fonts:
- asset: assets/fonts/Manrope-VF.ttf
- asset: assets/fonts/Poppins-Medium.ttf
weight: 500
- asset: assets/fonts/Poppins-SemiBold.ttf
weight: 600
- asset: assets/fonts/Poppins-Bold.ttf
weight: 700
- asset: assets/fonts/Poppins-ExtraBold.ttf
weight: 800
- family: GeistMono
fonts:
- asset: assets/fonts/GeistMono-VF.ttf
assets:
- assets/images/
- assets/animations/
# To add assets to your application, add an assets section, like this:
# assets:

View File

@@ -53,6 +53,7 @@ class FakeApi extends DoormileApi {
required List<DestinationGroup> destinations,
required String? slotId,
FareEstimate? fare,
String? contactPhone,
String? idempotencyKey,
}) async {
createKeys.add(idempotencyKey);

View File

@@ -44,7 +44,10 @@ Future<void> signIn(WidgetTester tester, {AppState? state}) async {
addTearDown(tester.view.reset);
await tester.pumpWidget(DoormileApp(initialState: state));
await settle(tester);
// The splash holds for its own minimum — and the waits in front of it — before handing over, so
// the entrance is waited out once, here, rather than by lengthening every
// settle in the suite.
await settle(tester, 4400);
await tester.enterText(find.byType(TextField).first, '9876543210');
// The CTA only enables once the field rebuilds with a valid number.
@@ -52,12 +55,11 @@ Future<void> signIn(WidgetTester tester, {AppState? state}) async {
await tester.tap(find.text('Continue'));
await settle(tester);
// The OTP screen verifies as soon as the fourth digit lands.
final boxes = find.byType(TextField);
for (var i = 0; i < 4; i++) {
await tester.enterText(boxes.at(i), '1');
await tester.pump();
}
// One field, not four boxes, and it verifies as soon as the fourth digit
// lands. Entered in one go on purpose: four separate fields dropped a digit
// whenever a keystroke arrived while focus was moving between them, which is
// what typing at any normal speed does.
await tester.enterText(find.byType(TextField).first, '1111');
await settle(tester);
}
@@ -78,40 +80,64 @@ Future<void> serverAdvance(
await settle(tester, 1400);
}
/// Home ▸ Start booking ▸ the send screen.
/// Home ▸ the BOOK orb ▸ the destination sheet.
///
/// One screen now: the pickup point is already known, so there is no step
/// between the CTA and the form.
/// BOOK no longer opens the form. It opens one question — where is it going —
/// and the form is what the answer leads to, so every booking test is
/// [openSend] followed by [pickCity].
Future<void> openSend(WidgetTester tester) async {
await tester.tap(find.text('Start booking'));
await tester.tap(find.text('BOOK'));
await settle(tester);
}
/// Opens the pickup-point editor from the send screen's "Change".
/// Answers the destination sheet, then the window sheet behind it.
///
/// Two sheets, because the customer now chooses their own pickup window
/// instead of being handed the first one silently. [takeWindow] false leaves
/// the booking without a slot, which is what dismissing the sheet does.
///
/// The destination is reached by searching rather than by opening its state:
/// search cuts across every state from the first step, and it is the path a
/// customer who already knows where they are sending actually takes.
Future<void> pickCity(
WidgetTester tester,
String name, {
bool takeWindow = true,
}) async {
await tester.enterText(find.byType(TextField).first, name);
await settle(tester, 250);
// A `Text`, explicitly: `find.text` also matches `EditableText`, so once the
// city's name is in the search box the plain finder returns two widgets and
// `.first` is the box itself. Tapping that only refocuses it.
await tester.tap(
find.byWidgetPredicate((w) => w is Text && w.data == name).first,
);
await settle(tester);
// Where the server allows several destinations the row ticks rather than
// closing the sheet, so the choice has to be confirmed. Where it caps the
// booking at one there is no button and the tap already committed.
final send = find.textContaining('Send to ');
if (send.evaluate().isNotEmpty) {
await tester.tap(send.first);
await settle(tester);
}
final use = find.textContaining('Use ');
if (takeWindow && use.evaluate().isNotEmpty) {
await tester.tap(use.first);
await settle(tester);
} else if (!takeWindow) {
await tester.tapAt(const Offset(200, 40)); // dismiss through the scrim
await settle(tester);
}
}
/// Opens the pickup-point editor from the send screen's FROM row.
Future<void> openPickupEditor(WidgetTester tester) async {
await openSend(tester);
await tester.tap(find.text('Change').first);
await settle(tester);
}
/// The city strip scrolls horizontally; drag later cities into view first.
Future<void> pickCity(WidgetTester tester, String name) async {
final width = tester.view.physicalSize.width / tester.view.devicePixelRatio;
final strip = find.descendant(
of: find.byKey(const ValueKey('cityStrip')),
matching: find.byType(Scrollable),
);
for (var i = 0; i < 8; i++) {
final finder = find.text(name);
if (finder.evaluate().isNotEmpty) {
final rect = tester.getRect(finder.first);
if (rect.left >= 0 && rect.right <= width) break;
}
await tester.drag(strip, const Offset(-160, 0));
await tester.pump();
}
await tester.tap(find.text(name).first);
await pickCity(tester, 'Chennai');
await tester.tap(find.text('FROM'));
await settle(tester);
}
@@ -135,11 +161,46 @@ void main() {
tearDown(() => DoormileApi.overrideInstance(null));
testWidgets('the code is one field, typed a digit at a time',
(tester) async {
// The regression this locks: the code used to be four `TextField`s that
// passed focus along, and a keystroke arriving mid-transition was dropped.
// The customer typed four digits, three arrived, and the screen answered
// "That code did not match" — blaming them for its own race.
tester.view.physicalSize = const Size(1230, 9000);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(const DoormileApp());
// The splash holds for its own minimum — and the waits in front of it — before handing over, so
// the entrance is waited out once, here, rather than by lengthening every
// settle in the suite.
await settle(tester, 4400);
await tester.enterText(find.byType(TextField).first, '9876543210');
await settle(tester, 200);
await tester.tap(find.text('Continue'));
await settle(tester);
// One field. Nothing to pass focus between, so nothing to drop it in.
expect(find.byType(TextField), findsOneWidget);
final field = find.byType(TextField).first;
for (final sofar in ['1', '12', '123', '1234']) {
await tester.enterText(field, sofar);
await tester.pump();
}
await settle(tester);
// All four digits arrived, so it verified and moved on.
expect(find.text('BOOK'), findsOneWidget);
await drainToasts(tester);
});
testWidgets('signs in and lands on Home', (tester) async {
await signIn(tester);
expect(find.text('Send a parcel'), findsOneWidget);
expect(find.text('Start booking'), findsOneWidget);
expect(find.text('BOOK'), findsOneWidget);
// The seeded active booking surfaces on Home.
expect(find.text('Miler assigned'), findsOneWidget);
await drainToasts(tester);
@@ -171,7 +232,8 @@ void main() {
final corrected = pickupAddress(tester);
await tester.tap(find.text('Use this address'));
await settle(tester);
expect(find.text('Send a parcel'), findsOneWidget);
// Back on the send screen: its title is the route, not a heading.
expect(find.text('TO'), findsOneWidget);
expect(find.text(corrected), findsOneWidget);
await drainToasts(tester);
});
@@ -220,7 +282,7 @@ void main() {
// …and that one point is what the booking carries forward.
await tester.tap(find.text('Use this address'));
await settle(tester);
expect(find.text('Send a parcel'), findsOneWidget);
expect(find.text('TO'), findsOneWidget);
expect(state.draftPickup!.lat, searched.lat);
expect(state.draftPickup!.lng, searched.lng);
expect(state.draftPickup!.title, 'Brookefields Mall');
@@ -328,24 +390,23 @@ void main() {
await signIn(tester, state: state);
await openSend(tester);
// Everything the booking needs is on this one screen.
expect(find.text('Send a parcel'), findsOneWidget);
expect(find.text('SENDING TO'), findsOneWidget);
expect(find.text('STREET ADDRESS'), findsOneWidget);
// RECIPIENT is no longer a head of its own. Landmark and the two recipient
// fields are optional, and drawn at full weight they put six rows of
// "you may skip this" between the address and the button — so they fold
// behind one row, and this is the closed state the screen opens in.
expect(find.text('Add landmark or recipient details'), findsOneWidget);
expect(find.text('Landmark'), findsNothing);
// Nothing to price until somewhere to send it.
expect(find.text('Pick a city to see the price'), findsOneWidget);
// BOOK asks one question, and it is the only thing on screen.
expect(find.text('Where is it going?'), findsOneWidget);
expect(find.text('Flat or house number'), findsNothing);
await pickCity(tester, 'Chennai');
// Picking the city also takes the first window the backend still has, so
// the common path never opens the sheet.
// Everything else the booking needs is on one screen, and the route to
// the top of it is two rows with no labels but FROM and TO.
expect(find.text('FROM'), findsOneWidget);
expect(find.text('TO'), findsOneWidget);
// Nothing is asked for here at all any more: no address fields, and no
// disclosure hiding three more. The route, the packages and the button.
expect(find.byType(TextField), findsNothing);
expect(find.text('Add landmark or recipient details'), findsNothing);
// The customer answered the window sheet on the way in, so the booking
// already carries a slot they chose.
expect(state.draftSlotId, isNotNull);
expect(find.text('Book pickup'), findsOneWidget);
@@ -362,40 +423,31 @@ void main() {
await drainToasts(tester);
});
testWidgets('the address and recipient reach the booking', (tester) async {
testWidgets('the pickup contact defaults to the account and can be changed',
(tester) async {
final state = AppState();
await signIn(tester, state: state);
await openPickupEditor(tester);
await openSend(tester);
await pickCity(tester, 'Chennai');
// Opens on the signed-in customer's own number, without the country code,
// because the field carries its own prefix.
final contact = find.widgetWithText(TextField, '9876543210');
expect(contact, findsOneWidget);
await tester.enterText(
find.widgetWithText(TextField, '12B, Nehru Apartments'),
'12B, Nehru Apartments',
);
await tester.enterText(
find.widgetWithText(TextField, 'Anna Nagar West, 3rd Street'),
'Anna Nagar West',
);
// The recipient fields are optional and now fold behind one row, so a
// customer who wants them opens that first. This tap is the flow, not a
// workaround for it.
await tester.tap(find.text('Add landmark or recipient details'));
await settle(tester, 200);
await tester.enterText(find.widgetWithText(TextField, 'Meera Anand'), 'Meera Anand');
await tester.enterText(find.widgetWithText(TextField, '90031 44518'), '9003144518');
await settle(tester, 200);
await tester.tap(find.text('Book pickup'));
// Left alone it stays null, so the request carries the account rather than
// a copy of it that can drift.
await tester.tap(find.text('Use this address'));
await settle(tester);
expect(state.draftContactPhone, isNull);
final details = state.trackedBooking!.destinations.first.details;
expect(details.building, '12B, Nehru Apartments');
expect(details.street, 'Anna Nagar West');
expect(details.recipientName, 'Meera Anand');
// The country code is added once, here, rather than being typed.
expect(details.recipientPhone, '+91 9003144518');
// Changed, it is the number the Miler will ring at the door.
await tester.tap(find.text('FROM'));
await settle(tester);
await tester.enterText(find.byType(TextField).first, '9003144518');
await settle(tester, 200);
await tester.tap(find.text('Use this address'));
await settle(tester);
expect(state.draftContactPhone, '+91 9003144518');
await drainToasts(tester);
});
@@ -433,7 +485,9 @@ void main() {
await pickCity(tester, 'Chennai');
final first = state.draftSlotId;
await tester.tap(find.text('Change').last);
// The window lives in the footer now, beside the price it is quoted
// against, and the whole left column opens the sheet.
await tester.tap(find.text('PICKUP'));
await settle(tester);
expect(find.text('Pickup window'), findsOneWidget);
@@ -448,21 +502,35 @@ void main() {
await drainToasts(tester);
});
testWidgets('the city strip offers serviceable cities only', (tester) async {
testWidgets('the destination sheet browses state then district',
(tester) async {
await signIn(tester);
await openSend(tester);
// One flat strip: the state step is gone, so the cities of every open
// state stand side by side.
// Step one is the states, not sixty district names.
expect(find.text('Tamil Nadu'), findsOneWidget);
expect(find.text('Kerala'), findsOneWidget);
expect(find.text('Chennai'), findsNothing);
// Step two is that state's districts.
await tester.tap(find.text('Tamil Nadu'));
await settle(tester);
expect(find.text('Chennai'), findsOneWidget);
expect(find.text('Coimbatore'), findsWidgets);
await pickCity(tester, 'Ernakulam');
expect(find.text('Ernakulam'), findsWidgets);
// Places that are not open are never offered — not as disabled cards, and
// not as a state whose every district is closed.
// Districts that are not open are never offered, and neither is a state
// whose every district is closed.
expect(find.text('Madurai'), findsNothing);
expect(find.text('Puducherry'), findsNothing);
// Back out, then let search cut across states: Ernakulam is in Kerala and
// the customer should not have to know that to find it.
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
await settle(tester);
expect(find.text('Chennai'), findsNothing);
await pickCity(tester, 'Ernakulam');
expect(find.textContaining('Ernakulam'), findsWidgets);
await drainToasts(tester);
});
@@ -477,16 +545,21 @@ void main() {
expect(find.text('DM-482913'), findsOneWidget);
// After collection: the seeded multi-destination pickup is in transit, so
// each destination stands on its own with a quiet line back to the visit.
// each destination stands on its own row.
final booking = state.byReference('DM-471200')!;
expect(booking.hasOrders, isTrue);
expect(find.text('Pickup DM-471200'), findsNothing);
expect(find.text('Ernakulam, Kerala'), findsOneWidget);
expect(find.text('Coimbatore, Tamil Nadu'), findsOneWidget);
expect(
find.text('Collected on DM-471200'),
findsNWidgets(booking.destinations.length),
);
// Each row is identified by its OWN tracking number, not by the visit that
// produced it. "Collected on DM-471200" used to run under both rows; it
// answers a question asked about an order, not while scanning a list of
// them, so it moved to the order's own screen.
expect(find.text('Collected on DM-471200'), findsNothing);
for (final group in booking.destinations) {
expect(find.text(group.trackingId!), findsOneWidget);
}
// Independently tracked.
expect(
booking.destinations.map((d) => d.trackingId).toSet().length,
@@ -501,7 +574,7 @@ void main() {
await drainToasts(tester);
});
testWidgets('the customer timeline groups the operational states',
testWidgets('the timeline is the shipment, not the pickup visit',
(tester) async {
final state = AppState();
await signIn(tester, state: state);
@@ -509,27 +582,29 @@ void main() {
await tester.tap(find.text('Miler assigned'));
await settle(tester);
// Tracking opens on the three most recent updates; the rest of the journey
// is one tap away.
await tester.tap(find.text('All 7 stages'));
await settle(tester);
// The whole shipment is on screen the moment tracking opens — five rows
// fit where the three-row summary and its "Full timeline" action used to.
expect(find.text('Full timeline'), findsNothing);
// Seven customer milestones, not nine backend states.
expect(find.text('Pickup booked'), findsWidgets);
expect(find.text('Pickup in progress'), findsOneWidget);
expect(find.text('Package collected'), findsOneWidget);
// Operational states are never timeline rows of their own.
// Five shipment milestones. Everything before the order exists is one
// row — the whole pickup visit, which the live card above narrates by
// name, with a distance and an ETA.
expect(find.text('Booking created'), findsOneWidget);
expect(find.text('Order created'), findsOneWidget);
expect(find.text('In transit'), findsOneWidget);
expect(find.text('Out for delivery'), findsOneWidget);
expect(find.text('Delivered'), findsOneWidget);
// The visit's own stages are never rows of their own.
expect(find.text('Pickup in progress'), findsNothing);
expect(find.text('Package collected'), findsNothing);
expect(find.text('Miler arrived'), findsNothing);
expect(find.text('Order created'), findsNothing);
// Advancing to "on the way" keeps the milestone and moves the detail. It
// reads twice: once on the timeline, and once as the caption on the live
// card's own progress rail.
// Advancing through the visit does not add a row — it moves the detail
// under the one row the whole visit occupies.
await serverAdvance(tester, state, 'DM-482913', JourneyStage.onTheWay);
expect(find.text('Pickup in progress'), findsNWidgets(2));
expect(find.text('Booking created'), findsOneWidget);
expect(find.textContaining('Miler on the way'), findsWidgets);
// The pill speaks customer language, not the operational stage.
expect(find.text('Order created'), findsNothing);
await drainToasts(tester);
});
@@ -602,7 +677,10 @@ void main() {
api.flags.networkError = false;
await tester.tap(find.text('Retry'));
await settle(tester);
// The strip comes back with the cities, not with a state to pick first.
// It comes back on the step it failed on: the states, not a blank sheet.
expect(find.text('Tamil Nadu'), findsOneWidget);
await tester.tap(find.text('Tamil Nadu'));
await settle(tester);
expect(find.text('Coimbatore'), findsWidgets);
await drainToasts(tester);
});
@@ -639,7 +717,7 @@ void main() {
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
await signIn(tester); // login + OTP
expect(find.text('Start booking'), findsOneWidget); // home
expect(find.text('BOOK'), findsOneWidget); // home
await openSend(tester); // the whole booking, one screen
await pickCity(tester, 'Chennai');
@@ -648,7 +726,8 @@ void main() {
await settle(tester); // confirmation
await tester.tap(find.text('Track pickup'));
await settle(tester); // tracking
expect(find.text('Pickup booked'), findsWidgets);
// The live card names the stage; the rail names the shipment milestone.
expect(find.text('Booking created'), findsWidgets);
await drainToasts(tester);
});
@@ -662,13 +741,16 @@ void main() {
addTearDown(tester.view.reset);
await tester.pumpWidget(const DoormileApp());
await settle(tester);
// The splash holds for its own minimum — and the waits in front of it — before handing over, so
// the entrance is waited out once, here, rather than by lengthening every
// settle in the suite.
await settle(tester, 4400);
await tester.tap(find.text('Create account'));
await settle(tester);
// Building it is the assertion: any overflow in the hero or the form
// throws.
expect(find.text('Create your account.'), findsOneWidget);
expect(find.text('Create your account'), findsOneWidget);
await drainToasts(tester);
});

View File

@@ -28,7 +28,12 @@ import 'package:flutter_test/flutter_test.dart';
/// everything in Ahem's black boxes and the pictures are worthless.
Future<void> _loadFonts() async {
for (final (family, asset) in const [
('Manrope', 'assets/fonts/Manrope-VF.ttf'),
// Every cut, because Poppins is static: a FontLoader given only one file
// renders w800 headings by smearing the one weight it has.
('Poppins', 'assets/fonts/Poppins-Medium.ttf'),
('Poppins', 'assets/fonts/Poppins-SemiBold.ttf'),
('Poppins', 'assets/fonts/Poppins-Bold.ttf'),
('Poppins', 'assets/fonts/Poppins-ExtraBold.ttf'),
('GeistMono', 'assets/fonts/GeistMono-VF.ttf'),
// The icon font too, or every glyph in the picture is a hollow box.
('packages/lucide_icons_flutter/Lucide',
@@ -39,10 +44,20 @@ Future<void> _loadFonts() async {
}
}
/// Runs the clock forward until a screen has stopped moving.
///
/// Four pumps, not two. An entrance animation is started by a zero-delay
/// `Future.delayed` inside `flutter_animate`, and under the test binding's fake
/// clock that timer only fires on the pump *after* the widget was built — so
/// the controller begins life part-way through the first elapse and a two-pump
/// settle photographs the screen mid-fade. Everything here is under a second,
/// so the extra frames cost nothing and the pictures are of settled screens.
Future<void> _settle(WidgetTester tester, [int ms = 800]) async {
await tester.pump();
await tester.pump(Duration(milliseconds: ms));
await tester.pump(const Duration(milliseconds: 400));
for (var i = 0; i < 4; i++) {
await tester.pump(const Duration(milliseconds: 400));
}
}
Future<void> _shot(WidgetTester tester, String name) async {
@@ -78,18 +93,39 @@ void main() {
final state = AppState();
await tester.pumpWidget(DoormileApp(initialState: state));
await _settle(tester);
// Image.asset resolves off the frame loop, so in a test it never paints
// unless it is decoded first — without this the wordmark is a blank gap in
// every picture of the entrance.
// unless it is decoded first — without this the mark and the wordmark are
// blank gaps in every picture of the entrance.
await tester.runAsync(() async {
await precacheImage(
const AssetImage('assets/images/doormile-logo.png'),
tester.element(find.byType(MaterialApp)),
);
for (final asset in const [
'assets/images/doormile-icon.png',
'assets/images/doormile-logo.png',
]) {
await precacheImage(
AssetImage(asset),
tester.element(find.byType(MaterialApp)),
);
}
});
await _settle(tester);
// Both beats, with raw pumps: `_settle` always runs four extra 400ms
// frames at the end, which is 2.5s — past the moment the splash hands
// over, so it would photograph the login screen instead of either one.
// The clock does not start until the engine says it has rasterized a
// frame and the composition has parsed, which under the test binding is
// about 1.2s. Both shots are taken relative to that, not to pumpWidget.
await tester.pump();
await tester.pump(const Duration(milliseconds: 2000));
await _shot(tester, '00-splash-animation');
// Past the handover, plus a frame for the switch to settle.
await tester.pump(const Duration(milliseconds: 1200));
await tester.pump(const Duration(milliseconds: 400));
await _shot(tester, '00b-splash-mark');
// The splash holds for its own minimum — and the waits in front of it — before handing over.
await _settle(tester, 4400);
await _shot(tester, '01-sign-in');
await tester.tap(find.text('Create account'));
@@ -104,14 +140,18 @@ void main() {
await _settle(tester);
await _shot(tester, '02-verify');
final boxes = find.byType(TextField);
for (var i = 0; i < 4; i++) {
await tester.enterText(boxes.at(i), '1');
await tester.pump();
}
await tester.enterText(find.byType(TextField).first, '1111');
await _settle(tester);
await _shot(tester, '03-home');
// The pickup address is a live value in the header, so the search that
// changes it is reachable from Home rather than only from the booking.
await tester.tap(find.text('38 Mettupalayam Road'));
await _settle(tester);
await _shot(tester, '03b-pickup-search');
await tester.tapAt(const Offset(200, 40)); // through the scrim
await _settle(tester);
await tester.tap(find.text('Orders'));
await _settle(tester);
await _shot(tester, '04-orders');
@@ -125,33 +165,50 @@ void main() {
await tester.tap(find.text('Miler assigned'));
await _settle(tester);
await _shot(tester, '06-tracking');
await tester.dragUntilVisible(
find.text('Cancel this pickup'),
find.byType(Scrollable).first,
const Offset(0, -200),
);
await tester.tap(find.text('Cancel this pickup'));
await _settle(tester);
await _shot(tester, '06b-cancel-sheet');
await tester.tapAt(const Offset(200, 40));
await _settle(tester);
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
await _settle(tester);
// Booking: one screen, plus the two escape hatches.
await tester.tap(find.text('Start booking'));
// Booking: state, district, window, then one screen.
await tester.tap(find.text('BOOK'));
await _settle(tester);
await _shot(tester, '07-send-empty');
await _shot(tester, '07-destination-states');
await tester.tap(find.text('Tamil Nadu'));
await _settle(tester);
await _shot(tester, '07b-destination-districts');
await tester.tap(find.text('Chennai').first);
await _settle(tester);
await _shot(tester, '08-send-ready');
// The window row is below the fold on a phone, and a ListView does not
// build what it has not reached.
// The window already carries the first available slot, so scroll to the
// package row beneath it — a string that is there either way.
await tester.dragUntilVisible(
find.text('1 package'),
find.byType(Scrollable).first,
const Offset(0, -220),
);
await _settle(tester);
await tester.tap(find.text('Change').last);
// The dev backend allows several destinations, so the row ticked rather
// than closing the sheet — the choice is confirmed.
await tester.tap(find.textContaining('Send to ').first);
await _settle(tester);
// The window sheet follows the destination on its own: the customer
// chooses their slot, it is never chosen for them.
await _shot(tester, '09-window-sheet');
await tester.tap(find.textContaining('Use ').first);
await _settle(tester);
await _shot(tester, '08-send');
// The map editor behind the FROM row: the one screen in the flow whose
// subject is a picture.
await tester.tap(find.text('FROM'));
await _settle(tester);
await _shot(tester, '10-pickup-map');
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
await _settle(tester);
await tester.tap(find.text('Book pickup'));
await _settle(tester);
@@ -164,6 +221,7 @@ void main() {
await _settle(tester);
await tester.tap(find.text('Completed'));
await _settle(tester);
await _shot(tester, '04b-orders-completed');
await tester.tap(find.text('Salem, Tamil Nadu'));
await _settle(tester);
await _shot(tester, '12-receipt');

View File

@@ -47,7 +47,10 @@ Future<void> _tapContinue(WidgetTester tester, AppState state) async {
addTearDown(tester.view.reset);
await tester.pumpWidget(DoormileApp(initialState: state));
await settle(tester);
// The splash holds for its own minimum — and the waits in front of it — before handing over, so
// the entrance is waited out once, here, rather than by lengthening every
// settle in the suite.
await settle(tester, 4400);
await tester.enterText(find.byType(TextField).first, '9876543210');
await settle(tester, 200);

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 633 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

BIN
test/snapshots/08-send.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

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