Redesign on the Stitch reference, in Plus Jakarta Sans

Four passes, and the shape they landed on.

The type face is Plus Jakarta Sans (variable, wght 200-800), which brings a
fix with it: it carries the rupee glyph and Switzer does not, so prices stop
being set in Geist Mono to work around a missing character. Mono stays where
it is earned - references and phone numbers, read digit by digit.

Surfaces lift rather than outline. Cards carry two very soft shadow layers
instead of a hairline, because eight outlined boxes down a screen read as a
wireframe. The tab bar floats as a pill again for the same reason it was
right to: it is now the same kind of object as everything above it.

Screens:

* Home is the greeting, the address, the sphere and one card. The card lost
  its progress bar - a filling line says "wait", and a parcel two days into
  a journey is not something anyone is waiting through - and gained the size
  that buys.
* Orders cards are four bands: identity, destination, route, and whatever is
  happening right now. Plus a search field, because the list is the archive.
* Tracking leads with the state at display size, then TRIP MILESTONES with a
  step counter, then the courier.
* Review is a route thread over two particular cards.
* Account opens on the person: avatar, name, and two counted figures.

Three real bugs the redesign surfaced:

* Quick dispatch handed `loadCities()` straight to a FutureBuilder, so the
  catalogue was refetched on every rebuild and Home never settled.
* Order cards showed the whole visit's weight on one destination's row -
  somebody else's parcel. Per group now, and only once actually weighed.
* The pickup window was printed beside "In transit", where it reads as a
  delivery time nobody promised.

Nothing invented. The reference shows EXPRESS PRIORITY, CARBON OFFSET,
CONCIERGE ELITE and hub-to-hub routing; this backend sends none of them, so
they are absent rather than mocked up.

flutter analyze: clean. flutter test: 88 passing.

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-24 11:08:25 +05:30
parent 06fa6b797a
commit 86b6af48c2
73 changed files with 3398 additions and 1722 deletions

View File

@@ -199,6 +199,56 @@ you the Account screen — which is exactly when you need to know.
and it refused the code. `1234` is not a real code; only the offline build and it refused the code. `1234` is not a real code; only the offline build
accepts anything. See below. accepts anything. See below.
### Two contract bugs found against production, 2026-09-23
Both were the same mistake — the client spelling a field the way the written
contract spells it, where the server reads something else — and both were
verified one request apart against `api.doormile.com`, same pickup, same slot.
**The pickup coordinates are `lat`/`lng`, not `latitude`/`longitude`.**
| sent | `POST /bookings` |
| --- | --- |
| `latitude` / `longitude` | **422 `unserviceable`** — "We are not collecting from that area yet" |
| `lat` / `lng` | **201**, booking `DM-252803` |
The server could not read the pin, could not place it in a serviceable area,
and blamed the customer's address for a field name. **The app could not create
a booking at all**; every serviceable district's own hub coordinates were
refused as a pickup. Fixed in `Place.toJson`.
**The same bug in `POST /fare/estimate` returns a wrong price instead of an
error**, which is worse:
| sent | local (0.2 km) | Chennai (427 km) |
| --- | --- | --- |
| `latitude` / `longitude` | ₹280–520, `routeKm` 0 | ₹280–520, `routeKm` 0 |
| `lat` / `lng` | ₹45–90 | ₹150–280 |
With the long spelling the server measures no route and answers a flat fallback
band for every journey — roughly six times the real price on a local run, and
the same number quoted for a parcel crossing the state. Every fare this app has
shown was that fallback. Fixed in `LiveDoormileApi.estimateFare`.
This is the third of these (`POST /auth/otp/verify` takes `code` where the
document says `otp`). **Follow the server, not the document**, and
`test/live_api_wire_test.dart` is where each one gets pinned down.
### The app cannot sign in to production
Separate from the above, and still open. The real backend has no SMS gateway,
so the OTP never arrives. The server does have a PIN flow —
```
POST /customer/auth/login {"phone": "+91…"} → {registered, pin_set}
POST /customer/auth/verify-pin {"phone", "pin"} → {accessToken, refreshToken, customer}
```
— but the app does not use it: `login_screen.dart` calls `sendOtp`. Until those
two endpoints are wired into the entrance, a booking cannot be made **from the
app** against production, whatever the payload says. The QA account
`+919999900001` ("Doormile QA") is registered with a PIN for exactly this.
### Signing in for development ### Signing in for development
A shipping build has **one** API implementation, `LiveDoormileApi`, and it talks A shipping build has **one** API implementation, `LiveDoormileApi`, and it talks

View File

@@ -1,4 +1,4 @@
Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins) Copyright 2020 The Plus Jakarta Sans Project Authors (https://github.com/tokotype/PlusJakartaSans)
This Font Software is licensed under the SIL Open Font License, Version 1.1. 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: 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.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,132 @@
Fontshare
------------------------------------------------------------------
ITF Free Font License (FFL)
Version 2.0 - 17 Aug 2026
------------------------------------------------------------------
Notice to User
Indian Type Foundry designs, produces and distributes font software as digital fonts to end users worldwide. In addition to commercial fonts that are available for a fee, ITF also offers several fonts that can be used free of charge. These free fonts are distributed through a dedicated platform called www.fontshare.com (“Fontshare”) to end users worldwide. Fonts identified on Fontshare as being licensed under the ITF Free Font License are subject to this legally binding EULA between Indian Type Foundry (“Indian Type Foundry”, “ITF” or “Licensor”) and you (“Licensee”).
You acknowledge that the Font Software and the designs embodied therein are protected by copyright, other intellectual property and industrial property rights, and international treaties. They are and shall remain at all times the intellectual property of Indian Type Foundry.
In addition to direct download, Fontshare may also make the Font Software available through the Fontshare API. In this case, the Font Software is delivered directly from servers used by Indian Type Foundry to the Licensee's website, without the Licensee having to download or host the Font Software.
By downloading, accessing, self-hosting, installing, storing, embedding, copying or otherwise using the Font Software, you agree to the following terms.
Definitions
“Font Software” refers to the computer files or programs released under this License that enable the display, reproduction and rendering of the typeface designs, including letters, numerals, punctuation, symbols, ornaments and other typographic characters. Font Software includes all font formats and bitmap or vector representations distributed by Indian Type Foundry under this License.
“Official Version” refers to the Font Software as officially distributed by Indian Type Foundry through Fontshare.
“Derivative Work” refers to any modified, adapted, altered or otherwise derived version of the Font Software or the typeface designs embodied therein, including modifications to font files (such as OTF, TTF, WOFF, WOFF2 or variable fonts), glyphs, characters, spacing, kerning, metrics, naming, subsetting, format conversion or other font data.
Derivative Work does not include any logo, document, graphic, image, website, application or other creative work created through the normal use of the Font Software, ownership of which remains with its respective creator or rights holder.
01 Grant of License
You are hereby granted a non-exclusive, non-assignable, non-transferable and terminable license to access, download, install, store and use the Font Software for personal or commercial purposes, free of charge and for an unlimited period of time, subject to the terms of this License.
You may use the Font Software in any media, including Print, Websites, Mobile or Desktop Applications, Digital Images, Digital Advertising, Social Media, ePublishing, Video, Broadcasting and Games, at any scale and in any location worldwide.
You may self-host the Font Software on your own servers or infrastructure for use on your own websites and applications, including through standard webfont technologies such as CSS @font-face. Self-hosting by end users is permitted and recommended for greater control, reliability and performance. Use of the Fontshare API is optional and is not required for web use.
You may embed the Font Software in mobile or desktop applications and digital documents for the uses permitted under this License.
You may use the Font Software to create logos, wordmarks, graphic elements, images, vector files, scalable drawings and other creative works. Logos and wordmarks created using the Font Software may be registered as trademarks. Any rights in the resulting creative works, including any trademark rights, remain with their respective creator or rights holder. Such rights do not grant any ownership or exclusive rights in the Font Software or the underlying typeface designs.
You may use the Font Software on any number of devices, including computers, tablets and phones. The number of output devices, including printers, is not restricted. Where the Licensee is an organization, the Font Software may be installed, stored, copied and shared internally among its employees for the Licensee's own permitted uses under this License. Such internal sharing does not constitute redistribution under this License.
You may make a reasonable number of backup copies of the Font Software as necessary for your permitted use.
You may, but are not required to, identify or credit Indian Type Foundry or Fontshare in works created using the Font Software.
02 Limitations of Usage
You may not modify, edit, adapt, translate, reverse engineer, decompile, disassemble or otherwise alter the Font Software or the typeface designs embodied therein, in whole or in part, without the prior written consent of the Licensor. This includes modifying or replacing glyphs, subsetting, format conversion, or altering font names, copyright information, ownership information or other metadata.
The Font Software may not, beyond the permitted copies and uses defined herein, be distributed, duplicated, loaned, resold, sublicensed, transferred, donated, given away or otherwise made available to any other person or entity, whether for free or for a fee. This includes distributing the Font Software through another font website, font library, marketplace, repository, download service, application or platform, or by email, removable media, publicly accessible servers, file-sharing services, peer-to-peer networks or any other means.
You may not provide the Font Software directly to external designers, agencies, contractors, printers or other service providers. Any third party wishing to use the Font Software must obtain their own copy directly from Fontshare and be independently bound by this License.
You may not host, serve, embed or otherwise make the Font Software available for use by third parties through any website, application, online service, SaaS platform, design tool, template editor or similar service. This includes making the Font Software available as a selectable font for third-party users to create, edit, customize or generate their own content, even where such users cannot download or extract the Font Software itself.
For the avoidance of doubt, nothing in this Section 02 restricts the self-hosting, embedding or other use of the Font Software by the Licensee for the Licensee's own websites, applications or other permitted uses under Section 01.
03 Embedding
You may embed the Font Software in PDFs and other digital documents for your permitted uses under this License, provided that the embeddedFont Software cannot be extracted or used independently from the document.
Documents containing embedded Font Software may be distributed without restriction as to the number of recipients.
The extraction of the Font Software, in whole or in part, for independent use is prohibited.
04 Third-party use, Commercial print service providers
You may provide documents, artwork or other files created using the Font Software to commercial printers and other service providers for printing, production or display purposes.
If a service provider requires access to the Font Software itself, they must obtain their own copy directly from Fontshare and be independently bound by this License.
05 Derivative Work
You may not create a Derivative Work from the Font Software without the prior written consent of the Licensor.
Where the Licensor expressly permits the creation of a Derivative Work, such permission is limited to the specific use authorized by the Licensor and does not grant any ownership or other rights in the Official Version of the Font Software or the underlying typefacedesigns.
A Derivative Work may not be sublicensed, sold, leased, rented, loaned, distributed, transferred or otherwise made available to any third party without the prior written consent of the Licensor.
06 Warranty and Liability
THE FONT SOFTWARE IS PROVIDED FREE OF CHARGE AND ON AN “AS IS” AND “AS AVAILABLE” BASIS. TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, INDIAN TYPE FOUNDRY MAKES NO WARRANTIES, EXPRESS OR IMPLIED, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, COMPATIBILITY, AVAILABILITY OR OTHERWISE.
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, INDIAN TYPE FOUNDRY SHALL NOT BE LIABLE TO THE LICENSEEOR ANY THIRD PARTY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL OR CONSEQUENTIAL DAMAGES, INCLUDING LOSS OF PROFITS, REVENUE, DATA, BUSINESS OR SAVINGS, OR BUSINESS INTERRUPTION, ARISING OUT OF THE USE OF OR INABILITY TO USE THE FONT SOFTWARE.
IF THE LICENSEE CHOOSES TO ACCESS OR SERVE THE FONT SOFTWARE THROUGH THE FONTSHARE API, THE LICENSEE DOES SO AT ITS OWN RISK. THE FONTSHARE API IS PROVIDED AS A CONVENIENCE AND DOES NOT FORM PART OF THE RIGHTS GRANTED TO THE LICENSEE TO USE THE FONT SOFTWARE UNDER THIS LICENSE.
INDIAN TYPE FOUNDRY DOES NOT GUARANTEE THE AVAILABILITY, PERFORMANCE OR CONTINUED OPERATION OF THE FONTSHARE API. INDIAN TYPE FOUNDRY MAY, AT ITS SOLE DISCRETION AND WITHOUT PRIOR NOTICE, MODIFY, RESTRICT, SUSPEND OR DISCONTINUE ACCESS TO THE FONTSHARE API, WHETHER GENERALLY OR FOR ANY PARTICULAR USER, WEBSITE OR APPLICATION. INDIAN TYPE FOUNDRY SHALL NOT BE RESPONSIBLE OR LIABLE FOR ANY INTERRUPTION, MALFUNCTION, DOWNTIME, CHANGE, RESTRICTION OR DISCONTINUATION OF THE API, OR FOR ANY RESULTING IMPACT ON THE LICENSEE'S WEBSITE, APPLICATION OR OTHER SERVICES.
07 Updates, Maintenance and Support Services
Indian Type Foundry is under no obligation to provide updates, upgrades, maintenance, technical support or other support services for the Font Software under this License.
Indian Type Foundry may, at its sole discretion, update, modify, replace or discontinue any Font Software made available through Fontshare.
08 Termination
Any breach of the terms of this License shall be grounds for termination. Where the breach is capable of being remedied, the Licensor may notify the Licensee in writing and require the breach to be remedied within 30 days of receipt of such notice.
Notwithstanding the foregoing, the Licensor may terminatethis License immediately in the event of any deliberate or material unauthorized modification, redistribution, sale, sublicensing, transfer or other misuse of the Font Software or the intellectual property rights of Indian Type Foundry.
Upon termination, and without limitation of any other remedies available under law or equity, the Licensee must immediately cease all use of the Font Software and delete the Font Software and all copies thereof inits possession or control. Proof of deletion must be provided upon request of the Licensor.
Indian Type Foundry reserves the right to seek damages and any other remedies available under applicable law for any breach of this License.
09 Final Provisions
If any provision of this License is found to be invalid, illegal or unenforceable, the remaining provisions shall remain in full force and effect. Any invalid, illegal or unenforceable provision shall, to the extent legally permissible, be interpreted or replaced in a manner that most closely reflects its original purpose.
The failure of Indian Type Foundry to enforce any provision of this License shall not constitute a waiver of that provision or of its right to enforce it subsequently.
The uses expressly permitted under this License require no additional license or fee. If a Licensee requires additional rights or permissions, exceptions to any restrictions under this License, modifications or customizations to the Font Software or typeface designs, additional styles, weights, widths, characters or language extensions, technical support ormaintenance services, warranties or indemnities, or other customized licensing terms, they may contact Indian Type Foundry to discuss a separate custom license or arrangement.
This License shall be governed by the laws ofthe Republic of India. To the extent legally permissible, the courts located in Ahmedabad, Gujarat, India shall have exclusive jurisdiction over any dispute arising out of or in connection with this License.
------------------------------------------------------------------
ITF Free Font License (FFL)
Version 2.0 - 17 Aug 2026
Copyright © 2026 Indian Type Foundry. All rights reserved.
https://www.fontshare.com
------------------------------------------------------------------

Binary file not shown.

View File

@@ -95,32 +95,28 @@ their own position without being told.
### 2.2 Typography ### 2.2 Typography
**Poppins for voice, Geist Mono for data.** Poppins (Indian Type Foundry, SIL **Switzer for voice, Geist Mono for data.** Switzer (Indian Type Foundry, via
Open Font License) is the app's voice as of 2026-09-22. Fontshare, ITF Free Font License — commercial use permitted) is the app's voice
as of 2026-09-23.
**Poppins has no variable release.** Google Fonts ships it as static cuts only, It is a neue-grotesque: flat-sided bowls, near-vertical terminals, a
so `pubspec.yaml` declares one file per weight and the sans styles below set straight-tailed `y`. It was chosen against a reference whose own face is almost
`fontWeight` and nothing else — `fontVariations` on a static font is ignored in certainly PP Neue Montreal or Neue Haas Grotesk Display — both commercial, and
silence, which is worse than useless, because it reads as if it were doing the neither identifiable with certainty from a skewed mockup. Switzer is the
work. Geist Mono is still variable and its three styles still carry the axis. closest thing that can actually be bundled.
Only the four weights the design uses are bundled — **500, 600, 700, 800** — **One variable file again, 141 KB.** Poppins held this slot for a day and has
because each is a separate ~155 KB file. That is ~625 KB of sans against the no variable release: four static cuts at ~155 KB each, 625 KB of sans, and for
165 KB a variable file cost, and it is the price of this typeface. Asking for that release the sans styles carried `fontWeight` alone because
w900 anywhere would not fail; Flutter would quietly synthesise it from Bold, `fontVariations` on a static font is ignored in silence. The axis is back, so
and it would look like it. every style below sets both — the axis does the work, the enum keeps the
semantics and the system-font fallback correct.
**Every weight is one step lighter than the face before it.** Poppins is built **Tighter than anything before it.** A grotesque at display size wants pulling
on near-circles and carries far more ink at the same nominal weight than a in hard, and the reference sets its headlines almost touching: the header runs
humanist sans. What was w900 is w800, w800 is w700, w700 is w600. At the old at **−1.4**. Body sizes stay near zero. Tracking belongs to a typeface, not to
numbers the headings looked inflated rather than strong. a design, and this is the third time these numbers have been rewritten for that
reason.
**And the tracking is deeper again** — the opposite correction from the last
pass. Poppins is wide, round and has generous 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 that same roundness is what makes
it legible small and tracking it in takes that back. Tracking belongs to a
typeface, not to a design.
Letter spacing is authored in em by the design and converted per size, because Letter spacing is authored in em by the design and converted per size, because
−0.035em is a different number of pixels on a 34pt header than on a 15pt row −0.035em is a different number of pixels on a 34pt header than on a 15pt row
@@ -129,13 +125,13 @@ tight.
| Style | Size / Height / Weight | Tracking | Used for | | Style | Size / Height / Weight | Tracking | Used for |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `headerTitle` | 34 / 1.0 / 800 | **−1.2** | The brand header's own title | | `headerTitle` | 34 / 1.0 / 800 | **−1.4** | The tab root's own title |
| `display` | 31 / 1.1 / 800 | −0.95 | The one dominant line on a screen | | `display` | 31 / 1.1 / 800 | −1.1 | The one dominant line on a screen |
| `title` | 25 / 1.16 / 800 | −0.65 | Screen headings on canvas | | `title` | 25 / 1.16 / 800 | −0.8 | Screen headings on canvas |
| `sheetTitle` | 21 / 1.2 / 800 | −0.55 | Bottom-sheet titles | | `sheetTitle` | 21 / 1.2 / 800 | −0.65 | Bottom-sheet titles |
| `heading` | 18 / 1.25 / 700 | −0.4 | The head above a group of cards | | `heading` | 18 / 1.25 / 700 | −0.45 | The head above a group of cards |
| `cardTitle` | 15 / 1.4 / 600 | −0.15 | A row's own line — the workhorse | | `cardTitle` | 15 / 1.4 / 600 | −0.2 | A row's own line — the workhorse |
| `body` | 15 / 1.5 / 500 | −0.1 | Paragraph text | | `body` | 15 / 1.5 / 500 | −0.12 | Paragraph text |
| `lede` | 14.5 / 1.55 / 500 | — | Paragraph under a screen heading | | `lede` | 14.5 / 1.55 / 500 | — | Paragraph under a screen heading |
| `small` | 13.5 / 1.45 / 500 | — | The supporting line under a row title | | `small` | 13.5 / 1.45 / 500 | — | The supporting line under a row title |
| `tiny` | 13 / 1.4 / 500 | — | The quietest line | | `tiny` | 13 / 1.4 / 500 | — | The quietest line |
@@ -516,9 +512,7 @@ attached.
names, with an index. It is a copy, and a stale copy is worse than none, so names, with an index. It is a copy, and a stale copy is worse than none, so
`tool/screens.sh` refreshes it in one command after the goldens are rendered. `tool/screens.sh` refreshes it in one command after the goldens are rendered.
The harness loads every Poppins cut, Geist Mono **and the Lucide icon font** The harness loads Switzer, Geist Mono **and the Lucide icon font** first —
first — every cut, because Poppins is static and a `FontLoader` given one file
renders w800 headings by smearing the one weight it has —
without that, Flutter's test harness draws Ahem's black boxes and hollow without that, Flutter's test harness draws Ahem's black boxes and hollow
squares and the pictures are worthless. squares and the pictures are worthless.
@@ -734,7 +728,126 @@ having been pushed up by the thing that was tapped.
--- ---
## 11. If you are adding something ## 11. The reference pass (2026-09-23)
A set of logistics-app references — white cards on a soft canvas, one dark
accent, big reference numbers, labelled columns — and one instruction: *less
red, less boxy, more modern*. What follows is what that actually meant in this
codebase.
### 11.1 The redness had one source
`DmBrandHeader` was 260pt of solid brand on Home, Orders and Account — three of
five screens opening on a wall of it. Every `onBrand*` token existed only to
survive that field, and the accent that is supposed to mean *do this* was the
background of half the product.
**It is the canvas now, with ink type.** What is left of the brand on those
screens is the sphere, the primary button and a live dot, which is what an
accent is for. The arcs survive at 3.5% crimson instead of 15% white — they
were struck from the mark's geometry and were never the colour doing the work.
Knock-ons: the header's address track became a bordered field and then a bare
line (below); the avatar and the Account/Orders actions dropped their on-brand
variants; the Miler card lost its crimson wash, which on a header-less screen
was the loudest surface left.
### 11.2 The canvas was doing no work
At `#F7F6F4` a white card was a **3% step** off the background, so the 1px
border was separating everything and every screen read as a list of outlined
boxes. Canvas → `#F1F0EC`, card radius **14 → 18**. The edge is still there; it
stopped being the only thing holding the layout together.
That change immediately exposed two screens whose content was floating on bare
canvas — the send screen's route and package rows, invisible as a problem at 3%
contrast and unfinished-looking at the new one. Both are on cards now.
**The gutter came in too**, `DmSpace.pad` **24 → 16**. Twenty-four was right
when the header was a crimson field and the canvas held loose rows; on a page
of white cards it left a broad margin down both sides and the cards read as
narrower than the screen.
### 11.3 Orders
The filter left the header. It was a segmented control recessed into the
crimson field, which made it look like part of the title bar rather than
something to press. It is three chips on the canvas, **ink** when selected —
one of three is always on, and crimson there would be a permanent red block on
a screen whose crimson is meant to be the status pills, the thing that changes.
The card follows the reference: **reference number and status pill, then From /
To as two columns, then Placed / Contents, then a horizontal journey rail.**
Two columns rather than a vertical thread, because the rail cost the full width
of the card to say two things; side by side, from and to are one glance and the
same route fits in half the height — which is what lets the journey rail exist
without the card becoming a page.
**The rail labels one node, not five.** The reference labels all three of its
nodes; that works at three and does not at five — "Booking created / Order
created / In transit / Out for delivery / Delivered" across 320pt is five
ellipses. The line carries *how far*, the word carries *where*.
"SEPTEMBER 2026" went from the header: it sat above a list that is not grouped
by month, does not scroll by month, and says "Today" and "Yesterday" on its own
rows.
### 11.4 Tracking, rebuilt
**The map went.** It was 178pt of basemap, two labels and a pin, on a screen
whose question is "where is my parcel". The pin was the *pickup address* — a
place the customer chose and already knows — not the Miler, who only has a
position for the twenty minutes between assignment and the door. A picture that
cannot show the thing it implies invites somebody to stare at a stationary dot
and conclude nothing is happening. The radar that preceded it went for the same
reason: animation standing in for an answer.
**The crimson head card went.** "Arun Kumar is assigned" at 27pt on a filled
brand field, with a supporting line, a progress rail and the window under it.
Crimson is the action colour and tracking is a screen you read. It is a white
card now: the reference at 26pt, a status pill, and the route as a thread.
**No progress bar inside it.** The journey rail below is the same five
milestones with the same fill, and two of them on one screen is the app
disagreeing with itself about which is the answer.
**The Miler card moved to the foot.** It sat directly under the reference,
which put *a person* between the customer and the answer they opened the screen
for. It is **who**; the screen's question is **where**. The journey has the
middle of the page now.
Also gone: a duplicate route card at the foot, the duplicate reference in the
top bar, and the white bed under the footer buttons.
### 11.5 Small things that were wrong
**The destination node is a map pin.** A rotated diamond distinguished the ends
of the thread — its whole argument — and did that by being a shape with no
meaning attached. A pin is the mark every customer already reads as *a place*,
so the thread needs no legend: hollow circle where you are, pin where it is
going.
**The status pill was floating mid-row.** `Flexible` gives a child the space it
needs and then leaves it at the *start* of what it was allotted. It takes an
`Align(centerRight)` to put a status where a status belongs.
**Home's address is a line, not a field.** It was a bordered white box with a
chevron — a text input that takes no text, which is the shape ride apps stopped
using years ago. A dot, the place, and a quiet "Change".
**BOOK / NOW is stacked**, which bought back size: both words get the sphere's
full width, so it is 30pt over two lines rather than the 24pt a single line had
to shrink to.
**Settings rows stopped truncating.** Two rounds of flex arithmetic produced
"Payment metho…" and "38 Mettupalaya…" in turn. The fix was not a third ratio:
the label never truncates, and a `value` is a word — anything longer belongs on
the screen the row opens.
---
## 12. If you are adding something
1. Take the value from `tokens.dart`. If it is not there, add it there. 1. Take the value from `tokens.dart`. If it is not there, add it there.
2. Ask what the screen's one decision is. If your addition is not it, it is 2. Ask what the screen's one decision is. If your addition is not it, it is

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 633 KiB

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 494 KiB

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 455 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 358 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 416 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 377 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 183 KiB

View File

@@ -345,7 +345,25 @@ class LiveDoormileApi extends DoormileApi {
// This app does not have them: the pickup is a map pin, and the // This app does not have them: the pickup is a map pin, and the
// reverse geocoder returns an address, not a serviceable-area code. The // reverse geocoder returns an address, not a serviceable-area code. The
// coordinates say the same thing and the server already resolves them. // coordinates say the same thing and the server already resolves them.
'pickup': {'latitude': ?pickup.lat, 'longitude': ?pickup.lng}, //
// ── `lat`/`lng`, and this one was quoting the wrong price ──
//
// Same field-name mismatch as `Place.toJson`, and on this endpoint it
// fails silently instead of erroring. With `latitude`/`longitude` the
// server cannot read the pin, measures no route, and answers a flat
// fallback band for every journey. Measured against production on
// 2026-09-23, same pickup, one request apart:
//
// latitude/longitude local ₹280–520 routeKm 0
// 427 km ₹280–520 routeKm 0
// lat/lng local ₹45–90 routeKm 0.2
// 427 km ₹150–280 routeKm 427.3
//
// So every fare this app has ever shown was the fallback: roughly six
// times the real price on a local run, and the same number quoted for
// a parcel crossing the state. A 422 tells you it is broken; this just
// lies.
'pickup': {'lat': ?pickup.lat, 'lng': ?pickup.lng},
'destinations': [ 'destinations': [
for (final d in destinations) for (final d in destinations)
{ {

View File

@@ -114,6 +114,23 @@ enum JourneyStage {
JourneyStage.outForDelivery => 'Out for delivery', JourneyStage.outForDelivery => 'Out for delivery',
JourneyStage.delivered => 'Delivered', JourneyStage.delivered => 'Delivered',
}; };
/// The same state in one or two words, for a status chip.
///
/// [milestoneLabel] is a sentence fragment written to sit under a heading —
/// "Miler assigned", "Miler at your door". Set in a 9.5pt chip beside a
/// reference and a time it renders as "MILER ASSIG…", which is not a state.
String get chipLabel => switch (this) {
JourneyStage.booked => 'Booked',
JourneyStage.assigned => 'Assigned',
JourneyStage.onTheWay => 'On the way',
JourneyStage.arrived => 'At your door',
JourneyStage.pickedUp => 'Collected',
JourneyStage.orderCreated => 'Created',
JourneyStage.inTransit => 'In transit',
JourneyStage.outForDelivery => 'Out for delivery',
JourneyStage.delivered => 'Delivered',
};
} }
/// What the customer sees on the timeline. /// What the customer sees on the timeline.
@@ -418,11 +435,31 @@ class Place {
lng: coordinate(json, 'lng'), lng: coordinate(json, 'lng'),
); );
/// ── The coordinates are `lat`/`lng`, NOT `latitude`/`longitude` ──
///
/// The written contract says `latitude`/`longitude` and this sent them for
/// months. The server reads `lat`/`lng`, so it saw a pickup with no
/// coordinates at all, could not place it in a serviceable area, and
/// answered every single booking with **422 `unserviceable` — "We are not
/// collecting from that area yet"**. The app could not create a booking
/// against production; the error blamed the customer's address for a field
/// name.
///
/// Verified against production on 2026-09-23, one request apart: the same
/// pickup, the same destination, the same slot. `latitude`/`longitude` →
/// 422 unserviceable. `lat`/`lng` → 201 and booking DM-252803. It is also
/// what `GET /places/reverse-geocode` returns, which is where this Place
/// came from in the first place — the app was renaming the server's own
/// keys on the way back out.
///
/// Second one of these on this surface: `POST /auth/otp/verify` takes
/// `code` where the document says `otp`. Follow the server, not the
/// document.
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'title': title, 'title': title,
'sub': sub, 'sub': sub,
'latitude': ?lat, 'lat': ?lat,
'longitude': ?lng, 'lng': ?lng,
}; };
/// `POST /customer/bookings` ▸ `pickup`. /// `POST /customer/bookings` ▸ `pickup`.
@@ -471,6 +508,11 @@ class Destination {
return coded.isEmpty ? '—' : coded.join(', '); return coded.isEmpty ? '—' : coded.join(', ');
} }
/// The district alone, for a headline. Falls back to the full [label] when
/// the backend sent only a state or only codes — a card headed "—" is worse
/// than a card headed with two words.
String get shortLabel => _clean(districtName) ?? _clean(districtCode) ?? label;
static String? _clean(String? v) { static String? _clean(String? v) {
final t = v?.trim(); final t = v?.trim();
return (t == null || t.isEmpty) ? null : t; return (t == null || t.isEmpty) ? null : t;

View File

@@ -111,7 +111,7 @@ ThemeData buildDoormileTheme() {
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
fontFamily: 'Poppins', fontFamily: 'Switzer',
colorScheme: scheme, colorScheme: scheme,
scaffoldBackgroundColor: DmColors.bg, scaffoldBackgroundColor: DmColors.bg,
splashFactory: InkSparkle.splashFactory, splashFactory: InkSparkle.splashFactory,

View File

@@ -3,14 +3,16 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../data/app_config.dart'; import '../../data/app_config.dart';
import '../../state/app_scope.dart'; import '../../state/app_scope.dart';
import '../format.dart'; import '../../data/models.dart';
import '../tokens.dart'; import '../tokens.dart';
import '../widgets/misc.dart';
import '../format.dart';
import 'shell_screen.dart';
import '../widgets/buttons.dart'; import '../widgets/buttons.dart';
import '../widgets/cards.dart'; import '../widgets/cards.dart';
import '../widgets/chrome.dart'; import '../widgets/chrome.dart';
import '../widgets/feedback.dart'; import '../widgets/feedback.dart';
import '../widgets/inputs.dart'; import '../widgets/inputs.dart';
import '../widgets/misc.dart';
import 'auth/login_screen.dart'; import 'auth/login_screen.dart';
import 'sheets/place_search_sheet.dart'; import 'sheets/place_search_sheet.dart';
@@ -35,49 +37,133 @@ class AccountScreen extends StatelessWidget {
final name = customer?.name ?? ''; final name = customer?.name ?? '';
final phone = customer?.phone ?? ''; final phone = customer?.phone ?? '';
// Counted off what is already loaded, so the figures move with the list
// rather than claiming a lifetime total the client cannot know.
final completed =
app.orders.where((o) => o.status == BookingStatus.completed);
final delivered = completed.fold<int>(0, (n, o) => n + o.totalPackages);
final cities = {
for (final o in completed)
for (final g in o.destinations)
?g.destination.districtCode,
}.length;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
DmBrandHeader( DmAppBar(
eyebrow: 'Account', unread: app.activeBookings.isNotEmpty,
title: name.isEmpty ? 'Your account' : name, onSearch: () => ShellScreen.of(context)?.goToTab(1),
leading: DmAvatar(initials: initialsOf(name), onBrand: true), onNotifications: () =>
action: DmIconButton( DmToast.show(context, 'Nothing new right now'),
icon: LucideIcons.circleHelp, ),
tooltip: 'Help', // ── The profile, centred ──
background: DmColors.onBrandRaise, //
foreground: Colors.white, // It was a 34pt name left-aligned with the number under it, which is
border: Colors.transparent, // the shape a *list* screen takes. The reference puts the person at
radius: DmRadius.lg, // the top of their own account: a large avatar with an edit badge, the
size: 42, // name, the number, and the account's standing — and that is what
onPressed: () => DmToast.show(context, 'Support is on the way'), // makes Account read as somewhere you are rather than as a menu.
), Padding(
child: phone.isEmpty padding: const EdgeInsets.fromLTRB(DmSpace.pad, 6, DmSpace.pad, 18),
? null child: Column(
: DmHeaderTrack( children: [
trailing: DmTextAction( Stack(
label: 'Edit', clipBehavior: Clip.none,
color: Colors.white, children: [
minHeight: 24, DmAvatar(initials: initialsOf(name), size: 86),
onPressed: () => DmToast.show(context, 'Editing is coming soon'), Positioned(
right: -2,
bottom: -2,
child: Container(
width: 30,
height: 30,
alignment: Alignment.center,
decoration: BoxDecoration(
color: DmColors.surface,
shape: BoxShape.circle,
boxShadow: DmShadow.card,
),
child: const Icon(
LucideIcons.pencil,
size: 14,
color: DmColors.ink2,
),
),
), ),
child: Text( ],
phone, ),
style: DmText.monoLg.copyWith(fontSize: 14.5, color: Colors.white), const SizedBox(height: 14),
Text(
name.isEmpty ? 'Your account' : name,
style: DmText.headerTitle.copyWith(fontSize: 24),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (phone.isNotEmpty) ...[
const SizedBox(height: 7),
Text(
phone,
style: DmText.monoSm.copyWith(
fontSize: 13,
color: DmColors.ink3,
), ),
), ),
],
// ── Two figures, both counted here ──
//
// The reference puts "24 all-time parcels" and "100% carbon
// offset" side by side under the name. The first is real — it
// is the orders already in memory — so it is shown. The second
// is not: nothing in this backend measures an offset, and a
// sustainability claim is the last thing an app should invent.
// Delivered parcels and cities reached are what the data
// actually supports.
if (delivered > 0) ...[
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: _Stat(
icon: LucideIcons.package,
label: 'DELIVERED',
value: '$delivered',
note: delivered == 1 ? 'parcel' : 'parcels',
),
),
const SizedBox(width: 10),
Expanded(
child: _Stat(
icon: LucideIcons.mapPin,
label: 'CITIES',
value: '$cities',
note: cities == 1 ? 'destination' : 'destinations',
),
),
],
),
],
],
),
), ),
Expanded( Expanded(
child: ListView( child: ListView(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 4, DmSpace.pad, 28), // Clears the floating tab bar, which the shell stacks over the
// body rather than laying out beneath it.
padding: EdgeInsets.fromLTRB(DmSpace.pad, 4, DmSpace.pad, 74 + MediaQuery.paddingOf(context).bottom),
children: [ children: [
const DmMicroHead('Preferences', top: 16, brand: false), const DmMicroHead('Preferences', top: 4, brand: false),
DmRowGroup( DmRowGroup(
children: [ children: [
DmRow( DmRow(
icon: LucideIcons.mapPin, icon: LucideIcons.mapPin,
label: 'Saved addresses', label: 'Saved addresses',
value: app.pickup?.title ?? 'None', note: 'Pickup and drop locations',
// No value. It was the pickup address, which is a street
// name and rendered as "38 Mettupal…" — a truncation
// nobody can read anything out of, in the slot meant for a
// word.
showChevron: true, showChevron: true,
onTap: () async { onTap: () async {
final place = await showPlaceSearchSheet(context); final place = await showPlaceSearchSheet(context);
@@ -90,19 +176,49 @@ class AccountScreen extends StatelessWidget {
const DmRow( const DmRow(
icon: LucideIcons.bell, icon: LucideIcons.bell,
label: 'Notifications', label: 'Notifications',
value: 'SMS and push', note: 'Real-time SMS and push',
showChevron: true, showChevron: true,
), ),
const DmRow( const DmRow(
icon: LucideIcons.wallet, icon: LucideIcons.wallet,
label: 'Payment methods', label: 'Payment methods',
value: 'UPI', note: 'UPI, on delivery',
showChevron: true, showChevron: true,
), ),
const DmRow( ],
),
// ── A second group, because the first was two things ──
//
// "Preferences" held three settings and one destination, and
// the destination — help — was the row a customer in trouble
// scans for. The two policy rows every app is expected to carry
// had nowhere to go at all, so they were simply missing.
//
// Split, each head says what its rows are: things you change,
// and things you read.
const DmMicroHead('About', brand: false),
DmRowGroup(
children: [
DmRow(
icon: LucideIcons.circleHelp, icon: LucideIcons.circleHelp,
label: 'Help and support', label: 'Help and support',
note: 'Talk to the courier desk',
showChevron: true, showChevron: true,
onTap: () => DmToast.show(context, 'Support is on the way'),
),
DmRow(
icon: LucideIcons.fileText,
label: 'Terms and policies',
note: 'What Doormile covers',
showChevron: true,
onTap: () => DmToast.show(context, 'Opening doormile.com…'),
),
DmRow(
icon: LucideIcons.info,
label: 'About Doormile',
note: 'Version and licences',
showChevron: true,
onTap: () => DmToast.show(context, 'Opening doormile.com…'),
), ),
], ],
), ),
@@ -111,36 +227,29 @@ class AccountScreen extends StatelessWidget {
// session came from a dev token rather than a sign-in. // session came from a dev token rather than a sign-in.
if (!AppConfig.isProd || AppConfig.authBypassed) ...[ if (!AppConfig.isProd || AppConfig.authBypassed) ...[
const DmMicroHead('Build', brand: false), const DmMicroHead('Build', brand: false),
DmCard( // Not a DmRow: that lays a short label against a short value
tone: DmCardTone.sunken, // on one line, and this value is a URL plus every flag the
children: [ // build carries. It overflowed by 59px the moment a bypass was
DmCardCell( // on — which is exactly when someone needs to read it.
// Not a DmRow: that lays a short label against a short //
// value on one line, and this value is a URL plus every // Not a card either. It is a line of mono on the page, like
// flag the build carries. It overflowed by 59px the // every other thing on this screen.
// moment a bypass was on — which is exactly when someone Padding(
// needs to read it. padding: const EdgeInsets.only(top: 2, bottom: 4),
child: Column( child: Text(
crossAxisAlignment: CrossAxisAlignment.start, AppConfig.describe,
children: [ style: DmText.mono.copyWith(
Text('API', style: DmText.eyebrow), height: 1.5,
const SizedBox(height: 6), color: DmColors.ink3,
Text(
AppConfig.describe,
style: DmText.mono.copyWith(
height: 1.5,
color: DmColors.ink2,
),
),
],
),
), ),
], ),
), ),
], ],
const SizedBox(height: 24), const SizedBox(height: 24),
DmButton( DmButton(
label: 'Sign out', label: 'Log Out',
icon: LucideIcons.logOut,
iconLeading: true,
kind: DmButtonKind.ghost, kind: DmButtonKind.ghost,
danger: true, danger: true,
onPressed: () { onPressed: () {
@@ -156,9 +265,9 @@ class AccountScreen extends StatelessWidget {
TextSpan( TextSpan(
style: DmText.tiny.copyWith(color: DmColors.ink4), style: DmText.tiny.copyWith(color: DmColors.ink4),
children: [ children: [
const TextSpan(text: 'Doormile Customer '), const TextSpan(text: 'DOORMILE CUSTOMER '),
TextSpan( TextSpan(
text: 'v1.0.0', text: 'v${AppConfig.appVersion}',
style: DmText.monoSm.copyWith(color: DmColors.ink4), style: DmText.monoSm.copyWith(color: DmColors.ink4),
), ),
], ],
@@ -172,3 +281,69 @@ class AccountScreen extends StatelessWidget {
); );
} }
} }
/// One counted figure under the profile.
///
/// A glyph in the corner, a micro-label, the number at display size and the
/// unit under it — the shape a dashboard tile takes when it is one number
/// rather than a chart.
class _Stat extends StatelessWidget {
const _Stat({
required this.icon,
required this.label,
required this.value,
required this.note,
});
final IconData icon;
final String label;
final String value;
final String note;
@override
Widget build(BuildContext context) {
return DmCard(
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 14, 14, 15),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: Text(
label,
style: DmText.eyebrow,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Icon(icon, size: 15, color: DmColors.ink4),
],
),
const SizedBox(height: 10),
Text(
value,
style: DmText.headerTitle.copyWith(fontSize: 28),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
note,
style: DmText.small.copyWith(
fontSize: 12.5,
color: DmColors.ink3,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
);
}
}

View File

@@ -6,7 +6,6 @@ import '../../tokens.dart';
import '../../widgets/buttons.dart'; import '../../widgets/buttons.dart';
import '../../widgets/cards.dart'; import '../../widgets/cards.dart';
import '../../widgets/chrome.dart'; import '../../widgets/chrome.dart';
import '../../widgets/misc.dart';
import '../../widgets/pieces.dart'; import '../../widgets/pieces.dart';
import '../../widgets/route_rail.dart'; import '../../widgets/route_rail.dart';
import '../tracking_screen.dart'; import '../tracking_screen.dart';
@@ -145,22 +144,24 @@ class _ConfirmedScreenState extends State<ConfirmedScreen>
), ),
if (booking != null) ...[ if (booking != null) ...[
const SizedBox(height: 10), const SizedBox(height: 10),
// ── One card, not two ──
//
// The reference sat in a washed crimson card of its own, ten
// points above a white card holding the route — two containers
// and a pink field for a screen whose whole message is that
// there is nothing left to do. The reference is the first cell
// of the same card now, where it reads as the heading of the
// record underneath it rather than as a second announcement.
DmCard( DmCard(
tone: DmCardTone.wash, raised: true,
children: [ children: [
DmCardCell( DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 13, 8, 13), padding: const EdgeInsets.fromLTRB(16, 14, 8, 14),
child: DmCopyRow( child: DmCopyRow(
label: 'Booking reference', label: 'Booking reference',
value: booking.reference, value: booking.reference,
onWash: true,
), ),
), ),
],
),
const SizedBox(height: 10),
DmCard(
children: [
DmCardCell( DmCardCell(
child: DmRouteRail( child: DmRouteRail(
pickup: booking.pickup.title, pickup: booking.pickup.title,
@@ -184,21 +185,22 @@ class _ConfirmedScreenState extends State<ConfirmedScreen>
DmCardCell( DmCardCell(
background: DmColors.brandSoft, background: DmColors.brandSoft,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
// Was a filled crimson pill reading "Searching" beside a
// crimson sentence saying the same thing at greater length.
// The spinner is the pill's whole job — it is the only
// element here that is actually live — so the words go back
// to ink and the crimson is left in the wash and the arc.
child: Row( child: Row(
children: [ children: [
const DmPill('Searching', tone: DmPillTone.live),
const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(
'Finding the nearest Miler', 'Finding the nearest Miler',
style: DmText.small.copyWith( style: DmText.cardTitle.copyWith(fontSize: 14.5),
color: DmColors.brand,
fontWeight: FontWeight.w600,
),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
const SizedBox(width: 12),
const SizedBox( const SizedBox(
width: 15, width: 15,
height: 15, height: 15,

View File

@@ -5,9 +5,11 @@ import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/doormile_api.dart'; import '../../../data/doormile_api.dart';
import '../../../data/models.dart';
import '../../../state/app_scope.dart'; import '../../../state/app_scope.dart';
import '../../../state/app_state.dart'; import '../../../state/app_state.dart';
import '../../tokens.dart'; import '../../tokens.dart';
import '../../widgets/cards.dart';
import '../../widgets/buttons.dart'; import '../../widgets/buttons.dart';
import '../../widgets/chrome.dart'; import '../../widgets/chrome.dart';
import '../../widgets/feedback.dart'; import '../../widgets/feedback.dart';
@@ -108,9 +110,8 @@ class _SendScreenState extends State<SendScreen> {
final slot = app.slotById(app.draftSlotId); final slot = app.slotById(app.draftSlotId);
final fare = app.draftFare; final fare = app.draftFare;
final ready = city.districtCode != null && final ready =
app.draftSlotId != null && city.districtCode != null && app.draftSlotId != null && app.draftReady;
app.draftReady;
return Scaffold( return Scaffold(
backgroundColor: DmColors.canvas, backgroundColor: DmColors.canvas,
@@ -118,79 +119,152 @@ class _SendScreenState extends State<SendScreen> {
body: ListView( body: ListView(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 24), padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 24),
children: [ children: [
// Where it comes from and where it goes, as one object. // ── The head ──
// //
// These were two separate blocks — an eyebrow, a 25pt crimson city // A numbered chip and the step in words, because a customer who
// and a Change action, then a bordered line with a label, an // arrived here from Home's sphere has answered two questions and
// address and a second Change. Seven strings and two identical // wants to know this is the last one. Three dots said the same thing
// words to say what a thread between two points says without any. // and made you count them.
_RouteHead( const _StepHead(step: 3, of: 3),
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(),
),
),
onTapTo: () async {
final picked = await showDestinationSheet(context);
if (picked != null && picked.isNotEmpty && context.mounted) {
app.setDestinations(picked);
}
},
),
const SizedBox(height: 4),
const SizedBox(height: 14), const SizedBox(height: 14),
// One row, no card around it: a card holding a single control is a // The route, as one card with the two ends and a thread between
// border drawn for its own sake. // them. It was four ruled rows of equal weight, so where it comes
Row( // from, where it goes, when and how many all read as the same kind
// of fact — and the two that are the *journey* were indistinguishable
// from the two that are its particulars.
DmCard(
raised: true,
children: [ children: [
const Icon( DmCardCell(
LucideIcons.package, padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
size: 17, child: _RouteHead(
color: DmColors.ink3, from: pickup?.title ?? 'Set a pickup address',
), fromNote: pickup?.sub,
const SizedBox(width: 12), to: [for (final g in app.draftDestinations) g.destination],
Expanded( onTapFrom: () => Navigator.of(context).push(
child: Text( bookingRoute<void>(
'${group.packageCount} ' BookingRoutes.pickup,
'${group.packageCount == 1 ? 'package' : 'packages'}', (_) => const PickupLocationScreen(),
style: DmText.cardTitle, ),
),
onTapTo: () async {
final picked = await showDestinationSheet(context);
if (picked != null &&
picked.isNotEmpty &&
context.mounted) {
app.setDestinations(picked);
}
},
), ),
), ),
DmStepper(
value: group.packageCount,
min: 1,
canIncrease: app.canAddPackageTo(0),
onChanged: (v) => app.setPackageCount(0, v),
),
], ],
), ),
const SizedBox(height: 10),
const SizedBox(height: 20), _DetailCard(
// Was three sentences about weighing, charging and cancelling. The icon: LucideIcons.calendar,
// customer is about to commit, so the only part that changes the label: 'PICKUP WINDOW',
// decision is that committing costs nothing yet. value: slot == null
Text( ? 'Choose a window'
'Nothing is charged now · Free cancellation', : '${slot.day}, ${slot.window}',
textAlign: TextAlign.center, onTap: () => showWindowSheet(context),
style: DmText.tiny.copyWith(color: DmColors.ink3), trailing: slot == null
? null
: const Icon(
LucideIcons.circleCheck,
size: 20,
color: DmColors.ok,
),
),
const SizedBox(height: 10),
_DetailCard(
icon: LucideIcons.package,
label: 'PARCELS',
value:
'${group.packageCount} '
'${group.packageCount == 1 ? 'parcel' : 'parcels'}',
note: 'Up to 15 kg each',
trailing: DmStepper(
value: group.packageCount,
min: 1,
canIncrease: app.canAddPackageTo(0),
onChanged: (v) => app.setPackageCount(0, v),
),
),
const SizedBox(height: 10),
// The one promise this screen makes that the customer cannot see for
// themselves: nobody is guessing the weight.
Container(
padding: const EdgeInsets.fromLTRB(16, 13, 16, 14),
decoration: BoxDecoration(
color: DmColors.surfaceAlt,
borderRadius: DmRadius.all(DmRadius.md),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(LucideIcons.scale, size: 17, color: DmColors.ink3),
const SizedBox(width: 12),
Expanded(
child: Text(
'Your Miler weighs every parcel at your door. '
'Nothing is estimated.',
style: DmText.small.copyWith(
fontSize: 13,
color: DmColors.ink2,
),
),
),
],
),
), ),
], ],
), ),
bottomNavigationBar: DmFooter( bottomNavigationBar: DmFooter(
edge: true, edge: true,
children: [ children: [
_Commitments( // ── Stacked, and left ──
window: slot == null ? null : '${slot.day} · ${slot.window}', //
price: fare?.range, // Label opposite figure meant the two were competing for one line,
onChangeWindow: () => showWindowSheet(context), // and at 30pt the band lost: "₹49 – …" is the half of a price that
// tells you nothing. Above and below, neither can crowd the other
// and the price starts at the same margin as everything else on the
// screen.
SizedBox(
width: double.infinity,
child: Text('ESTIMATED', style: DmText.eyebrow),
), ),
const SizedBox(height: 12), const SizedBox(height: 2),
Text(
fare?.range ?? '—',
style: DmText.headerTitle.copyWith(fontSize: 30),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Row(
children: [
const Icon(
LucideIcons.receipt,
size: 13,
color: DmColors.ink3,
),
const SizedBox(width: 7),
Flexible(
child: Text(
'Pay after doorstep weigh · Free cancellation',
style: DmText.small.copyWith(
fontSize: 12.5,
color: DmColors.ink3,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 14),
DmButton( DmButton(
label: 'Book pickup', label: 'Confirm Booking',
icon: ready ? LucideIcons.arrowRight : null, icon: ready ? LucideIcons.arrowRight : null,
busy: _busy, busy: _busy,
busyLabel: 'Booking…', busyLabel: 'Booking…',
@@ -200,133 +274,192 @@ class _SendScreenState extends State<SendScreen> {
), ),
); );
} }
} }
/// Where it comes from, where it goes, and both of them tappable. /// Which step this is.
/// ///
/// Deliberately not [DmRouteRail]: that one is a read-only thread used on /// ── Dots → a numbered chip ──
/// 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 /// Three dots with the current one elongated is a shape you have to count.
/// sentence, and this screen had two of the word stacked one above the other. /// A numeral in a disc and "Step 3 of 3" beside it is the same information
class _RouteHead extends StatelessWidget { /// read rather than parsed, and it leaves the screen's own title to the thing
const _RouteHead({ /// the customer is about to press.
required this.from, class _StepHead extends StatelessWidget {
required this.to, const _StepHead({required this.step, required this.of});
required this.onTapFrom,
required this.onTapTo,
});
final String from; final int step;
final int of;
/// 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Semantics(
padding: const EdgeInsets.symmetric(vertical: 4), header: true,
decoration: const BoxDecoration( label: 'Step $step of $of',
border: Border(bottom: BorderSide(color: DmColors.border)), excludeSemantics: true,
), child: Row(
child: Column(
children: [ children: [
_RouteRow( Container(
icon: LucideIcons.locateFixed, width: 22,
eyebrow: 'FROM', height: 22,
value: from, alignment: Alignment.center,
onTap: onTapFrom, decoration: const BoxDecoration(
), color: DmColors.ink,
for (var i = 0; i < to.length; i++) shape: BoxShape.circle,
_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,
), ),
child: Text(
'$step',
style: DmText.label.copyWith(fontSize: 11.5, color: Colors.white),
),
),
const SizedBox(width: 10),
Text(
'Step $step of $of',
style: DmText.cardTitle.copyWith(
fontSize: 14.5,
color: DmColors.ink2,
),
),
], ],
), ),
); );
} }
} }
class _RouteRow extends StatelessWidget { /// Where it comes from, where it goes, on one thread.
const _RouteRow({ ///
required this.icon, /// The two ends of a journey are not two rows of a list. A black dot for the
required this.eyebrow, /// door it leaves and a brand pin for the door it arrives at, with a rule
required this.value, /// running between them, says *route* in a way four equal rows cannot.
required this.onTap, class _RouteHead extends StatelessWidget {
this.strong = false, const _RouteHead({
required this.from,
required this.to,
required this.onTapFrom,
required this.onTapTo,
this.fromNote,
}); });
final IconData icon; final String from;
final String eyebrow; final String? fromNote;
final String value;
final VoidCallback onTap;
/// The destination is set in the brand colour: it is the only choice on this /// One stop per destination. A pickup that fans out to three places is three
/// screen that moves the price. /// stops on one thread, not "3 destinations" standing in for them.
final bool strong; final List<Destination> to;
final VoidCallback onTapFrom;
final VoidCallback onTapTo;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Stop(
label: 'ORIGIN',
value: from,
note: fromNote,
onTap: onTapFrom,
origin: true,
last: false,
),
for (var i = 0; i < to.length; i++)
_Stop(
label: to.length == 1 ? 'DESTINATION' : 'DESTINATION ${i + 1}',
value: to[i].shortLabel,
note: to[i].stateName,
onTap: onTapTo,
origin: false,
last: i == to.length - 1,
),
],
);
}
}
class _Stop extends StatelessWidget {
const _Stop({
required this.label,
required this.value,
required this.onTap,
required this.origin,
required this.last,
this.note,
});
final String label;
final String value;
final String? note;
final VoidCallback onTap;
final bool origin;
final bool last;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Semantics( return Semantics(
button: true, button: true,
label: '$eyebrow $value', label: '$label $value',
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: onTap, onTap: onTap,
child: Container( child: IntrinsicHeight(
constraints: const BoxConstraints(minHeight: DmSpace.tap),
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Icon( // The node and the thread it hangs from. The thread is drawn by
icon, // the stop *above* the join, so the last one ends clean.
size: 18, SizedBox(
color: strong ? DmColors.brand : DmColors.ink3, width: 22,
),
const SizedBox(width: 14),
Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(eyebrow, style: DmText.eyebrow), const SizedBox(height: 15),
Text( Container(
value, width: 10,
// One weight down from where this started. At w700 height: 10,
// crimson, five destinations stacked read as five decoration: BoxDecoration(
// headings — the page shouted its own contents back. A color: origin ? DmColors.ink : DmColors.brand,
// list of places the customer chose is a list, not a shape: BoxShape.circle,
// 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 (!last)
const Expanded(
child: VerticalDivider(
width: 1,
thickness: 1,
color: DmColors.border,
),
),
], ],
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 12),
const Icon( Expanded(
LucideIcons.chevronRight, child: Padding(
size: 17, padding: EdgeInsets.fromLTRB(0, 8, 0, last ? 4 : 16),
color: DmColors.ink4, child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: DmText.eyebrow),
const SizedBox(height: 3),
Text(
value,
style: DmText.cardTitle.copyWith(fontSize: 16.5),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (note != null && note!.isNotEmpty)
Text(
note!,
style: DmText.small.copyWith(
fontSize: 12.5,
color: DmColors.ink3,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
), ),
], ],
), ),
@@ -336,82 +469,87 @@ class _RouteRow extends StatelessWidget {
} }
} }
/// The two things the button is about to agree to, side by side above it. /// One particular of the booking, on its own card: a glyph well, a micro-label
/// /// over a value, and whatever acts on it.
/// The window used to be a row in a card in the middle of the form and the class _DetailCard extends StatelessWidget {
/// price used to appear in the footer only once everything was valid, which const _DetailCard({
/// meant the customer had to scroll back up to check what time they had been required this.icon,
/// given. Both live here now, and the left half is tappable. required this.label,
class _Commitments extends StatelessWidget { required this.value,
const _Commitments({ this.note,
required this.window, this.onTap,
required this.price, this.trailing,
required this.onChangeWindow,
}); });
final String? window; final IconData icon;
final String? price; final String label;
final VoidCallback onChangeWindow; final String value;
final String? note;
final VoidCallback? onTap;
final Widget? trailing;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( return DmCard(
crossAxisAlignment: CrossAxisAlignment.end, onTap: onTap,
children: [ children: [
Expanded( DmCardCell(
child: Semantics( padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
button: true, child: Row(
child: GestureDetector( children: [
behavior: HitTestBehavior.opaque, Container(
onTap: onChangeWindow, width: 38,
child: Column( height: 38,
crossAxisAlignment: CrossAxisAlignment.start, alignment: Alignment.center,
mainAxisSize: MainAxisSize.min, decoration: BoxDecoration(
children: [ color: DmColors.surfaceAlt,
Text('PICKUP', style: DmText.eyebrow), borderRadius: DmRadius.all(DmRadius.xs),
const SizedBox(height: 3), ),
Row( child: Icon(icon, size: 18, color: DmColors.ink2),
children: [ ),
Flexible( const SizedBox(width: 13),
child: Text( Expanded(
window ?? 'Choose a window', child: Column(
style: DmText.cardTitle.copyWith( crossAxisAlignment: CrossAxisAlignment.start,
color: window == null ? DmColors.brand : null, mainAxisSize: MainAxisSize.min,
), children: [
maxLines: 1, Text(label, style: DmText.eyebrow),
overflow: TextOverflow.ellipsis, const SizedBox(height: 2),
Text(
value,
style: DmText.cardTitle.copyWith(fontSize: 15.5),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (note != null)
Text(
note!,
style: DmText.small.copyWith(
fontSize: 12.5,
color: DmColors.ink3,
), ),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
const SizedBox(width: 4), ],
const Icon( ),
LucideIcons.chevronDown,
size: 15,
color: DmColors.ink3,
),
],
),
],
), ),
), if (trailing != null) ...[
const SizedBox(width: 10),
trailing!,
] else if (onTap != null) ...[
const SizedBox(width: 10),
const Icon(
LucideIcons.chevronRight,
size: 18,
color: DmColors.ink4,
),
],
],
), ),
), ),
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,17 +1,19 @@
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../data/models.dart'; import '../../data/models.dart';
import '../../state/app_scope.dart'; import '../../state/app_scope.dart';
import '../format.dart';
import '../tokens.dart'; import '../tokens.dart';
import 'shell_screen.dart';
import '../format.dart';
import '../widgets/cards.dart';
import '../widgets/book_orb.dart'; import '../widgets/book_orb.dart';
import '../widgets/chrome.dart';
import '../widgets/misc.dart'; import '../widgets/misc.dart';
import 'booking/booking_routes.dart'; import 'booking/booking_routes.dart';
import 'booking/send_screen.dart'; import 'booking/send_screen.dart';
import 'shell_screen.dart';
import 'sheets/destination_sheet.dart'; import 'sheets/destination_sheet.dart';
import 'sheets/place_search_sheet.dart'; import 'sheets/place_search_sheet.dart';
import 'sheets/window_sheet.dart'; import 'sheets/window_sheet.dart';
@@ -89,105 +91,179 @@ class _HomeScreenState extends State<HomeScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final app = AppScope.of(context); final app = AppScope.of(context);
final active = app.activeBookings.isEmpty ? null : app.activeBookings.first; final active = app.activeBookings.isEmpty ? null : app.activeBookings.first;
final firstRun = app.orders.isEmpty;
return Column( // The sphere's own field, floored so a very short window leaves it a
crossAxisAlignment: CrossAxisAlignment.stretch, // sphere rather than a slot. It is a *minimum*: the scroll view hands it
children: [ // whatever is left once the card below has taken what it needs.
DmBrandHeader( final orbHeight = math.max(300.0, MediaQuery.sizeOf(context).height * 0.38);
eyebrow: 'Welcome, ${_firstName(app.customer?.name)}',
title: firstRun ? 'Send your first parcel' : 'Send a parcel', return SafeArea(
action: InkResponse( bottom: false,
onTap: () => ShellScreen.of(context)?.goToTab(2), child: Column(
radius: 26, crossAxisAlignment: CrossAxisAlignment.stretch,
child: DmAvatar( children: [
initials: initialsOf(app.customer?.name), // No wordmark bar here.
onBrand: true, //
// Orders and Account open on a title and want a bar over it. Home
// opens on the customer's own name with one object under it, and a
// second header above that made the screen start twice.
// ── Greeting, then where from ──
//
// "Hello, Joe ●" with the brand dot, the avatar opposite it, and the
// address as a bordered pill under both. With the wordmark bar gone
// this is the top of the screen, so it takes the inset the bar used
// to hold.
Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 14, DmSpace.pad, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Row(
children: [
Flexible(
child: Text(
'Hello, ${_firstName(app.customer?.name)}',
style: DmText.headerTitle.copyWith(fontSize: 26),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: DmColors.brand,
shape: BoxShape.circle,
),
),
],
),
),
const SizedBox(width: 12),
InkResponse(
onTap: () => ShellScreen.of(context)?.goToTab(2),
radius: 26,
child: DmAvatar(
initials: initialsOf(app.customer?.name),
size: 40,
badge: active != null,
),
),
],
),
const SizedBox(height: 12),
_PickupLine(
title: app.pickup?.title ?? 'Set a pickup address',
onTap: () async {
final place = await showPlaceSearchSheet(context);
if (place != null && context.mounted) app.setPickup(place);
},
),
],
), ),
), ),
// The address is the one fact every booking starts from, so it sits Expanded(
// in the header as a live value the customer can change in place — child: AnimatedScale(
// not as the first question of the flow. scale: _handingOver ? 0.96 : 1,
child: DmHeaderTrack(
leading: const DmLiveDot(size: 6, color: Colors.white),
trailing: const Icon(
LucideIcons.chevronDown,
size: 16,
color: DmColors.onBrand3,
),
onTap: () async {
final place = await showPlaceSearchSheet(context);
if (place != null && context.mounted) app.setPickup(place);
},
child: Text(
app.pickup?.title ?? 'Set a pickup address',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.cardTitle.copyWith(color: Colors.white),
),
),
),
Expanded(
child: AnimatedScale(
scale: _handingOver ? 0.96 : 1,
duration: DmMotion.base,
curve: DmMotion.ease,
child: AnimatedOpacity(
opacity: _handingOver ? 0.45 : 1,
duration: DmMotion.base, duration: DmMotion.base,
curve: DmMotion.ease, curve: DmMotion.ease,
child: RefreshIndicator( child: AnimatedOpacity(
color: DmColors.brand, opacity: _handingOver ? 0.45 : 1,
backgroundColor: DmColors.surface, duration: DmMotion.base,
onRefresh: () async { curve: DmMotion.ease,
await Future.wait([ child: RefreshIndicator(
app.detectPickupLocation(), color: DmColors.brand,
app.refreshOrders(), backgroundColor: DmColors.surface,
]); onRefresh: () async {
}, await Future.wait([
child: ListView( app.detectPickupLocation(),
physics: const AlwaysScrollableScrollPhysics(), app.refreshOrders(),
padding: const EdgeInsets.fromLTRB( ]);
DmSpace.pad, },
16, child: LayoutBuilder(
DmSpace.pad, builder: (context, box) => ListView(
28, physics: const AlwaysScrollableScrollPhysics(),
), padding: const EdgeInsets.fromLTRB(
children: [ DmSpace.pad,
const SizedBox(height: 6), 0,
DmBookOrb( DmSpace.pad,
onTap: _book, 0,
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(), children: [
], ConstrainedBox(
constraints: BoxConstraints(
minHeight: math.max(orbHeight, box.maxHeight),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
DmBookOrb(onTap: _book, held: _handingOver)
.animate()
.fadeIn(duration: 420.ms)
.scale(
begin: const Offset(0.82, 0.82),
end: const Offset(1, 1),
duration: 620.ms,
curve: Curves.easeOutBack,
),
const SizedBox(height: 20),
// The promise, as a chip. It was a sentence under
// the sphere on first run and nothing afterwards;
// a returning customer is the one who most wants
// to know a Miler is three minutes away.
const _PromiseChip(
icon: LucideIcons.zap,
label: '3 min pickup',
).animate().fadeIn(
duration: 380.ms,
delay: 220.ms,
),
],
),
),
],
),
),
), ),
), ),
), ),
), ),
), // ── The foot: what is running, and nothing else ──
], //
// A "quick dispatch" row of shortcut cities lived here and was
// removed. It was a second way to start the one thing this screen
// starts, and it cost the sphere the space that makes it read as an
// object floating in a page rather than as a button in a stack.
//
// The card sits outside the scroll view, so the sphere is centred in
// what is actually left rather than in a viewport pretending the
// card is not there.
if (active != null)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
14,
DmSpace.pad,
6,
),
child: _LiveCard(booking: active),
)
.animate()
.fadeIn(duration: 380.ms, delay: 160.ms)
.slideY(begin: 0.12, curve: DmMotion.ease),
// Clears the floating tab bar, which the shell stacks over the body.
//
// Measured, not guessed. A fixed 74 was right on the 844pt golden
// and eight points short on a device with a home indicator, where
// the bar is its own 58 plus 10 of padding plus a 34pt inset — so
// the pill sat on top of the live card.
SizedBox(height: MediaQuery.paddingOf(context).bottom + 74),
],
),
); );
} }
@@ -197,14 +273,24 @@ class _HomeScreenState extends State<HomeScreen> {
} }
} }
/// The live booking, as one row. /// The live booking, at the foot of Home.
/// ///
/// This was a card with four cells. A customer glancing at Home wants two /// ── The loader went, and the card grew ──
/// 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, /// It carried a thin filled line under the destination — a progress bar across
/// the reference and the package count all went back there. /// the five customer milestones. It was the one fact that cannot be written as
class _LiveRow extends StatelessWidget { /// a word, and it still read as a *loader*: a bar that fills is the shape an
const _LiveRow({required this.booking}); /// app uses to say "wait", and a parcel two days into a journey is not
/// something the customer is waiting through. The rail on tracking says the
/// same thing with five labelled nodes and no ambiguity.
///
/// What the space bought is size. This is the only thing on Home besides the
/// sphere and it was a 90pt strip — a chip, a place and a courier compressed
/// into three tight lines. The destination is set at display weight with its
/// promise under it now, a rule separates the journey from the person carrying
/// it, and the arrow is a target rather than a glyph.
class _LiveCard extends StatelessWidget {
const _LiveCard({required this.booking});
final Booking booking; final Booking booking;
@@ -212,162 +298,259 @@ class _LiveRow extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final app = AppScope.read(context); final app = AppScope.read(context);
final slot = app.slotById(booking.slotId); final slot = app.slotById(booking.slotId);
final miler = booking.miler ?? booking.deliveryAgent;
// One supporting line, chosen by what the customer can act on right now: // The district alone. "Chennai, Tamil Nadu" shares its row with the state
// before the handover that is when the Miler is coming, after it that is // and the reference, and on a 402pt device it rendered "Chenna…" — the
// where the parcel is going. // half of the name that identifies nothing.
final support = switch (booking.stage) { final place = booking.isMultiDestination
? '${booking.destinations.length} destinations'
: booking.destination.shortLabel;
// The promise, in the order a customer can act on it: how long, then
// where they are, then what was agreed.
final promise = switch (booking.stage) {
JourneyStage.arrived => 'At your door now',
_ when booking.milerEtaMinutes != null && booking.milerEtaMinutes! > 0 =>
'Arriving in about ${booking.milerEtaMinutes} min',
JourneyStage.booked || JourneyStage.assigned || JourneyStage.onTheWay JourneyStage.booked || JourneyStage.assigned || JourneyStage.onTheWay
when slot != null => when slot != null =>
'${slot.day} · ${slot.window}', '${slot.day} · ${slot.window}',
JourneyStage.arrived => 'At your door now', _ when booking.expectedDelivery != null =>
_ => 'Expected ${booking.expectedDelivery}',
booking.isMultiDestination _ => booking.stage.milestoneLabel,
? 'To ${booking.destinations.length} destinations'
: 'To ${booking.destination.label}',
}; };
return Semantics( return DmCard(
button: true, raised: true,
child: GestureDetector( onTap: () {
behavior: HitTestBehavior.opaque, app.openTracking(booking.reference);
onTap: () { Navigator.of(context).push(
app.openTracking(booking.reference); bookingRoute<void>(
Navigator.of(context).push( BookingRoutes.tracking,
bookingRoute<void>( (_) => const TrackingScreen(),
BookingRoutes.tracking,
(_) => const TrackingScreen(),
),
);
},
child: Container(
constraints: const BoxConstraints(minHeight: 64),
padding: const EdgeInsets.fromLTRB(16, 14, 12, 14),
decoration: BoxDecoration(
color: DmColors.brandSoft,
borderRadius: DmRadius.all(DmRadius.md),
border: Border.all(color: DmColors.brandLine),
), ),
child: Row( );
},
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(18, 17, 18, 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
const DmLiveDot(size: 7), Row(
const SizedBox(width: 12), children: [
Expanded( const DmLiveDot(size: 7),
child: Column( const SizedBox(width: 9),
crossAxisAlignment: CrossAxisAlignment.start, Flexible(
mainAxisSize: MainAxisSize.min, child: Text(
children: [ booking.stage.chipLabel.toUpperCase(),
Text( style: DmText.eyebrow.copyWith(
booking.stage.milestoneLabel, fontSize: 10,
style: DmText.cardTitle.copyWith( letterSpacing: 0.9,
color: DmColors.brandPress, color: DmColors.brand,
), ),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
Text( ),
support, const SizedBox(width: 10),
style: DmText.small, Flexible(
child: Text(
booking.displayId,
style: DmText.monoSm.copyWith(color: DmColors.ink3),
textAlign: TextAlign.right,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
),
],
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
place,
style: DmText.headerTitle.copyWith(fontSize: 26),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
Text(
promise,
style: DmText.body.copyWith(
fontSize: 14.5,
color: DmColors.ink3,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 12),
// A disc, not a bare chevron. It is the only affordance on
// the card and it sits against 26pt of type, where a 17pt
// glyph on its own disappears.
Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: DmColors.brandSoft,
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.arrowRight,
size: 19,
color: DmColors.brand,
),
),
],
),
if (miler != null) ...[
const SizedBox(height: 16),
const Divider(
height: 1,
thickness: 1,
color: DmColors.hairline,
),
const SizedBox(height: 14),
Row(
children: [
DmAvatar(initials: miler.initials, size: 38),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
miler.name,
style: DmText.cardTitle.copyWith(fontSize: 14.5),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
miler.vehicle,
style: DmText.monoSm.copyWith(color: DmColors.ink3),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
], ],
), ),
), ],
const SizedBox(width: 8),
const Icon(
LucideIcons.chevronRight,
size: 18,
color: DmColors.brand,
),
], ],
), ),
), ),
],
);
}
}
/// Where the Miler is coming to, under the greeting.
///
/// ── Box → line → pill → line → row → pill ──
///
/// It has been most shapes. The one it has landed on is the reference's: a
/// small bordered pill holding a brand pin, the address, and a chevron-down.
///
/// The pill is right *here* and was wrong before because the screen changed
/// around it. On a page whose only other objects were a name and a sphere it
/// was a container built to hold one line of text. On a page that now opens
/// with a wordmark bar and ends with a floating one, it is the middle term —
/// and the chevron-down is the honest glyph, because what it opens is a
/// chooser, not another screen.
class _PickupLine extends StatelessWidget {
const _PickupLine({required this.title, required this.onTap});
final String title;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: 'Pickup from $title. Change',
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Align(
alignment: Alignment.centerLeft,
child: Container(
constraints: const BoxConstraints(minHeight: 40, maxWidth: 300),
padding: const EdgeInsets.fromLTRB(11, 8, 11, 8),
decoration: BoxDecoration(
color: DmColors.surface,
borderRadius: DmRadius.all(DmRadius.pill),
border: Border.all(color: DmColors.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(LucideIcons.mapPin, size: 15, color: DmColors.brand),
const SizedBox(width: 8),
Flexible(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.cardTitle.copyWith(fontSize: 14),
),
),
const SizedBox(width: 6),
const Icon(
LucideIcons.chevronDown,
size: 15,
color: DmColors.ink4,
),
],
),
),
),
), ),
); );
} }
} }
/// How it works, on first run only. /// The promise under the sphere: a glyph, a figure, a tint.
/// ///
/// This was a bordered card holding three numbered rows, each with a subtitle. /// It was a full sentence — "A Miler collects from your door." — shown on the
/// It is a timeline now — glyph, hairline, title — and the subtitles are gone: /// first run only. That is the app explaining itself to somebody who has
/// they described the step the title had already named, on the one screen where /// already pressed nothing; what a customer actually wants before they commit
/// a new customer is least willing to read. /// is how long they will wait, and that is worth saying every time.
class _HowItWorks extends StatelessWidget { class _PromiseChip extends StatelessWidget {
const _HowItWorks(); const _PromiseChip({required this.icon, required this.label});
static const _steps = [
(LucideIcons.mapPin, 'Tell us where it goes'),
(LucideIcons.clock3, 'Pick a two-hour window'),
(LucideIcons.packageCheck, 'Hand it over and track'),
];
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
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 _Step extends StatelessWidget {
const _Step({required this.icon, required this.title, required this.last});
final IconData icon; final IconData icon;
final String title; final String label;
final bool last;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return IntrinsicHeight( return Container(
padding: const EdgeInsets.fromLTRB(11, 7, 13, 8),
decoration: BoxDecoration(
color: DmColors.surface,
borderRadius: DmRadius.all(DmRadius.pill),
boxShadow: DmShadow.card,
),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min,
children: [ children: [
// The rail: a washed disc per step, joined by a hairline that runs Icon(icon, size: 13, color: DmColors.brand),
// the height of that step's text. Sized by [IntrinsicHeight] so the const SizedBox(width: 7),
// connector always meets the next disc, whatever the text wraps to. Text(
SizedBox( label,
width: 34, style: DmText.label.copyWith(fontSize: 12.5, color: DmColors.ink),
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),
),
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

@@ -97,8 +97,19 @@ class OrderDetailsScreen extends StatelessWidget {
], ],
) )
else else
// ── The hero stopped being a crimson slab ──
//
// A filled brand card was the first and biggest thing on a screen
// about an order that is *finished*. Crimson is this app's action
// colour — the sphere, the button, the live dot — so the loudest
// possible use of it was sitting on the one screen with nothing
// left to do.
//
// Green for the tick, ink for the type, white for the card. The
// design already reserves green for exactly one meaning, and this
// is it; the confirmation screen says the same word the same way.
DmCard( DmCard(
tone: DmCardTone.fill, raised: true,
children: [ children: [
DmCardCell( DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 18), padding: const EdgeInsets.fromLTRB(16, 20, 16, 18),
@@ -110,13 +121,13 @@ class OrderDetailsScreen extends StatelessWidget {
height: 42, height: 42,
alignment: Alignment.center, alignment: Alignment.center,
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: DmColors.onBrandRaise, color: DmColors.okSoft,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon( child: const Icon(
LucideIcons.check, LucideIcons.check,
size: 22, size: 22,
color: Colors.white, color: DmColors.ok,
), ),
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
@@ -124,16 +135,13 @@ class OrderDetailsScreen extends StatelessWidget {
recipient == null recipient == null
? 'Delivered' ? 'Delivered'
: 'Delivered to $recipient', : 'Delivered to $recipient',
style: DmText.display.copyWith( style: DmText.display.copyWith(fontSize: 26),
fontSize: 26,
color: Colors.white,
),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
'${booking.destination.districtName} · ' '${booking.destination.districtName} · '
'${dateLabel(booking.deliveredAt ?? booking.createdAt)}', '${dateLabel(booking.deliveredAt ?? booking.createdAt)}',
style: DmText.small.copyWith(color: DmColors.onBrand2), style: DmText.small.copyWith(color: DmColors.ink2),
), ),
], ],
), ),
@@ -145,7 +153,7 @@ class OrderDetailsScreen extends StatelessWidget {
const Icon( const Icon(
LucideIcons.package, LucideIcons.package,
size: 15, size: 15,
color: DmColors.onBrand3, color: DmColors.ink3,
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
@@ -153,14 +161,14 @@ class OrderDetailsScreen extends StatelessWidget {
booking.packageSummary ?? booking.packageSummary ??
'${booking.totalPackages} ' '${booking.totalPackages} '
'${booking.totalPackages == 1 ? 'package' : 'packages'}', '${booking.totalPackages == 1 ? 'package' : 'packages'}',
style: DmText.small.copyWith(color: DmColors.onBrand2), style: DmText.small.copyWith(color: DmColors.ink2),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
Text( Text(
booking.displayId, booking.displayId,
style: DmText.monoSm.copyWith(color: Colors.white), style: DmText.monoSm.copyWith(color: DmColors.ink3),
), ),
], ],
), ),
@@ -201,7 +209,7 @@ class OrderDetailsScreen extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(18, 16, 18, 4), padding: const EdgeInsets.fromLTRB(18, 16, 18, 4),
child: Text( child: Text(
'PACKAGES', 'PACKAGES',
style: DmText.eyebrow.copyWith(color: DmColors.brand), style: DmText.eyebrow,
), ),
), ),
for (final group in booking.destinations) for (final group in booking.destinations)
@@ -231,7 +239,7 @@ class OrderDetailsScreen extends StatelessWidget {
Expanded( Expanded(
child: Text( child: Text(
'PAYMENT', 'PAYMENT',
style: DmText.eyebrow.copyWith(color: DmColors.brand), style: DmText.eyebrow,
), ),
), ),
Text( Text(
@@ -370,7 +378,7 @@ class _VerificationCard extends StatelessWidget {
Expanded( Expanded(
child: Text( child: Text(
'COLLECTED AT YOUR DOOR', 'COLLECTED AT YOUR DOOR',
style: DmText.eyebrow.copyWith(color: DmColors.brand), style: DmText.eyebrow,
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
@@ -558,12 +566,15 @@ class _Card extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// The same lift [DmCard] carries. This screen's own wrapper predates it
// and was still drawing a border, so four outlined panels sat under a card
// that had stopped having one.
return Container( return Container(
padding: padding, padding: padding,
decoration: BoxDecoration( decoration: BoxDecoration(
color: DmColors.surface, color: DmColors.surface,
border: Border.all(color: DmColors.border), borderRadius: DmRadius.all(DmRadius.card),
borderRadius: DmRadius.all(DmRadius.md), boxShadow: DmShadow.card,
), ),
child: child, child: child,
); );

View File

@@ -4,32 +4,35 @@ import '../../data/models.dart';
import '../../state/app_scope.dart'; import '../../state/app_scope.dart';
import '../format.dart'; import '../format.dart';
import '../tokens.dart'; import '../tokens.dart';
import '../widgets/chrome.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../widgets/cards.dart'; import '../widgets/cards.dart';
import '../widgets/misc.dart';
import '../widgets/route_rail.dart';
import 'booking/booking_routes.dart'; import 'booking/booking_routes.dart';
import 'order_details_screen.dart'; import 'order_details_screen.dart';
import 'tracking_screen.dart'; import 'tracking_screen.dart';
/// One row in Orders, as a pickup → drop card. /// One order in the list.
/// ///
/// Before collection the card is the pickup. Afterwards each destination has /// ── The shape it landed on ──
/// 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 quiet ── /// Bordered card → bare type on the canvas → lifted card → this. The
/// reference this is drawn against gives the card a *structure* rather than a
/// template, and the structure is four bands:
/// ///
/// A customer scanning this list is asking one question — what is happening to /// #DM-6912 ● OUT FOR DELIVERY ⏱ Arriving in 30m
/// my parcel — so the state is the first thing on every card, at the same /// Bengaluru 1 parcel · 2.4 kg
/// height on each. /// 38 Mettupalayam Road → Indiranagar
/// ─────────────────────────────────────────────────────
/// ⊕ Rajesh K. · On the way [ Track → ]
/// ///
/// The reference used to share a crimson-washed strip with it, which gave a /// identity, destination, route, and whatever is happening right now. The
/// string nobody reads while scanning the same weight as the only word they do. /// fourth band is what stops four orders reading as four instances of one
/// It is a quiet identifier on the same line now, and the strip is gone: one /// widget: it carries the Miler when there is one, the window when there is
/// cell, not two. /// not, and a filled Track button only while the order is live.
/// ///
/// "Collected on DM-471200" went with it. It explained why two orders share a /// Hub names — "Whitefield Hub → Indiranagar 100ft Rd" in the reference — are
/// pickup, which is a question asked on the order, not in a list of them. /// not on a booking. `hub` belongs to the service-area catalogue, so the route
/// band shows the two ends the booking actually has.
class OrderCard extends StatelessWidget { class OrderCard extends StatelessWidget {
const OrderCard({super.key, required this.entry}); const OrderCard({super.key, required this.entry});
@@ -43,104 +46,190 @@ class OrderCard extends StatelessWidget {
final live = booking.status == BookingStatus.active; final live = booking.status == BookingStatus.active;
final stage = group?.stage ?? booking.stage; final stage = group?.stage ?? booking.stage;
final (label, tone) = switch (booking.status) { final (label, Color tone) = switch (booking.status) {
BookingStatus.cancelled => ('Cancelled', DmPillTone.off), BookingStatus.cancelled => ('Cancelled', DmColors.ink4),
BookingStatus.completed => ('Delivered', DmPillTone.done), BookingStatus.completed => ('Delivered', DmColors.ok),
BookingStatus.active => ( BookingStatus.active => (stage.chipLabel, DmColors.brand),
stage.milestoneLabel,
// Crimson-filled only while something is actually moving towards the
// customer. Everything else in progress holds the outline, so a list
// of six active orders has at most a couple of filled pills on it.
_isMoving(stage) ? DmPillTone.live : DmPillTone.neutral,
),
}; };
final drop = entry.isPickup final destination = entry.isPickup
? (booking.isMultiDestination ? (booking.isMultiDestination
? '${booking.destinations.length} destinations' ? '${booking.destinations.length} destinations'
: booking.destination.label) : booking.destination.shortLabel)
: group!.destination.label; : group!.destination.shortLabel;
final reference = entry.isPickup final reference = entry.isPickup
? booking.reference ? booking.reference
: (group!.trackingId ?? booking.reference); : (group!.trackingId ?? booking.reference);
final packages = entry.isPickup ? booking.totalPackages : group!.packageCount; final packages = entry.isPickup ? booking.totalPackages : group!.packageCount;
final meta = [ final slot = app.slotById(booking.slotId);
'$packages ${packages == 1 ? 'package' : 'packages'}',
if (booking.status == BookingStatus.completed && booking.amountPaid != null) // Top right: when. An ETA while a Miler is moving, otherwise the window
'₹${booking.amountPaid} paid', // that was agreed, otherwise the day it was placed.
final eta = booking.milerEtaMinutes;
final collected = stage.index >= JourneyStage.pickedUp.index;
final (timing, urgent) = switch (true) {
_ when live && eta != null && eta > 0 => ('Arriving in ${eta}m', true),
_ when live && stage == JourneyStage.arrived => ('At your door', true),
// The pickup window is a promise about the *collection*. Once the parcel
// is on the network it says nothing, and printing it next to "In
// transit" reads as a delivery time that nobody promised.
_ when live && !collected && slot != null => (slot.window, false),
_ when live && booking.expectedDelivery != null =>
('Exp. ${booking.expectedDelivery}', false),
_ => (dateLabel(booking.createdAt), false),
};
// Second line, right: what is in it. The weight appears only once the
// Miler has actually weighed it at the door.
// The weight of *these* parcels, and only once a Miler has actually
// weighed them. `totalWeightLabel` is the whole visit's, which on one
// destination's row is somebody else's parcel.
final weight = entry.isPickup
? booking.totalWeightLabel
: group!.verification?.weightLabel;
final contents = [
'$packages ${packages == 1 ? 'parcel' : 'parcels'}',
?weight,
].join(' · '); ].join(' · ');
// The foot: who has it, or when it was promised.
final miler = booking.miler ?? booking.deliveryAgent;
final footNote = miler != null
? '${miler.name} · ${stage.chipLabel}'
: (booking.status == BookingStatus.completed
? 'Delivered${booking.amountPaid == null ? '' : ' · ₹${booking.amountPaid}'}'
: stage.milestoneLabel);
void open() {
app.openTracking(booking.reference);
Navigator.of(context).push(
live
? bookingRoute<void>(
BookingRoutes.tracking,
(_) => TrackingScreen(focusedDestination: entry.destinationIndex),
)
: MaterialPageRoute<void>(
builder: (_) => OrderDetailsScreen(reference: booking.reference),
),
);
}
return DmCard( return DmCard(
// The spine marks the one card the customer has to do something about. raised: live,
rail: live && stage == JourneyStage.arrived, onTap: open,
onTap: () {
app.openTracking(booking.reference);
Navigator.of(context).push(
live
? bookingRoute<void>(
BookingRoutes.tracking,
(_) => TrackingScreen(focusedDestination: entry.destinationIndex),
)
: MaterialPageRoute<void>(
builder: (_) => OrderDetailsScreen(reference: booking.reference),
),
);
},
children: [ children: [
DmCardCell( DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 15), padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( 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: [ children: [
// Flexible, not Expanded: the pill is sized by its own text Flexible(child: _RefChip(reference: reference)),
// and only gives way if the row runs out of room. Expanded const SizedBox(width: 8),
// made it fill every spare pixel, which drew "In transit" Flexible(
// as a 600pt bar with one word in it. Expanding the *date* child: DmStatusChip(
// instead — the version before that — clipped the only word label: label,
// on the row that matters, to make space for "Yesterday". tone: tone,
// Neither of them is flexible; the pill is. quiet: !live,
Flexible(child: DmPill(label, tone: tone)), ),
Padding( ),
padding: const EdgeInsets.only(left: 10), const SizedBox(width: 8),
child: Text( Flexible(
dateLabel(booking.createdAt), child: Row(
maxLines: 1, mainAxisSize: MainAxisSize.min,
style: DmText.tiny, children: [
if (urgent) ...[
const Icon(
LucideIcons.clock,
size: 12,
color: DmColors.brand,
),
const SizedBox(width: 5),
],
Flexible(
child: Text(
timing,
style: DmText.small.copyWith(
fontSize: 12.5,
color: urgent ? DmColors.brand : DmColors.ink3,
),
textAlign: TextAlign.right,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
), ),
), ),
], ],
), ),
const SizedBox(height: 13), const SizedBox(height: 10),
DmRouteRail(
compact: true,
pickup: booking.pickup.title,
drop: drop,
),
const SizedBox(height: 11),
Row( Row(
// The date belongs at the right edge, on the same line on crossAxisAlignment: CrossAxisAlignment.baseline,
// every card, so the eye can run down the column. Left to the textBaseline: TextBaseline.alphabetic,
// default packing it sits wherever the status text happens to
// end, which is a different place on all four.
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Expanded(child: Text(meta, style: DmText.tiny)), Flexible(
const SizedBox(width: 10), child: Text(
Text( destination,
reference, style: DmText.headerTitle.copyWith(
maxLines: 1, fontSize: 26,
overflow: TextOverflow.ellipsis, color: live ? DmColors.ink : DmColors.ink2,
style: DmText.monoSm.copyWith(color: DmColors.ink4), ),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
), ),
const SizedBox(width: 10),
Flexible(
child: Text(
contents,
textAlign: TextAlign.right,
style: DmText.small.copyWith(
fontSize: 12.5,
color: DmColors.ink3,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 4),
_Route(
from: booking.pickup.title,
to: entry.isPickup
? (booking.isMultiDestination
? '${booking.destinations.length} stops'
: booking.destination.label)
: group!.destination.label,
),
const SizedBox(height: 13),
const Divider(height: 1, thickness: 1, color: DmColors.hairline),
const SizedBox(height: 12),
Row(
children: [
Icon(
miler != null ? LucideIcons.userRound : LucideIcons.clock,
size: 15,
color: DmColors.ink4,
),
const SizedBox(width: 9),
Expanded(
child: Text(
footNote,
style: DmText.small.copyWith(
fontSize: 13,
color: DmColors.ink2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 10),
_EndButton(live: live, onPressed: open),
], ],
), ),
], ],
@@ -149,15 +238,114 @@ class OrderCard extends StatelessWidget {
], ],
); );
} }
/// True while the parcel — or the Miler coming for it — is physically on the
/// move, which is the only time the loud pill is earned.
static bool _isMoving(JourneyStage stage) => switch (stage) {
JourneyStage.booked ||
JourneyStage.onTheWay ||
JourneyStage.arrived ||
JourneyStage.outForDelivery =>
true,
_ => false,
};
} }
/// The reference, in a quiet well.
///
/// It reads as an identifier rather than as a sentence — which is what it is,
/// and the reason it can sit at 11.5pt beside a 26pt place name without
/// disappearing.
class _RefChip extends StatelessWidget {
const _RefChip({required this.reference});
final String reference;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 5),
decoration: BoxDecoration(
color: DmColors.surfaceAlt,
borderRadius: DmRadius.all(DmRadius.chip),
),
child: Text(
reference.startsWith('#') ? reference : '#$reference',
style: DmText.monoSm.copyWith(fontSize: 11, color: DmColors.ink2),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
);
}
}
/// Where it leaves from and where it is going, as one line with an arrow.
class _Route extends StatelessWidget {
const _Route({required this.from, required this.to});
final String from;
final String to;
@override
Widget build(BuildContext context) {
final style = DmText.small.copyWith(fontSize: 12.5, color: DmColors.ink3);
return Row(
children: [
Flexible(
child: Text(from, style: style, maxLines: 1, overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 7),
const Icon(LucideIcons.arrowRight, size: 13, color: DmColors.ink4),
const SizedBox(width: 7),
Flexible(
child: Text(
to,
style: style.copyWith(color: DmColors.ink2),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
}
/// Track while it is running, Details once it is not.
///
/// Filled crimson for the live one and a quiet text action for the rest, so a
/// screen of eleven delivered parcels has no red on it at all.
class _EndButton extends StatelessWidget {
const _EndButton({required this.live, required this.onPressed});
final bool live;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onPressed,
child: Container(
padding: live
? const EdgeInsets.fromLTRB(15, 9, 12, 10)
: const EdgeInsets.fromLTRB(10, 9, 6, 10),
decoration: BoxDecoration(
color: live ? DmColors.brand : Colors.transparent,
borderRadius: DmRadius.all(DmRadius.pill),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
live ? 'Track' : 'Details',
style: DmText.label.copyWith(
fontSize: 13,
color: live ? Colors.white : DmColors.ink2,
),
),
const SizedBox(width: 6),
Icon(
live ? LucideIcons.arrowRight : LucideIcons.chevronRight,
size: 14,
color: live ? Colors.white : DmColors.ink4,
),
],
),
),
),
);
}
}

View File

@@ -1,54 +1,114 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../data/models.dart'; import '../../data/models.dart';
import '../../state/app_scope.dart'; import '../../state/app_scope.dart';
import '../format.dart';
import '../tokens.dart'; import '../tokens.dart';
import '../widgets/buttons.dart'; import '../widgets/inputs.dart';
import '../widgets/feedback.dart';
import '../widgets/chrome.dart'; import '../widgets/chrome.dart';
import '../widgets/states.dart'; import '../widgets/states.dart';
import 'order_row.dart'; import 'order_row.dart';
/// Orders — current bookings and past ones, as route cards. /// Orders — current bookings and past ones, as route cards.
/// ///
/// The month, the title and the filter all live inside one crimson field at /// ── The filter came out of the header ──
/// the top. That is deliberate: the filter is this screen's only control, and ///
/// recessing it into the header means the list below can be pure content with /// It was a segmented control recessed into the crimson field, which made it
/// no chrome of its own — no title bar competing with the first card, no /// look like part of the title bar rather than like something to press. A
/// separate count row, because each tab carries its own figure. /// filter is a control the customer uses on every visit; it belongs on the
class OrdersScreen extends StatelessWidget { /// canvas with the list it filters, where it can scroll into view as a row of
/// chips rather than sit locked into a band of chrome.
///
/// Each chip carries its own count, so there is still no separate figure row.
class OrdersScreen extends StatefulWidget {
const OrdersScreen({super.key}); const OrdersScreen({super.key});
@override
State<OrdersScreen> createState() => _OrdersScreenState();
}
class _OrdersScreenState extends State<OrdersScreen> {
static const _tabs = BookingStatus.values; static const _tabs = BookingStatus.values;
final _query = TextEditingController();
@override
void dispose() {
_query.dispose();
super.dispose();
}
/// Reference or destination, case-insensitively. Two fields, because those
/// are the two strings a customer has: the number they were given, and the
/// place they sent it to.
bool _matches(OrderEntry e, String q) {
if (q.isEmpty) return true;
final needle = q.toLowerCase();
final reference = e.isPickup
? e.booking.reference
: (e.group?.trackingId ?? e.booking.reference);
final place = e.isPickup
? e.booking.destination.label
: (e.group?.destination.label ?? '');
return reference.toLowerCase().contains(needle) ||
place.toLowerCase().contains(needle);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final app = AppScope.of(context); final app = AppScope.of(context);
final rows = app.entriesOf(app.ordersTab); final q = _query.text.trim();
final all = app.entriesOf(app.ordersTab);
final rows = [for (final e in all) if (_matches(e, q)) e];
final index = _tabs.indexOf(app.ordersTab); final index = _tabs.indexOf(app.ordersTab);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
DmBrandHeader( DmAppBar(
eyebrow: monthLabel(DateTime.now()), unread: app.activeBookings.isNotEmpty,
title: 'Orders', onSearch: () => DmToast.show(context, 'Search is coming soon'),
action: DmIconButton( onNotifications: () =>
icon: LucideIcons.refreshCw, DmToast.show(context, 'Nothing new right now'),
tooltip: 'Refresh', ),
background: DmColors.onBrandRaise, Padding(
foreground: Colors.white, padding: const EdgeInsets.fromLTRB(DmSpace.pad, 4, DmSpace.pad, 14),
border: Colors.transparent, // The filter gets its own row.
radius: DmRadius.lg, //
size: 42, // Beside the title it had a third of the width for three segments,
onPressed: app.refreshOrders, // and rendered "Act… Pa… Ca…" — the reference fits its control on
), // the title line because it carries two segments, not three. A row
child: DmSegmented( // is cheaper than an unreadable control.
labels: const ['Active', 'Completed', 'Cancelled'], child: Column(
counts: [for (final tab in _tabs) app.entriesOf(tab).length], crossAxisAlignment: CrossAxisAlignment.stretch,
index: index < 0 ? 0 : index, children: [
onChanged: (i) => app.setOrdersTab(_tabs[i]), Text(
'Orders',
style: DmText.headerTitle.copyWith(fontSize: 26),
),
const SizedBox(height: 14),
_Filter(
labels: const ['Active', 'Past', 'Cancelled'],
counts: [for (final tab in _tabs) app.entriesOf(tab).length],
index: index < 0 ? 0 : index,
onChanged: (i) => app.setOrdersTab(_tabs[i]),
),
const SizedBox(height: 12),
// ── A search box, because the list is the archive ──
//
// Orders is the only screen that grows without bound: every
// parcel a customer has ever sent ends up here. Scrolling is
// fine at four and useless at forty, and the two things they
// have to go on are the reference somebody quoted at them and
// the city they sent it to. Both are matched.
DmSearchField(
controller: _query,
hint: 'Search by order ID or city',
onChanged: (_) => setState(() {}),
),
],
), ),
), ),
Expanded( Expanded(
@@ -58,11 +118,14 @@ class OrdersScreen extends StatelessWidget {
onRefresh: app.refreshOrders, onRefresh: app.refreshOrders,
child: ListView.separated( child: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 16, DmSpace.pad, 28), // The last card clears the floating tab bar, which the shell
// stacks over the body rather than laying out beneath it.
padding: EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 74 + MediaQuery.paddingOf(context).bottom),
itemCount: rows.isEmpty ? 1 : rows.length, itemCount: rows.isEmpty ? 1 : rows.length,
separatorBuilder: (_, _) => const SizedBox(height: 10), separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, i) => itemBuilder: (context, i) => rows.isEmpty
rows.isEmpty ? _empty(app.ordersTab) : OrderCard(entry: rows[i]), ? (q.isEmpty ? _empty(app.ordersTab) : _noMatch(q))
: OrderCard(entry: rows[i]),
), ),
), ),
), ),
@@ -70,6 +133,14 @@ class OrdersScreen extends StatelessWidget {
); );
} }
/// Nothing matched what they typed — which is a different problem from
/// having no orders, and says so.
Widget _noMatch(String query) => DmEmptyState(
icon: LucideIcons.search,
title: 'No orders match "$query"',
message: 'Try an order ID, or the city it was going to.',
);
Widget _empty(BookingStatus tab) { Widget _empty(BookingStatus tab) {
final (title, message) = switch (tab) { final (title, message) = switch (tab) {
BookingStatus.active => ( BookingStatus.active => (
@@ -92,3 +163,123 @@ class OrdersScreen extends StatelessWidget {
); );
} }
} }
/// The three filters, as chips on the canvas.
///
/// Scrollable because a fourth state is one backend release away and a row of
/// four fixed chips at 1.8x text has nowhere to go. Each carries its count in
/// the same pill, because "Active" and "4" are one fact.
/// Active / Past / Cancelled, as a segmented control.
///
/// ── Chips → text tabs → a segment ──
///
/// The chips were three containers, one of them filled solid black, sitting
/// above a list of cards — the loudest object on the screen was the filter.
/// Plain text tabs with a rule fixed that and left the row looking like a
/// heading rather than a control.
///
/// A groove with a raised chip in it is the shape that is unambiguously a
/// control and still quiet: white lifted out of a warm bed, ink type, and the
/// count beside the label because that is the reason anyone looks at the two
/// tabs they are not on.
class _Filter extends StatelessWidget {
const _Filter({
required this.labels,
required this.counts,
required this.index,
required this.onChanged,
});
final List<String> labels;
final List<int?> counts;
final int index;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: DmColors.groove,
borderRadius: DmRadius.all(DmRadius.pill),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < labels.length; i++)
Flexible(
child: _Tab(
label: labels[i],
count: counts[i],
selected: i == index,
onTap: () => onChanged(i),
),
),
],
),
);
}
}
class _Tab extends StatelessWidget {
const _Tab({
required this.label,
required this.count,
required this.selected,
required this.onTap,
});
final String label;
final int? count;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
selected: selected,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
HapticFeedback.selectionClick();
onTap();
},
child: AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
constraints: const BoxConstraints(minHeight: 34),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? DmColors.surface : Colors.transparent,
borderRadius: DmRadius.all(DmRadius.pill),
boxShadow: selected ? DmShadow.card : null,
),
child: Text.rich(
TextSpan(
children: [
TextSpan(text: label),
if (count != null && count! > 0)
TextSpan(
text: ' ($count)',
style: DmText.small.copyWith(
fontSize: 12,
color: selected ? DmColors.ink3 : DmColors.ink4,
),
),
],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.label.copyWith(
fontSize: 13,
color: selected ? DmColors.ink : DmColors.ink3,
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
),
),
),
),
);
}
}

View File

@@ -7,7 +7,6 @@ import '../../../state/app_scope.dart';
import '../../state_marks.dart'; import '../../state_marks.dart';
import '../../tokens.dart'; import '../../tokens.dart';
import '../../widgets/buttons.dart'; import '../../widgets/buttons.dart';
import '../../widgets/cards.dart';
import '../../widgets/feedback.dart'; import '../../widgets/feedback.dart';
import '../../widgets/inputs.dart'; import '../../widgets/inputs.dart';
import '../../widgets/states.dart'; import '../../widgets/states.dart';
@@ -165,8 +164,29 @@ class _DestinationSheetState extends State<_DestinationSheet> {
Expanded( Expanded(
child: DmSheetHeader( child: DmSheetHeader(
title: browsing?.name ?? 'Where is it going?', title: browsing?.name ?? 'Where is it going?',
// ── A line of orientation ──
//
// The sheet asked a question and then showed a list, and a
// customer who has never used it cannot tell whether tapping
// a state chooses it or opens it. One sentence says which,
// and at the district step says how many they may pick.
subtitle: browsing == null
? 'Pick a state to see the cities it serves'
: (app.allowsMultipleDestinations
? 'Choose one or more districts'
: 'Choose a district'),
), ),
), ),
// A close, because a sheet that can only be dismissed by dragging
// is a sheet somebody will get stuck in.
DmIconButton(
icon: LucideIcons.x,
tooltip: 'Close',
background: DmColors.surface,
border: Colors.transparent,
size: 38,
onPressed: () => Navigator.of(context).pop(),
),
], ],
), ),
DmSearchField( DmSearchField(
@@ -295,23 +315,22 @@ class _StateRow extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return _Row( return _Row(
onTap: onTap, onTap: onTap,
leading: DmTile(icon: stateMark(area.code), tone: DmTileTone.wash), // The mark, with nothing behind it. It sat in a 38pt washed-crimson
// tile, so a list of six states was six pink squares in a column — six
// containers and six spots of brand to hold six glyphs that read
// perfectly well on the sheet's own surface.
leading: Icon(stateMark(area.code), size: 21, color: DmColors.ink3),
title: area.name, title: area.name,
subtitle: subtitle:
'$count ${count == 1 ? 'city' : 'cities'}' '$count ${count == 1 ? 'city' : 'cities'}'
'${area.transitTag == null ? '' : ' · ${area.transitTag}'}', '${area.transitTag == null ? '' : ' · ${area.transitTag}'}',
// The number of districts already chosen in this state. It was a filled
// crimson pill — a badge, for a figure that is only ever 1, 2 or 3.
trailing: chosen == 0 trailing: chosen == 0
? null ? null
: Container( : Text(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3), '$chosen',
decoration: BoxDecoration( style: DmText.cardTitle.copyWith(color: DmColors.brand),
color: DmColors.brand,
borderRadius: DmRadius.all(DmRadius.pill),
),
child: Text(
'$chosen',
style: DmText.label.copyWith(color: Colors.white),
),
), ),
); );
} }
@@ -400,11 +419,27 @@ class _Row extends StatelessWidget {
selected: selected, selected: selected,
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
child: Container( // ── Cards, not ruled rows ──
//
// The rules were the right correction to the washed-crimson tiles that
// came before them, and they left the sheet as the one screen in the
// app still made of open rows while everything behind it had moved to
// lifted white. A chooser is a set of objects you pick between, which
// is exactly what a card is for; the selected one takes the brand
// edge, so *chosen* reads at a glance rather than from a tick.
child: AnimatedContainer(
duration: DmMotion.fast,
curve: DmMotion.ease,
constraints: const BoxConstraints(minHeight: DmSpace.tap), constraints: const BoxConstraints(minHeight: DmSpace.tap),
padding: EdgeInsets.symmetric(vertical: leading == null ? 14 : 10), margin: const EdgeInsets.only(bottom: 10),
decoration: const BoxDecoration( padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
border: Border(bottom: BorderSide(color: DmColors.hairline)), decoration: BoxDecoration(
color: selected ? DmColors.brandSoft : DmColors.surface,
borderRadius: DmRadius.all(DmRadius.md),
border: selected
? Border.all(color: DmColors.brand, width: 1.5)
: null,
boxShadow: selected ? null : DmShadow.card,
), ),
child: Row( child: Row(
children: [ children: [

View File

@@ -88,18 +88,27 @@ class _WindowSheetState extends State<_WindowSheet> {
// One row of days rather than a heading per day: the list // One row of days rather than a heading per day: the list
// under it stays short enough to take in at a glance, which // under it stays short enough to take in at a glance, which
// is the whole reason this is a sheet. // is the whole reason this is a sheet.
Row( // The segments sit in a groove, which is what makes the
children: [ // chosen one read as raised rather than as merely white.
for (final d in days) ...[ Container(
if (d != days.first) const SizedBox(width: 8), padding: const EdgeInsets.all(4),
DmChoiceChip( decoration: BoxDecoration(
label: d, color: DmColors.groove,
selected: d == day, borderRadius: DmRadius.all(DmRadius.md),
expand: true, ),
onTap: () => setState(() => _day = d), child: Row(
), children: [
for (final d in days) ...[
if (d != days.first) const SizedBox(width: 4),
DmChoiceChip(
label: d,
selected: d == day,
expand: true,
onTap: () => setState(() => _day = d),
),
],
], ],
], ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
for (final slot in shown) ...[ for (final slot in shown) ...[

View File

@@ -36,6 +36,9 @@ class ShellScreenState extends State<ShellScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: DmColors.canvas, backgroundColor: DmColors.canvas,
// The bar floats, so it is stacked over the content rather than laid
// out beneath it — and each root pads its own scroll view past it.
extendBody: true,
body: IndexedStack( body: IndexedStack(
index: _index, index: _index,
children: const [HomeScreen(), OrdersScreen(), AccountScreen()], children: const [HomeScreen(), OrdersScreen(), AccountScreen()],
@@ -47,35 +50,22 @@ class ShellScreenState extends State<ShellScreen> {
/// The tab bar, fixed to the bottom edge. /// The tab bar, fixed to the bottom edge.
/// ///
/// ── Why it is no longer a floating pill-shaped bar ── /// ── A floating pill, after all ──
/// ///
/// It used to hover 10pt above the bottom with a 28pt radius and a shadow /// This bar has been three things. It hovered as a pill, then sat on the edge
/// under it, which put a second rounded surface between the content and the /// with a hairline over it and a crimson indicator travelling along it, then
/// gesture bar and made every screen end in two stacked edges. It now sits on /// lost the indicator's edge. The reference the app is now drawn against puts
/// the edge with a single hairline above it, like the footers in the rest of /// it back in the air — and the reason is better than the first time round.
/// the app.
/// ///
/// The indicator does the work instead: a crimson pill that travels to the tab /// The pill works *because* the screens above it stopped having edges. When
/// you pick, with the glyph turning white as it arrives. Selection reads as one /// every card was outlined, a floating bar was a fourth rounded surface in a
/// object moving rather than three tabs changing colour in place — and the /// stack of them. Now the content is white shapes lifted off a near-white
/// label under it stays crimson, so the tab is still identifiable in the /// page, and the bar is one more of those: same radius language, same shadow,
/// half-second the pill is in flight. /// read as the same kind of object.
/// ///
/// ── What the 2026-09-22 pass changed ── /// The labels went with the edge. Three glyphs a customer has seen on every
/// /// app they own do not need naming, and without them the bar is 62pt instead
/// **The pill stopped glowing.** It carried [DmShadow.liftSm] — a 65% crimson /// of 86 — which is the space the pill is floating in.
/// 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 { class _TabBar extends StatelessWidget {
const _TabBar({required this.index, required this.onChanged}); const _TabBar({required this.index, required this.onChanged});
@@ -91,80 +81,46 @@ class _TabBar extends StatelessWidget {
(label: 'Account', icon: LucideIcons.userRound), (label: 'Account', icon: LucideIcons.userRound),
]; ];
/// The travelling pill's size. Narrower than its slot so it reads as an static const double _slot = 62;
/// object sitting in the row rather than as a selected cell. static const double _height = 58;
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return DecoratedBox( return SafeArea(
decoration: const BoxDecoration( top: false,
color: DmColors.surface, child: Padding(
border: Border(top: BorderSide(color: DmColors.border)), padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10),
), // A Row, not a Center: `bottomNavigationBar` hands its child an
child: SafeArea( // unbounded height, and a Center in that slot expands to fill the
top: false, // whole screen with the pill floating in the middle of it.
child: Padding( child: Row(
padding: const EdgeInsets.fromLTRB(6, 9, 6, 7), mainAxisAlignment: MainAxisAlignment.center,
child: SizedBox( children: [
// The row is the pill plus the label, and the label is type: at Container(
// 1.8x accessibility text a fixed 58 clipped it by two points. The height: _height,
// bar gets taller instead, which is what a larger-text user asked padding: const EdgeInsets.symmetric(horizontal: 6),
// for. decoration: BoxDecoration(
height: _pillHeight + color: DmColors.surface,
5 + borderRadius: DmRadius.all(DmRadius.pill),
MediaQuery.textScalerOf(context).scale(11.5) * 1.3, boxShadow: DmShadow.cardRaised,
child: LayoutBuilder( ),
builder: (context, constraints) { child: Row(
final slot = constraints.maxWidth / _items.length; mainAxisSize: MainAxisSize.min,
return Stack( children: [
children: [ for (var i = 0; i < _items.length; i++)
AnimatedPositioned( SizedBox(
duration: DmMotion.slow, width: _slot,
curve: DmMotion.glide, child: _TabButton(
left: slot * index + (slot - _pillWidth) / 2, label: _items[i].label,
width: _pillWidth, icon: _items[i].icon,
top: 0, selected: index == i,
height: _pillHeight, onTap: () => onChanged(i),
child: DecoratedBox(
decoration: BoxDecoration(
color: DmColors.brand,
borderRadius: DmRadius.all(DmRadius.pill),
boxShadow: _pillShadow,
),
), ),
), ),
Row( ],
children: [ ),
for (var i = 0; i < _items.length; i++)
Expanded(
child: _TabButton(
label: _items[i].label,
icon: _items[i].icon,
selected: index == i,
onTap: () => onChanged(i),
),
),
],
),
],
);
},
), ),
), ],
), ),
), ),
); );
@@ -193,9 +149,11 @@ class _TabButtonState extends State<_TabButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final selected = widget.selected;
return Semantics( return Semantics(
button: true, button: true,
selected: widget.selected, selected: selected,
label: widget.label,
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTapDown: (_) => setState(() => _down = true), onTapDown: (_) => setState(() => _down = true),
@@ -203,54 +161,34 @@ class _TabButtonState extends State<_TabButton> {
onTapCancel: () => setState(() => _down = false), onTapCancel: () => setState(() => _down = false),
onTap: widget.onTap, onTap: widget.onTap,
child: AnimatedScale( 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, scale: _down ? 0.9 : 1,
duration: DmMotion.fast, duration: DmMotion.fast,
curve: DmMotion.ease, curve: DmMotion.ease,
child: Column( child: Center(
mainAxisAlignment: MainAxisAlignment.start, child: AnimatedContainer(
children: [ duration: DmMotion.base,
SizedBox( curve: DmMotion.ease,
height: _TabBar._pillHeight, width: 42,
child: Center( height: 42,
child: AnimatedScale( alignment: Alignment.center,
// Lands with the pill, on the pill's own curve. The decoration: BoxDecoration(
// overshoot is what ties the two together — the glyph is color: selected ? DmColors.brandSoft : Colors.transparent,
// reacting to the thing arriving under it. borderRadius: DmRadius.all(DmRadius.sm),
scale: widget.selected ? 1.14 : 1, ),
duration: DmMotion.slow, child: AnimatedScale(
curve: DmMotion.glide, // A shade of overshoot as the tint arrives under it, so the
child: AnimatedSwitcher( // two read as one gesture rather than as a glyph that
duration: DmMotion.base, // recolours when a rectangle happens to appear behind it.
child: Icon( scale: selected ? 1.06 : 1,
widget.icon, duration: DmMotion.slow,
key: ValueKey(widget.selected), curve: DmMotion.glide,
size: 21, child: Icon(
color: widget.selected ? Colors.white : DmColors.ink3, widget.icon,
), size: 21,
), color: selected ? DmColors.brand : DmColors.ink4,
),
), ),
), ),
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,
),
),
],
), ),
), ),
), ),

View File

@@ -12,11 +12,7 @@ import '../widgets/chrome.dart';
import '../widgets/feedback.dart'; import '../widgets/feedback.dart';
import '../widgets/milestones.dart'; import '../widgets/milestones.dart';
import '../widgets/misc.dart'; import '../widgets/misc.dart';
import '../widgets/pieces.dart';
import '../widgets/route_map.dart';
import '../widgets/route_rail.dart';
import '../widgets/states.dart'; import '../widgets/states.dart';
import '../widgets/summary.dart';
import 'booking/booking_routes.dart'; import 'booking/booking_routes.dart';
import 'booking/send_screen.dart'; import 'booking/send_screen.dart';
import 'sheets/cancel_sheet.dart'; import 'sheets/cancel_sheet.dart';
@@ -42,7 +38,7 @@ class TrackingScreen extends StatelessWidget {
if (booking == null) { if (booking == null) {
return Scaffold( return Scaffold(
backgroundColor: DmColors.canvas, backgroundColor: DmColors.canvas,
appBar: const DmTopBar(title: 'Live tracking'), appBar: const DmTopBar(title: 'Tracking'),
body: const DmEmptyState( body: const DmEmptyState(
icon: LucideIcons.package, icon: LucideIcons.package,
title: 'Nothing to track', title: 'Nothing to track',
@@ -68,18 +64,14 @@ class TrackingScreen extends StatelessWidget {
booking.miler != null && booking.miler != null &&
stage.index >= JourneyStage.assigned.index && stage.index >= JourneyStage.assigned.index &&
stage.index <= JourneyStage.pickedUp.index; stage.index <= JourneyStage.pickedUp.index;
final assigning = !cancelled && stage == JourneyStage.booked;
return Scaffold( return Scaffold(
backgroundColor: DmColors.canvas, backgroundColor: DmColors.canvas,
appBar: DmTopBar( appBar: DmTopBar(
title: cancelled ? 'Booking' : 'Live tracking', title: cancelled ? 'Booking' : 'Live Order Tracking',
// The reference belongs in the bar, not in a card three scrolls down: // No reference here. The head card carries it at 26pt, which is what
// it is what a customer reads out when they call about this parcel. // a customer reads out over a phone, and the same string twice on one
trailing: Text( // screen is one of them doing nothing.
focused?.trackingId ?? booking.displayId,
style: DmText.mono,
),
), ),
body: RefreshIndicator( body: RefreshIndicator(
color: DmColors.brand, color: DmColors.brand,
@@ -89,92 +81,25 @@ class TrackingScreen extends StatelessWidget {
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 24), padding: const EdgeInsets.only(bottom: 24),
children: [ children: [
_StatusHeader(booking: booking, slot: slot, focused: focused), _TrackCard(booking: booking, slot: slot, focused: focused),
// ── No radar while a Miler is being found ── // ── No map, and no radar ──
// //
// This showed a 200pt card holding a pulsing radar with a bike // The radar was a 200pt card with a pulsing sweep and a bike glyph
// glyph at its centre, then a banner counting "verified Milers // at its centre, beside a banner counting "verified Milers active
// active in your zone". Both were animation standing in for an // in your zone" — animation standing in for an answer.
// answer: the radar is not looking at anything, and the count is a //
// number the customer cannot use. The rail below says *Booking // The map went for a harder reason. It was 178pt of basemap, two
// created* with the line stopped there, and the crimson card above // labels and a pin, on a screen whose question is "where is my
// says what is happening in words. That is the answer, and it is // parcel" and whose honest answer is a milestone. The pin was the
// the same rail whether a Miler has been found or not. // *pickup address* — a place the customer chose and already knows
if (!assigning && !cancelled) // — not the Miler, who only has a position for the twenty minutes
Padding( // between assignment and the door. A picture that cannot show the
padding: const EdgeInsets.fromLTRB( // thing it implies invites somebody to stare at a stationary dot
DmSpace.pad, // and conclude nothing is happening.
0, //
DmSpace.pad, // The live position, when there is one, is a sentence on the
10, // Miler's own card.
),
// 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) if (!cancelled)
Padding( Padding(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
@@ -197,19 +122,26 @@ class TrackingScreen extends StatelessWidget {
child: _YourPackages(booking: booking, focused: focused), child: _YourPackages(booking: booking, focused: focused),
), ),
Padding( // `_RouteOutline` was here: the same pickup, destination and
padding: const EdgeInsets.fromLTRB( // window the head card now draws as a rail, repeated at the foot
DmSpace.pad, // of the screen. It existed when the head was a crimson headline
0, // that carried no route at all.
DmSpace.pad,
10, // ── Who is carrying it comes last ──
), //
child: _RouteOutline( // The Miler's card sat directly under the head, between the
booking: booking, // reference and the journey, which put a person between the
slot: slot, // customer and the answer they opened the screen for. It is real
focused: focused, // and it is reachable — a name, a plate, Call and Message — but it
), // is *who*, and the screen's question is *where*. The journey has
), // the middle of the page now and the person is at the foot of it,
// one thumb away.
if (milerEnRoute)
_MilerCard(person: booking.miler!),
if (!cancelled &&
stage == JourneyStage.outForDelivery &&
booking.deliveryAgent != null)
_MilerCard(person: booking.deliveryAgent!),
// Stands in for backend push updates. Real stage changes arrive // Stands in for backend push updates. Real stage changes arrive
// from `GET /customer/bookings/{reference}`, so this exists only // from `GET /customer/bookings/{reference}`, so this exists only
@@ -218,20 +150,16 @@ class TrackingScreen extends StatelessWidget {
], ],
), ),
), ),
// No bed under the actions. `edge: true` painted a white bar with a
// hairline across the bottom, which on this canvas reads as a second
// surface holding two buttons — and the buttons already have edges.
bottomNavigationBar: DmFooter( bottomNavigationBar: DmFooter(
edge: true,
children: _footerActions(context, app, booking), children: _footerActions(context, app, booking),
), ),
); );
} }
/// How far along the route the Miler is drawn, by stage. /// How far along the route the Miler is drawn, by stage.
static double _routeProgress(JourneyStage stage) => switch (stage) {
JourneyStage.assigned => 0.08,
JourneyStage.onTheWay => 0.42,
JourneyStage.arrived || JourneyStage.pickedUp => 0.92,
_ => 0.5,
};
/// The footer is a hierarchy, not a single button: the helpful action is the /// The footer is a hierarchy, not a single button: the helpful action is the
/// filled one and cancelling is a text button under it. Cancel used to be a /// filled one and cancelling is a text button under it. Cancel used to be a
@@ -260,10 +188,15 @@ class TrackingScreen extends StatelessWidget {
} }
return [ return [
// Filled, not outlined. Sharing a live link is the only thing a
// customer can *do* from this screen while the parcel is moving, and an
// outlined button on a page of white cards was the quietest object on
// it. Cancelling stays a text action, which is the right weight for
// something you should not reach for by accident.
DmButton( DmButton(
label: 'Share live tracking', label: 'Share live tracking',
icon: LucideIcons.share2, icon: LucideIcons.share2,
kind: DmButtonKind.outline, iconLeading: true,
onPressed: () => DmToast.show(context, 'Sharing is coming soon'), onPressed: () => DmToast.show(context, 'Sharing is coming soon'),
), ),
// Backend policy decides whether cancelling is still possible. // Backend policy decides whether cancelling is still possible.
@@ -290,25 +223,21 @@ class TrackingScreen extends StatelessWidget {
} }
} }
/// The screen's one loud surface: what is happening, right now. /// The state, the route, and the shipment's own figures.
/// ///
/// ── Why this is a filled card and not a heading ── /// ── Card → bare type → this ──
/// ///
/// The status used to be an eyebrow, a 32pt headline and a row of chips laid /// The bordered card repeated the milestone the journey below it already
/// straight onto the canvas. It said the right words, but it looked like every /// listed. Stripping it to type fixed that and left the top of the screen with
/// other heading in the app, so the screen opened with no focal point and the /// nothing on it but two words and two places.
/// map below it — a decoration — became the loudest thing on the page.
/// ///
/// It is now the one crimson card the design allows per screen. It carries the /// The reference's answer is better than both: the state stays the hero, but
/// live pulse, the headline, the journey's own progress and the window, so the /// it earns a row of its own with the reference and a priority mark beside it,
/// whole answer is in one object above the fold. A cancelled booking drops to a /// the promise sits under it, and the route becomes a card carrying the two
/// plain card: nothing is live, and a crimson field would keep insisting it is. /// ends *and* the three figures a customer checks while waiting — how far,
class _StatusHeader extends StatelessWidget { /// what is carrying it, how heavy.
const _StatusHeader({ class _TrackCard extends StatelessWidget {
required this.booking, const _TrackCard({required this.booking, required this.slot, this.focused});
required this.slot,
this.focused,
});
final Booking booking; final Booking booking;
final PickupSlot? slot; final PickupSlot? slot;
@@ -320,140 +249,225 @@ class _StatusHeader extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cancelled = booking.isCancelled; final cancelled = booking.isCancelled;
final stage = focused?.stage ?? booking.stage; final stage = focused?.stage ?? booking.stage;
final group = focused ?? booking.destinations.first;
final headline = switch (true) { // What the customer can act on: when, before the handover; where, after.
_ when cancelled => 'Booking cancelled', final when = switch (true) {
_ when stage == JourneyStage.booked => 'Finding your Miler', _ when cancelled => 'You were not charged',
_ when stage == JourneyStage.assigned && booking.miler != null => _ when booking.milerEtaMinutes != null && booking.milerEtaMinutes! > 0 =>
'${booking.miler!.name} is assigned', 'Arriving in about ${booking.milerEtaMinutes} min',
_ when stage == JourneyStage.onTheWay && booking.miler != null => _ when stage == JourneyStage.arrived => 'At your door now',
booking.milerEtaMinutes == null _ when stage.index <= JourneyStage.assigned.index && slot != null =>
? '${booking.miler!.name} is on the way' 'Today · ${slot!.window}',
: '${booking.miler!.name} is ${booking.milerEtaMinutes} minutes away', _ when booking.expectedDelivery != null =>
_ when stage == JourneyStage.arrived && booking.miler != null => 'Expected ${booking.expectedDelivery}',
'${booking.miler!.name} is at your door', _ => null,
_ => stage.headline,
}; };
final sub = cancelled final eta = booking.milerEtaMinutes;
? 'This pickup was cancelled. You were not charged.' final reference = focused?.trackingId ?? booking.displayId;
: stage.sub;
final total = CustomerMilestone.values.length; // Only figures the backend actually sent. A strip with one cell in it is
final step = CustomerMilestone.of(stage).index + 1; // a fact centred in a grey band, which is worse than the fact on its own
// — so it is drawn at two or more.
if (cancelled) { final figures = <Widget>[
return Padding( if (booking.fare?.routeKm != null)
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10), _Figure(
child: DmCard( label: 'Distance',
children: [ value: '${booking.fare!.routeKm!.toStringAsFixed(1)} km',
DmCardCell(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const DmPill('Cancelled', tone: DmPillTone.off),
const SizedBox(height: 12),
Text(headline, style: DmText.display.copyWith(fontSize: 25)),
const SizedBox(height: 6),
Text(sub, style: DmText.small),
],
),
),
],
), ),
); _Figure(
} label: booking.totalPackages == 1 ? 'Package' : 'Packages',
value: '${booking.totalPackages}',
),
if (booking.totalWeightLabel != null)
_Figure(label: 'Weight', value: booking.totalWeightLabel!)
else if (booking.miler?.vehicleType != null)
_Figure(label: 'Vehicle', value: booking.miler!.vehicleType!)
else if (slot != null)
_Figure(label: 'Window', value: slot!.window),
];
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10), padding: const EdgeInsets.fromLTRB(DmSpace.pad, 2, DmSpace.pad, 14),
child: DmCard( child: Column(
tone: DmCardTone.fill, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
DmCardCell( Row(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), children: [
child: Column( DmStatusChip(
crossAxisAlignment: CrossAxisAlignment.start, label: reference.startsWith('#') ? reference : '#$reference',
children: [ tone: cancelled ? DmColors.ink4 : DmColors.brand,
Row( quiet: cancelled,
children: [ ),
const DmLiveDot(size: 9, color: Colors.white), // No priority chip. The reference carries "EXPRESS PRIORITY"
const SizedBox(width: 4), // beside the reference; this backend has no express tier, and a
Text( // badge that every booking wears is decoration.
'LIVE',
style: DmText.eyebrow.copyWith( ],
fontSize: 11, ),
color: Colors.white, const SizedBox(height: 14),
), Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
cancelled ? 'Cancelled' : stage.milestoneLabel,
style: DmText.headerTitle.copyWith(
fontSize: 29,
color: cancelled ? DmColors.ink3 : DmColors.ink,
),
),
),
if (eta != null && eta > 0 && !cancelled) ...[
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.fromLTRB(11, 7, 11, 8),
decoration: BoxDecoration(
color: DmColors.brand,
borderRadius: DmRadius.all(DmRadius.chip),
),
child: Text(
'ETA $eta mins',
style: DmText.label.copyWith(
fontSize: 12.5,
color: Colors.white,
), ),
const Spacer(),
if (focused != null)
Flexible(
child: Text(
'To ${focused!.destination.label}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: DmText.small.copyWith(
color: DmColors.onBrand3,
),
),
),
],
),
const SizedBox(height: 12),
Text(
headline,
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: 16),
DmStepBar(
progress: step / total,
steps: total,
current: step,
onFill: true,
caption: false,
),
], ],
],
),
if (when != null) ...[
const SizedBox(height: 5),
Text(
when,
style: DmText.body.copyWith(fontSize: 14.5, color: DmColors.ink3),
), ),
],
const SizedBox(height: 16),
DmCard(
raised: !cancelled,
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _Place(
label: 'ORIGIN',
value: booking.pickup.title,
note: booking.pickup.sub,
),
),
const SizedBox(width: 16),
Expanded(
child: _Place(
label: 'DESTINATION',
value: group.destination.shortLabel,
note: group.destination.stateName,
end: true,
),
),
],
),
),
if (figures.length > 1)
DmCardCell(
padding: const EdgeInsets.fromLTRB(4, 12, 4, 13),
// `groove`, not `surfaceAlt`: the latter is within a point
// of the canvas, so the strip read as spilling out of the
// bottom of the card rather than as a band inside it.
background: DmColors.groove,
child: Row(children: figures),
),
],
), ),
], ],
), ),
); );
} }
}
/// The one fact under the rail: whatever is most useful at this stage. /// A labelled place. Two of these are the route.
String _footnote(JourneyStage stage) { class _Place extends StatelessWidget {
if (booking.milerEtaMinutes != null && booking.milerEtaMinutes! > 0) { const _Place({
return 'Arriving in about ${booking.milerEtaMinutes} min'; required this.label,
} required this.value,
if (stage == JourneyStage.arrived) return 'Waiting at your door'; this.note,
if (stage.index <= JourneyStage.assigned.index && slot != null) { this.end = false,
return '${slot!.day}, ${slot!.window}'; });
}
if (booking.expectedDelivery != null) { final String label;
return 'Expected ${booking.expectedDelivery}'; final String value;
} final String? note;
return CustomerMilestone.of(stage).label;
/// The destination, which aligns to the card's right edge so the two ends
/// read as two ends rather than as two columns of a table.
final bool end;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment:
end ? CrossAxisAlignment.end : CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: DmText.eyebrow),
const SizedBox(height: 5),
Text(
value,
textAlign: end ? TextAlign.right : TextAlign.left,
style: DmText.cardTitle.copyWith(fontSize: 17),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (note != null && note!.isNotEmpty)
Text(
note!,
textAlign: end ? TextAlign.right : TextAlign.left,
style: DmText.small.copyWith(fontSize: 12.5, color: DmColors.ink3),
// Two lines. A pickup note is a locality and a pincode, and at one
// line it rendered "Gandhipuram, Coimbat…" — which is the half of
// it a customer already knows.
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
);
}
}
/// One of the three figures in the strip under the route.
class _Figure extends StatelessWidget {
const _Figure({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label.toUpperCase(),
style: DmText.eyebrow.copyWith(fontSize: 9.5),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
Text(
value,
style: DmText.cardTitle.copyWith(fontSize: 14.5),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
} }
} }
@@ -473,130 +487,94 @@ class _Journey extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return DmMilestones( return DmMilestones(
booking: booking, booking: booking,
title: booking.trackingId == null ? 'Journey' : 'Live journey', title: 'Trip milestones',
showStep: true,
); );
} }
} }
/// A chip floating on the map — "En route, 1.4 km away". /// Who is collecting, and how to reach them.
class _Badge extends StatelessWidget {
const _Badge({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(10, 7, 12, 7),
decoration: BoxDecoration(
color: DmColors.surface,
border: Border.all(color: DmColors.border),
borderRadius: DmRadius.all(6),
boxShadow: DmShadow.float,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: DmColors.brand),
const SizedBox(width: 6),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.label.copyWith(fontSize: 12, color: DmColors.ink),
),
),
],
),
);
}
}
/// Radar shown while the backend matches a Miler.
/// 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 /// ── Card → bare row → card ──
/// person rather than a parcel, and the tint is what separates "who" from the ///
/// facts stacked above and below it. The plate is mono — it is read off a /// The first card was a bordered box with an avatar, a name, a plate tag and
/// number plate at the door. /// a two-cell footer holding a filled crimson Call and an outlined Message —
/// six objects to say "Arun is coming, here is his number". Stripping it to a
/// row under a hairline fixed the clutter and lost the person: the one human
/// being in the whole flow became the quietest thing on the screen.
///
/// It is a lifted card again, with the contents the flat pass arrived at. The
/// avatar keeps its crimson disc — this is the one place in the app standing
/// in for a person, which is the job the solid tone was written for — and the
/// two actions are a filled Call beside an outlined Message, because a phone
/// number is what this card is *for*.
///
/// "Your Miler" / "Delivery Miler" stayed gone. The two are shown at mutually
/// exclusive stages, so the label distinguished between things that are never
/// on the screen together.
class _MilerCard extends StatelessWidget { class _MilerCard extends StatelessWidget {
const _MilerCard({required this.person, required this.role}); const _MilerCard({required this.person});
final Person person; final Person person;
final String role;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10), padding: const EdgeInsets.fromLTRB(DmSpace.pad, 6, DmSpace.pad, 10),
child: DmCard( child: DmCard(
tone: DmCardTone.wash, raised: true,
children: [ children: [
DmCardCell( DmCardCell(
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13), padding: const EdgeInsets.fromLTRB(16, 15, 16, 15),
child: Row( child: Row(
children: [ children: [
DmAvatar(initials: person.initials, size: 44), DmAvatar(initials: person.initials, size: 46),
const SizedBox(width: 13), const SizedBox(width: 14),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(person.name, style: DmText.cardTitle), Text(
const SizedBox(height: 2), person.name,
// Absent unless the backend sent a rating or a pickup style: DmText.cardTitle.copyWith(fontSize: 16.5),
// count. A default would put an invented reputation on a maxLines: 1,
// real person's card. overflow: TextOverflow.ellipsis,
if (person.credentials != null) ),
Row( const SizedBox(height: 3),
children: [ // ── Reputation, then the plate ──
const Icon( //
LucideIcons.star, // The reference puts "★ 4.9 · 1,240 deliveries" under
size: 12, // the name, which is the line that makes a stranger at
color: DmColors.brand, // your door a person somebody has vouched for. It is
), // shown only when the backend actually sent a rating or
const SizedBox(width: 5), // a count — a default here would be an invented
Flexible( // reputation on a real person — and the plate takes the
child: Text( // line whenever it did not.
person.credentials!, if (person.rating != null || person.trips != null)
maxLines: 1, _Credentials(person: person)
overflow: TextOverflow.ellipsis,
style: DmText.tiny,
),
),
],
)
else else
Text(role, style: DmText.tiny), Text(
person.vehicle,
style: DmText.monoSm.copyWith(color: DmColors.ink3),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
], ],
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
ConstrainedBox( _Reach(
constraints: const BoxConstraints(maxWidth: 122),
child: DmTag(person.vehicle, mono: true),
),
],
),
),
DmCardCell(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 13),
child: Row(
children: [
DmMiniButton(
label: 'Call',
icon: LucideIcons.phone, icon: LucideIcons.phone,
tooltip: 'Call ${person.name}',
filled: true, filled: true,
onPressed: () => onPressed: () =>
DmToast.show(context, 'Calling ${person.name}…'), DmToast.show(context, 'Calling ${person.name}…'),
), ),
const SizedBox(width: 8), const SizedBox(width: 9),
DmMiniButton( _Reach(
label: 'Message',
icon: LucideIcons.messageSquare, icon: LucideIcons.messageSquare,
tooltip: 'Message ${person.name}',
onPressed: () => onPressed: () =>
DmToast.show(context, 'Chat is not available yet'), DmToast.show(context, 'Chat is not available yet'),
), ),
@@ -609,57 +587,66 @@ class _MilerCard extends StatelessWidget {
} }
} }
/// Where it is going, and the few facts worth keeping on screen. /// Call and Message, as two discs.
class _RouteOutline extends StatelessWidget { ///
const _RouteOutline({ /// They were full-width buttons in a second cell, which gave a phone call the
required this.booking, /// same footprint as the screen's primary action. A round target beside the
required this.slot, /// name is the shape every app that has ever put a courier on a screen
this.focused, /// arrived at, and it costs a third of the height.
class _Reach extends StatefulWidget {
const _Reach({
required this.icon,
required this.tooltip,
required this.onPressed,
this.filled = false,
}); });
final Booking booking; final IconData icon;
final PickupSlot? slot; final String tooltip;
final DestinationGroup? focused; final VoidCallback onPressed;
final bool filled;
@override
State<_Reach> createState() => _ReachState();
}
class _ReachState extends State<_Reach> {
bool _down = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final group = focused; return Tooltip(
final recipient = (group ?? booking.destinations.first).details.recipient; message: widget.tooltip,
child: Semantics(
return DmCard( button: true,
children: [ label: widget.tooltip,
DmCardCell( child: GestureDetector(
child: DmRouteRail( behavior: HitTestBehavior.opaque,
pickup: booking.pickup.title, onTapDown: (_) => setState(() => _down = true),
pickupNote: booking.pickup.sub, onTapUp: (_) => setState(() => _down = false),
drop: group != null onTapCancel: () => setState(() => _down = false),
? group.destination.label onTap: widget.onPressed,
: booking.isMultiDestination child: AnimatedScale(
? '${booking.destinations.length} destinations' scale: _down ? 0.92 : 1,
: booking.destination.label, duration: DmMotion.fast,
dropNote: group != null curve: DmMotion.ease,
? '${group.packageCount} ' child: Container(
'${group.packageCount == 1 ? 'package' : 'packages'}' width: 44,
: booking.isMultiDestination height: 44,
? booking.packageSummary alignment: Alignment.center,
: booking.details.exactAddress ?? decoration: BoxDecoration(
(recipient == null color: widget.filled ? DmColors.brand : DmColors.surfaceAlt,
? 'Exact address collected at pickup' shape: BoxShape.circle,
: 'Recipient · $recipient'), ),
child: Icon(
widget.icon,
size: 19,
color: widget.filled ? Colors.white : DmColors.ink,
),
),
), ),
), ),
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}',
),
],
); );
} }
} }
@@ -854,3 +841,42 @@ class _StepButton extends StatelessWidget {
); );
} }
} }
/// "★ 4.9 · 1,240 deliveries · TN 37 BX 4412", minus whatever is missing.
class _Credentials extends StatelessWidget {
const _Credentials({required this.person});
final Person person;
@override
Widget build(BuildContext context) {
final rest = [
if (person.trips != null)
'${person.trips} ${person.trips == 1 ? 'delivery' : 'deliveries'}',
if (person.vehicle.isNotEmpty) person.vehicle,
].join(' · ');
return Row(
children: [
if (person.rating != null) ...[
const Icon(LucideIcons.star, size: 12, color: DmColors.brand),
const SizedBox(width: 4),
Text(
person.rating!.toStringAsFixed(1),
style: DmText.label.copyWith(fontSize: 12.5, color: DmColors.ink),
),
if (rest.isNotEmpty) const SizedBox(width: 7),
],
if (rest.isNotEmpty)
Flexible(
child: Text(
rest,
style: DmText.small.copyWith(fontSize: 12.5, color: DmColors.ink3),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
}

View File

@@ -27,13 +27,22 @@ class DmColors {
// ── Brand ──────────────────────────────────────────────────────────────── // ── Brand ────────────────────────────────────────────────────────────────
/// The mark's single colour. /// The mark's single colour.
///
/// Briefly brightened to a vermilion on 2026-09-23 to match a reference
/// design and put straight back. The palette is not up for redesign — the
/// reference's *layout* is what the app is being drawn against, and the
/// oxblood carries it perfectly well.
static const brand = Color(0xFF8F0F06); static const brand = Color(0xFF8F0F06);
/// Pressed state, and crimson text sitting on a [brandSoft] wash where the /// Pressed state, and brand text sitting on a [brandSoft] wash where the
/// full brand would vibrate against the tint. /// full brand would vibrate against the tint.
static const brandPress = Color(0xFF730C05); static const brandPress = Color(0xFF730C05);
static const brandDeep = brandPress; static const brandDeep = brandPress;
/// The lit edge of the sphere and of the primary button's gradient. Named
/// rather than repeated as a literal in three files.
static const brandLit = Color(0xFFA6180B);
/// Tint behind selected options, wash cards and brand chips. /// Tint behind selected options, wash cards and brand chips.
static const brandSoft = Color(0xFFFBF3F2); static const brandSoft = Color(0xFFFBF3F2);
@@ -48,13 +57,25 @@ class DmColors {
// ── Surfaces ───────────────────────────────────────────────────────────── // ── Surfaces ─────────────────────────────────────────────────────────────
/// The page canvas. /// The page canvas.
static const canvas = Color(0xFFF7F6F4); ///
/// Deepened on 2026-09-23. At `#F7F6F4` a white card on it was a 3% step,
/// so the 1px border was doing all the separating and every screen read as
/// a list of outlined boxes. A canvas the cards can sit *on* is what lets
/// the edge go quiet.
static const canvas = Color(0xFFF1F0EC);
static const surface = Color(0xFFFFFFFF); static const surface = Color(0xFFFFFFFF);
/// Filled inputs, quiet chips, tiles, skeleton bases. Also the "sunken" /// Filled inputs, quiet chips, tiles, skeleton bases. Also the "sunken"
/// treatment for a disabled primary action. /// treatment for a disabled primary action.
static const surfaceAlt = Color(0xFFF1EFEC); static const surfaceAlt = Color(0xFFF1EFEC);
/// The bed a segmented control's chosen segment is raised out of.
///
/// A step below [surfaceAlt], which is within a point of the canvas and so
/// drew no groove at all on a sheet. A segment only reads as *raised* if
/// there is something visible for it to be raised out of.
static const groove = Color(0xFFE7E4E0);
/// Barely-there divider between rows inside one card. /// Barely-there divider between rows inside one card.
static const hairline = Color(0xFFF1EFEC); static const hairline = Color(0xFFF1EFEC);
@@ -111,7 +132,15 @@ class DmRadius {
static const sm = 12.0; static const sm = 12.0;
/// Cards and every panel that holds rows. /// Cards and every panel that holds rows.
static const md = 14.0; static const md = 18.0;
/// A card that stands alone on the canvas rather than in a stack of rows.
/// Wider than [md] because a shadowed card with no edge needs the corner to
/// carry the shape the border used to.
static const card = 20.0;
/// A chip: a status pill, a tier badge, a stat tile's glyph well.
static const chip = 8.0;
/// Sheets' grabber area, the segmented control, the header's bottom sweep. /// Sheets' grabber area, the segmented control, the header's bottom sweep.
static const lg = 16.0; static const lg = 16.0;
@@ -137,11 +166,14 @@ class DmRadius {
class DmSpace { class DmSpace {
DmSpace._(); DmSpace._();
/// Horizontal page padding. 22 on the brand header's body, 24 on canvas /// Horizontal page padding.
/// content — the header's crimson field reads as tighter than it measures, ///
/// so it takes the smaller inset. /// 24 once, when the header was a crimson field and the canvas held loose
static const pad = 24.0; /// rows. On a page of white cards it left a broad margin down both sides and
static const headerPad = 22.0; /// the cards read as narrower than the screen — the reference designs this
/// was drawn against run their cards to within about a finger of the edge.
static const pad = 16.0;
static const headerPad = 16.0;
/// Minimum touch target. /// Minimum touch target.
static const tap = 48.0; static const tap = 48.0;
@@ -176,6 +208,53 @@ class DmShadow {
), ),
]; ];
/// A card resting on the canvas.
///
/// ── Why the cards stopped having edges ──
///
/// Every surface in this app was separated by a 1px hairline, which is
/// honest and cheap and, repeated eight times down a screen, reads as a
/// wireframe: the outline is the loudest thing about an object whose whole
/// job is to be quiet and hold something else.
///
/// Two very soft layers do it instead — a wide, faint ambient wash and a
/// short contact shadow just under the shape. Neither is visible as a
/// shadow; together they lift the white off the warm canvas so the card is
/// read as *nearer* rather than as *outlined*. That is the difference
/// between a premium consumer surface and a bordered panel, and it costs
/// nothing but two `BoxShadow`s.
static const card = [
BoxShadow(
color: Color(0x0D1F1A17),
blurRadius: 22,
spreadRadius: -10,
offset: Offset(0, 8),
),
BoxShadow(
color: Color(0x081F1A17),
blurRadius: 3,
spreadRadius: -1,
offset: Offset(0, 1),
),
];
/// A card the customer has to act on, or the one live thing in a list.
/// The same two layers, a third again as deep.
static const cardRaised = [
BoxShadow(
color: Color(0x141F1A17),
blurRadius: 30,
spreadRadius: -12,
offset: Offset(0, 10),
),
BoxShadow(
color: Color(0x0B1F1A17),
blurRadius: 4,
spreadRadius: -1,
offset: Offset(0, 2),
),
];
/// Controls that genuinely float over content — map buttons, map chips. /// Controls that genuinely float over content — map buttons, map chips.
static const float = [ static const float = [
BoxShadow( BoxShadow(
@@ -250,18 +329,33 @@ class DmMotion {
class DmText { class DmText {
DmText._(); DmText._();
static const _f = 'Poppins'; /// ── Switzer → Plus Jakarta Sans (2026-09-24) ──
///
/// Both are variable neo-grotesques and the swap is a one-line change, but
/// two things come with it. Plus Jakarta's humanist curves and taller
/// x-height give the place names the editorial weight the reference design
/// has, and — the practical part — **it carries the rupee glyph**, which
/// Switzer does not. Every amount in this app was set in Geist Mono for that
/// reason alone; the ones that are prices rather than data can now be type.
///
/// Its axis is wght 200–800, so every weight the design uses is real rather
/// than synthesised.
static const _f = 'PlusJakartaSans';
static const _mono = 'GeistMono'; static const _mono = 'GeistMono';
// The weight axis, for Geist Mono only. It ships as one variable file, so a // The weight axis. Switzer and Geist Mono both ship as one variable file, so
// style has to move `wght` to change weight — `fontWeight` alone picks the // 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 // nearest named instance and, on a single-file VF, that is always the
// default. Both are set on its styles: the axis does the work, the enum keeps // default. Both are set on every style: the axis does the work, the enum
// the semantics (and the system-font fallback) correct. // keeps the semantics (and the system-font fallback) correct.
// //
// Poppins needs none of this. It is four static files and Flutter matches // This came back when Poppins left. Poppins has no variable release, so for
// them on `fontWeight`. // one release the sans styles carried `fontWeight` alone and this block was
// the mono's private business.
static const _w500 = [FontVariation('wght', 500)]; 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 // 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 // size, because -0.035em is a different number of pixels on a 31pt display
@@ -273,22 +367,26 @@ class DmText {
/// Screen-dominating statement — "Pickup booked", "Arun is 12 minutes away". /// Screen-dominating statement — "Pickup booked", "Arun is 12 minutes away".
static const display = TextStyle( static const display = TextStyle(
fontFamily: _f, fontSize: 31, height: 1.1, fontFamily: _f, fontSize: 31, height: 1.1,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800, fontVariations: _w800,
letterSpacing: -0.95, color: DmColors.ink, letterSpacing: -1.1, color: DmColors.ink,
); );
/// The brand header's own title, sitting on crimson. /// The largest line on a screen: the name on Home, the title of a tab root.
///
/// It was white, because it was written for a crimson header. That header is
/// a canvas now and every caller was overriding the colour back to ink, so
/// the white was a trap waiting for the first caller who did not.
static const headerTitle = TextStyle( static const headerTitle = TextStyle(
fontFamily: _f, fontSize: 34, height: 1.0, fontFamily: _f, fontSize: 34, height: 1.0,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800, fontVariations: _w800,
letterSpacing: -1.2, color: Colors.white, letterSpacing: -1.4, color: DmColors.ink,
); );
/// Screen headings on a canvas. /// Screen headings on a canvas.
static const title = TextStyle( static const title = TextStyle(
fontFamily: _f, fontSize: 25, height: 1.16, fontFamily: _f, fontSize: 25, height: 1.16,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800, fontVariations: _w800,
letterSpacing: -0.65, color: DmColors.ink, letterSpacing: -0.8, color: DmColors.ink,
); );
/// Tab-root page titles where no brand header is used. /// Tab-root page titles where no brand header is used.
@@ -297,14 +395,14 @@ class DmText {
/// The heading above a group of cards, and a sheet's own title. /// The heading above a group of cards, and a sheet's own title.
static const heading = TextStyle( static const heading = TextStyle(
fontFamily: _f, fontSize: 18, height: 1.25, fontFamily: _f, fontSize: 18, height: 1.25,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700, fontVariations: _w700,
letterSpacing: -0.4, color: DmColors.ink, letterSpacing: -0.45, color: DmColors.ink,
); );
static const sheetTitle = TextStyle( static const sheetTitle = TextStyle(
fontFamily: _f, fontSize: 21, height: 1.2, fontFamily: _f, fontSize: 21, height: 1.2,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800, fontVariations: _w800,
letterSpacing: -0.55, color: DmColors.ink, letterSpacing: -0.65, color: DmColors.ink,
); );
// ── Rows ───────────────────────────────────────────────────────────────── // ── Rows ─────────────────────────────────────────────────────────────────
@@ -312,8 +410,8 @@ class DmText {
/// A row's own line — `t1` in the design, and the workhorse of the app. /// A row's own line — `t1` in the design, and the workhorse of the app.
static const cardTitle = TextStyle( static const cardTitle = TextStyle(
fontFamily: _f, fontSize: 15, height: 1.4, fontFamily: _f, fontSize: 15, height: 1.4,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600, fontVariations: _w600,
letterSpacing: -0.15, color: DmColors.ink, letterSpacing: -0.2, color: DmColors.ink,
); );
/// Alias: the same weight of statement, used mid-form. /// Alias: the same weight of statement, used mid-form.
@@ -321,55 +419,55 @@ class DmText {
static const body = TextStyle( static const body = TextStyle(
fontFamily: _f, fontSize: 15, height: 1.5, fontFamily: _f, fontSize: 15, height: 1.5,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500, fontVariations: _w500,
letterSpacing: -0.1, color: DmColors.ink, letterSpacing: -0.12, color: DmColors.ink,
); );
/// Supporting line under a row title — `t2`. /// Supporting line under a row title — `t2`.
static const small = TextStyle( static const small = TextStyle(
fontFamily: _f, fontSize: 13.5, height: 1.45, fontFamily: _f, fontSize: 13.5, height: 1.45,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500, fontVariations: _w500,
color: DmColors.ink2, color: DmColors.ink2,
); );
/// The quietest line — `t3`. Timestamps, counts, "collected on". /// The quietest line — `t3`. Timestamps, counts, "collected on".
static const tiny = TextStyle( static const tiny = TextStyle(
fontFamily: _f, fontSize: 13, height: 1.4, fontFamily: _f, fontSize: 13, height: 1.4,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500, fontVariations: _w500,
color: DmColors.ink3, color: DmColors.ink3,
); );
/// Paragraph under a screen heading. /// Paragraph under a screen heading.
static const lede = TextStyle( static const lede = TextStyle(
fontFamily: _f, fontSize: 14.5, height: 1.55, fontFamily: _f, fontSize: 14.5, height: 1.55,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500, fontVariations: _w500,
color: DmColors.ink2, color: DmColors.ink2,
); );
/// The right-hand value in a settings row. /// The right-hand value in a settings row.
static const value = TextStyle( static const value = TextStyle(
fontFamily: _f, fontSize: 13.5, height: 1.4, fontFamily: _f, fontSize: 13.5, height: 1.4,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600, fontVariations: _w600,
color: DmColors.ink2, color: DmColors.ink2,
); );
/// Field labels, tags, chips, inline actions. /// Field labels, tags, chips, inline actions.
static const label = TextStyle( static const label = TextStyle(
fontFamily: _f, fontSize: 12.5, height: 1.3, fontFamily: _f, fontSize: 12.5, height: 1.3,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600, fontVariations: _w600,
color: DmColors.ink2, color: DmColors.ink2,
); );
static const button = TextStyle( static const button = TextStyle(
fontFamily: _f, fontSize: 16, height: 1.2, fontFamily: _f, fontSize: 16, height: 1.2,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700, fontVariations: _w700,
letterSpacing: -0.2, letterSpacing: -0.25,
); );
/// The text-only action under a primary button ("Back to home"). /// The text-only action under a primary button ("Back to home").
static const textButton = TextStyle( static const textButton = TextStyle(
fontFamily: _f, fontSize: 14.5, height: 1.2, fontFamily: _f, fontSize: 14.5, height: 1.2,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600, fontVariations: _w600,
color: DmColors.ink2, color: DmColors.ink2,
); );
@@ -377,22 +475,22 @@ class DmText {
/// it heads brand content, [DmColors.ink3] when it heads a neutral group. /// it heads brand content, [DmColors.ink3] when it heads a neutral group.
static const eyebrow = TextStyle( static const eyebrow = TextStyle(
fontFamily: _f, fontSize: 10.5, height: 1.3, fontFamily: _f, fontSize: 10.5, height: 1.3,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600, fontVariations: _w600,
letterSpacing: 1.16, color: DmColors.ink3, letterSpacing: 1.16, color: DmColors.ink3,
); );
/// The eyebrow as it appears on the crimson header. /// The eyebrow as it appears on the crimson header.
static const headerEyebrow = TextStyle( static const headerEyebrow = TextStyle(
fontFamily: _f, fontSize: 11.5, height: 1.3, fontFamily: _f, fontSize: 11.5, height: 1.3,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700, fontVariations: _w700,
letterSpacing: 1.84, color: DmColors.onBrand3, letterSpacing: 1.84, color: DmColors.onBrand3,
); );
/// Sentence-case heading above a group of rows. /// Sentence-case heading above a group of rows.
static const sectionHead = TextStyle( static const sectionHead = TextStyle(
fontFamily: _f, fontSize: 15, height: 1.3, fontFamily: _f, fontSize: 15, height: 1.3,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700, fontVariations: _w700,
letterSpacing: -0.25, color: DmColors.ink, letterSpacing: -0.3, color: DmColors.ink,
); );
/// Alias kept for existing call sites. /// Alias kept for existing call sites.
@@ -401,7 +499,7 @@ class DmText {
/// Field input text. /// Field input text.
static const field = TextStyle( static const field = TextStyle(
fontFamily: _f, fontSize: 16, height: 1.3, fontFamily: _f, fontSize: 16, height: 1.3,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600, fontVariations: _w600,
color: DmColors.ink, color: DmColors.ink,
); );
@@ -431,7 +529,7 @@ class DmText {
/// The OTP digits. /// The OTP digits.
static const otp = TextStyle( static const otp = TextStyle(
fontFamily: _f, fontSize: 24, height: 1.1, fontFamily: _f, fontSize: 24, height: 1.1,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600, fontVariations: _w600,
color: DmColors.ink, color: DmColors.ink,
); );
} }

View File

@@ -60,7 +60,8 @@ class DmBookOrb extends StatefulWidget {
const DmBookOrb({ const DmBookOrb({
super.key, super.key,
required this.onTap, required this.onTap,
this.label = 'BOOK', this.label = 'BOOK NOW',
this.strapline = 'INSTANT PARCEL',
this.caption, this.caption,
this.held = false, this.held = false,
}); });
@@ -80,6 +81,9 @@ class DmBookOrb extends StatefulWidget {
/// second use of this widget elsewhere is not forced to repeat itself. /// second use of this widget elsewhere is not forced to repeat itself.
final String? caption; final String? caption;
/// The small line inside the sphere, under the label.
final String strapline;
@override @override
State<DmBookOrb> createState() => _DmBookOrbState(); State<DmBookOrb> createState() => _DmBookOrbState();
} }
@@ -117,8 +121,8 @@ class _DmBookOrbState extends State<DmBookOrb> with TickerProviderStateMixin {
return Semantics( return Semantics(
button: true, button: true,
label: widget.caption == null label: widget.caption == null
? widget.label ? '${widget.label}. ${widget.strapline}'
: '${widget.label}. ${widget.caption}', : '${widget.label}. ${widget.strapline}. ${widget.caption}',
child: Column( child: Column(
children: [ children: [
GestureDetector( GestureDetector(
@@ -160,7 +164,11 @@ class _DmBookOrbState extends State<DmBookOrb> with TickerProviderStateMixin {
scale: Tween(begin: 1.0, end: 1.012).animate( scale: Tween(begin: 1.0, end: 1.012).animate(
CurvedAnimation(parent: _breath, curve: Curves.easeInOut), CurvedAnimation(parent: _breath, curve: Curves.easeInOut),
), ),
child: _Sphere(size: disc, label: widget.label), child: _Sphere(
size: disc,
label: widget.label,
strapline: widget.strapline,
),
), ),
), ),
], ],
@@ -184,10 +192,15 @@ class _DmBookOrbState extends State<DmBookOrb> with TickerProviderStateMixin {
/// The lit body. Four layers, all obeying one light from the upper left: /// The lit body. Four layers, all obeying one light from the upper left:
/// gradient fill, specular bloom, rim, then the word. /// gradient fill, specular bloom, rim, then the word.
class _Sphere extends StatelessWidget { class _Sphere extends StatelessWidget {
const _Sphere({required this.size, required this.label}); const _Sphere({
required this.size,
required this.label,
required this.strapline,
});
final double size; final double size;
final String label; final String label;
final String strapline;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -201,7 +214,7 @@ class _Sphere extends StatelessWidget {
// takes its light from. // takes its light from.
center: Alignment(-0.38, -0.52), center: Alignment(-0.38, -0.52),
radius: 1.08, radius: 1.08,
colors: [Color(0xFFC0230F), DmColors.brand, Color(0xFF5C0802)], colors: [DmColors.brandLit, DmColors.brand, Color(0xFF5C0802)],
stops: [0.0, 0.5, 1.0], stops: [0.0, 0.5, 1.0],
), ),
boxShadow: [ boxShadow: [
@@ -256,28 +269,52 @@ class _Sphere extends StatelessWidget {
), ),
), ),
), ),
// ── The face ──
//
// It was "BOOK / NOW" stacked at 30pt in caps with 3pt of tracking,
// which filled the sphere edge to edge and read as a warning label.
// The reference sets a glyph, a sentence-case line and a small
// strapline — the same three parts every physical button that has
// ever been designed well carries, and the air round them is what
// makes the circle read as an object rather than as a word in a
// red disc.
Column( Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(
LucideIcons.navigation,
size: 21,
color: Colors.white,
),
const SizedBox(height: 11),
Text( Text(
label, label,
textAlign: TextAlign.center,
style: DmText.heading.copyWith( style: DmText.heading.copyWith(
fontSize: 32, fontSize: 23,
height: 1, height: 1.05,
letterSpacing: 5, letterSpacing: -0.5,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w700,
fontVariations: const [FontVariation('wght', 800)], fontVariations: const [FontVariation('wght', 700)],
color: Colors.white, color: Colors.white,
shadows: const [ shadows: const [
Shadow(color: Color(0x4D5C0802), blurRadius: 12, offset: Offset(0, 2)), Shadow(
color: Color(0x4D5C0802),
blurRadius: 12,
offset: Offset(0, 2),
),
], ],
), ),
), ),
const SizedBox(height: 6), const SizedBox(height: 5),
const Icon( Text(
LucideIcons.arrowUpRight, strapline,
size: 18, textAlign: TextAlign.center,
color: Color(0xB3FFFFFF), style: DmText.eyebrow.copyWith(
fontSize: 9.5,
letterSpacing: 1.7,
color: const Color(0xB3FFFFFF),
),
), ),
], ],
), ),

View File

@@ -102,6 +102,11 @@ class _DmButtonState extends State<DmButton> {
? DmColors.surfaceAlt ? DmColors.surfaceAlt
: (_down ? DmColors.brandPress : DmColors.brand); : (_down ? DmColors.brandPress : DmColors.brand);
foreground = _enabled ? Colors.white : DmColors.ink3; foreground = _enabled ? Colors.white : DmColors.ink3;
// A disabled primary on the deepened canvas is a 1% step from it, so
// the screen's one action read as a grey smear rather than as a
// button waiting for the form to be filled. The edge is what keeps it
// a button.
if (!_enabled) border = Border.all(color: DmColors.border);
shadow = _enabled && !_down ? DmShadow.lift : null; shadow = _enabled && !_down ? DmShadow.lift : null;
// Lit like the sphere, and only while it can be pressed: a disabled // 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. // control that still catches the light is a control that is lying.
@@ -109,7 +114,7 @@ class _DmButtonState extends State<DmButton> {
? const LinearGradient( ? const LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [Color(0xFFA6180B), DmColors.brand, Color(0xFF7A0C04)], colors: [DmColors.brandLit, DmColors.brand, DmColors.brandPress],
stops: [0.0, 0.55, 1.0], stops: [0.0, 0.55, 1.0],
) )
: null; : null;

View File

@@ -21,24 +21,40 @@ enum DmCardTone {
sunken, sunken,
} }
/// A bordered panel that stacks rows and rules them apart. /// A panel that stacks rows and rules them apart.
/// ///
/// This replaces the shadowed card the app used to draw everywhere. The rule /// The rule between rows belongs to the card, not to the rows: a row does not
/// between rows belongs to the card, not to the rows: a row does not know /// know whether it is first, and a card that is handed one child draws no rule
/// whether it is first, and a card that is handed one child draws no rule at /// at all, so the same [DmCardCell] works alone or in a stack of five.
/// all, so the same [DmCardCell] works alone or in a stack of five. ///
/// ── Edge → shadow (2026-09-23) ──
///
/// This was a bordered panel, which was the right correction to the shadowed
/// card it replaced — that one dropped a shadow under everything, so nothing
/// on a screen could be said to matter more than anything else. Eight hairline
/// boxes down a screen turned out to have the same failure in reverse: the
/// outline became the loudest thing about an object whose job is to be quiet.
///
/// A card is lifted now, not outlined. [DmShadow.card] is two very soft layers
/// — an ambient wash and a short contact shadow — neither of which is legible
/// *as* a shadow; together they read as the white being nearer than the canvas.
/// [raised] doubles them for the one card on a screen that needs acting on.
class DmCard extends StatelessWidget { class DmCard extends StatelessWidget {
const DmCard({ const DmCard({
super.key, super.key,
required this.children, required this.children,
this.tone = DmCardTone.plain, this.tone = DmCardTone.plain,
this.rail = false, this.rail = false,
this.raised = false,
this.onTap, this.onTap,
}); });
final List<Widget> children; final List<Widget> children;
final DmCardTone tone; final DmCardTone tone;
/// Deeper lift, for the one card in a list that is live or needs a decision.
final bool raised;
/// A 3pt crimson spine down the leading edge — marks the one row in a list /// A 3pt crimson spine down the leading edge — marks the one row in a list
/// that needs the customer's attention now. /// that needs the customer's attention now.
final bool rail; final bool rail;
@@ -62,6 +78,9 @@ class DmCard extends StatelessWidget {
/// The rule between two rows of the same card. On crimson it is a lightened /// The rule between two rows of the same card. On crimson it is a lightened
/// white rather than a darker line: a dark rule on a saturated field reads as /// white rather than a darker line: a dark rule on a saturated field reads as
/// a crack, a light one as a seam. /// a crack, a light one as a seam.
/// Only the white card lifts. See the note in [build].
bool get _lifts => tone == DmCardTone.plain;
Color get _rule => switch (tone) { Color get _rule => switch (tone) {
DmCardTone.plain => DmColors.hairline, DmCardTone.plain => DmColors.hairline,
DmCardTone.fill => const Color(0x2EFFFFFF), DmCardTone.fill => const Color(0x2EFFFFFF),
@@ -79,18 +98,25 @@ class DmCard extends StatelessWidget {
// and the card lost its outline exactly where it needed one most. A // and the card lost its outline exactly where it needed one most. A
// foreground decoration paints last, so every cell is clipped inside an // foreground decoration paints last, so every cell is clipped inside an
// edge that is always visible. // edge that is always visible.
final radius = DmRadius.all(DmRadius.card);
final card = DecoratedBox( final card = DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: _background, color: _background,
borderRadius: DmRadius.all(DmRadius.md), borderRadius: radius,
boxShadow: _lifts ? (raised ? DmShadow.cardRaised : DmShadow.card) : null,
), ),
child: Container( child: Container(
foregroundDecoration: BoxDecoration( // A tone that is not white keeps its edge: a crimson or washed card is
borderRadius: DmRadius.all(DmRadius.md), // already separated from the canvas by its own colour, and a shadow
border: Border.all(color: _edge), // under a saturated fill reads as grime rather than as lift.
), foregroundDecoration: _lifts
? null
: BoxDecoration(
borderRadius: radius,
border: Border.all(color: _edge),
),
child: ClipRRect( child: ClipRRect(
borderRadius: DmRadius.all(DmRadius.md), borderRadius: radius,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -109,7 +135,8 @@ class DmCard extends StatelessWidget {
? Container( ? Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: DmColors.brand, color: DmColors.brand,
borderRadius: DmRadius.all(DmRadius.md), borderRadius: radius,
boxShadow: _lifts ? DmShadow.cardRaised : null,
), ),
padding: const EdgeInsets.only(left: 3), padding: const EdgeInsets.only(left: 3),
child: card, child: card,

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../tokens.dart'; import '../tokens.dart';
@@ -387,16 +386,25 @@ class DmFooter extends StatelessWidget {
} }
} }
/// The crimson field at the top of a tab root. /// The head of a tab root: an eyebrow, a title, an optional action, and the
/// control that belongs to this destination.
/// ///
/// Home, Orders and Account all open with one. It does three jobs at once — /// ── It stopped being a crimson field ──
/// it states where you are (eyebrow + title), it holds that destination's one
/// control (a segmented filter, an address switcher, a phone number), and it
/// gives the app a recognisable brand surface that the white canvas below can
/// then stay completely quiet.
/// ///
/// The arcs are drawn, not an asset: they are struck from the same geometry as /// This was 260pt of solid brand on Home, Orders and Account — three of the
/// the mark, they scale to any header height, and they cost no bytes. /// app's five screens opening on a wall of it, with white type, a tinted rose
/// for the secondary line, a recessed track for the address and a lightened
/// control on top. Every one of those tokens existed only to survive the
/// crimson, and the accent that is supposed to mean *do this* was the
/// background of half the product.
///
/// It is the canvas now, with ink type. What is left of the brand on these
/// screens is the sphere, the primary button, and a live dot — which is what
/// an accent is for.
///
/// The arcs survive at a fraction of their old opacity: they were struck from
/// the mark's geometry and they are the one thing here that was never the
/// colour doing the work.
class DmBrandHeader extends StatelessWidget { class DmBrandHeader extends StatelessWidget {
const DmBrandHeader({ const DmBrandHeader({
super.key, super.key,
@@ -421,57 +429,35 @@ class DmBrandHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ClipRRect( return DecoratedBox(
borderRadius: const BorderRadius.vertical( decoration: const BoxDecoration(color: DmColors.canvas),
bottom: Radius.circular(DmRadius.header), child: CustomPaint(
), painter: const DmArcField(opacity: 0.035, colour: DmColors.brand),
child: DecoratedBox( child: SafeArea(
// Lit from the top-left like every other brand object in the app, so bottom: false,
// the crimson field has a direction instead of sitting flat. The spread child: Padding(
// is deliberately narrow — a header that reads as a gradient rather padding: const EdgeInsets.fromLTRB(
// than as a colour has stopped being the brand. DmSpace.pad,
decoration: const BoxDecoration( 2,
gradient: LinearGradient( DmSpace.pad,
begin: Alignment.topLeft, 14,
end: Alignment.bottomRight, ),
colors: [Color(0xFFA0150A), DmColors.brand, Color(0xFF7A0C04)], child: Column(
stops: [0.0, 0.52, 1.0], crossAxisAlignment: CrossAxisAlignment.start,
), mainAxisSize: MainAxisSize.min,
), children: [
child: CustomPaint( SizedBox(
painter: const DmArcField(), height: 48,
child: SafeArea( child: Row(children: [?leading, const Spacer(), ?action]),
bottom: false, ),
child: Padding( const SizedBox(height: 6),
padding: const EdgeInsets.fromLTRB( if (eyebrow != null) ...[
DmSpace.headerPad, Text(eyebrow!.toUpperCase(), style: DmText.eyebrow),
2, const SizedBox(height: 6),
DmSpace.headerPad,
18,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 48,
child: Row(
children: [
?leading,
const Spacer(),
?action,
],
),
),
const SizedBox(height: 10),
if (eyebrow != null) ...[
Text(eyebrow!.toUpperCase(), style: DmText.headerEyebrow),
const SizedBox(height: 8),
],
Text(title, style: DmText.headerTitle),
if (child != null) ...[const SizedBox(height: 18), child!],
], ],
), Text(title, style: DmText.headerTitle),
if (child != null) ...[const SizedBox(height: 16), child!],
],
), ),
), ),
), ),
@@ -483,10 +469,13 @@ class DmBrandHeader extends StatelessWidget {
/// Thin arc strokes struck from the mark's geometry, at the opacity the design /// Thin arc strokes struck from the mark's geometry, at the opacity the design
/// uses. Decorative only — it never carries meaning, so it is not announced. /// uses. Decorative only — it never carries meaning, so it is not announced.
class DmArcField extends CustomPainter { class DmArcField extends CustomPainter {
const DmArcField({this.opacity = 0.15}); const DmArcField({this.opacity = 0.15, this.colour = Colors.white});
final double opacity; final double opacity;
/// White on a crimson field; crimson, very faint, on the canvas.
final Color colour;
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
// The arcs are authored on a 340-square field anchored to the top-right, // The arcs are authored on a 340-square field anchored to the top-right,
@@ -501,7 +490,7 @@ class DmArcField extends CustomPainter {
final paint = Paint() final paint = Paint()
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = 1.5 / scale ..strokeWidth = 1.5 / scale
..color = Colors.white.withValues(alpha: opacity); ..color = colour.withValues(alpha: opacity);
canvas.drawCircle(const Offset(250, 90), 120, paint); canvas.drawCircle(const Offset(250, 90), 120, paint);
canvas.drawCircle(const Offset(250, 90), 170, paint); canvas.drawCircle(const Offset(250, 90), 170, paint);
@@ -521,126 +510,7 @@ class DmArcField extends CustomPainter {
@override @override
bool shouldRepaint(covariant DmArcField oldDelegate) => bool shouldRepaint(covariant DmArcField oldDelegate) =>
oldDelegate.opacity != opacity; oldDelegate.opacity != opacity || oldDelegate.colour != colour;
}
/// The segmented filter recessed into the brand header — Orders' Active /
/// Completed / Cancelled, with each tab's count.
///
/// The selected tab lifts out as a white pill and keeps its figure; the others
/// stay in the recess. One control, three states, no separate count row.
class DmSegmented extends StatelessWidget {
const DmSegmented({
super.key,
required this.labels,
required this.counts,
required this.index,
required this.onChanged,
});
final List<String> labels;
/// Null where a tab has no meaningful figure yet (still loading).
final List<int?> counts;
final int index;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: DmColors.onBrandSunken,
borderRadius: DmRadius.all(DmRadius.lg),
),
child: Row(
children: [
for (var i = 0; i < labels.length; i++) ...[
if (i > 0) const SizedBox(width: 4),
Expanded(
child: _Segment(
label: labels[i],
count: i < counts.length ? counts[i] : null,
selected: i == index,
onTap: () => onChanged(i),
),
),
],
],
),
);
}
}
class _Segment extends StatelessWidget {
const _Segment({
required this.label,
required this.count,
required this.selected,
required this.onTap,
});
final String label;
final int? count;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
selected: selected,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
HapticFeedback.selectionClick();
onTap();
},
child: AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 11),
decoration: BoxDecoration(
color: selected ? Colors.white : Colors.transparent,
borderRadius: DmRadius.all(DmRadius.sm),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.label.copyWith(
fontSize: 13,
color: selected ? DmColors.brand : DmColors.onBrand3,
),
),
),
if (count != null) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: selected ? DmColors.brandSoft : DmColors.onBrandRaise,
borderRadius: DmRadius.all(DmRadius.pill),
),
child: Text(
'$count',
style: DmText.monoSm.copyWith(
fontSize: 11,
color: selected ? DmColors.brand : Colors.white,
),
),
),
],
],
),
),
),
);
}
} }
/// The recessed strip under a header title that carries one live fact — the /// The recessed strip under a header title that carries one live fact — the
@@ -668,9 +538,14 @@ class DmHeaderTrack extends StatelessWidget {
onTap: onTap, onTap: onTap,
child: Container( child: Container(
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13), padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
// A bordered white field, not a recess. `onBrandSunken` is 14%
// black, which on the canvas reads as a grey smudge rather than as
// something cut into the surface — a recess only works against a
// colour dark enough to be cut.
decoration: BoxDecoration( decoration: BoxDecoration(
color: DmColors.onBrandSunken, color: DmColors.surface,
borderRadius: DmRadius.all(DmRadius.lg), borderRadius: DmRadius.all(DmRadius.lg),
border: Border.all(color: DmColors.border),
), ),
child: Row( child: Row(
children: [ children: [
@@ -753,3 +628,211 @@ class DmScreenHeader extends StatelessWidget {
); );
} }
} }
/// The wordmark bar that sits at the top of every tab root.
///
/// ── Why the app grew a permanent header ──
///
/// The three roots each began with their own arrangement: Home with a name,
/// Orders with a title, Account with another name. Nothing on any of them
/// said which app this was, and the two things a customer reaches for from
/// anywhere — find an order, see what changed — had no home at all.
///
/// One bar, on all three. The mark on the left with its dot, a search and a
/// bell on the right. It is 52pt of chrome that makes the three roots read as
/// one product rather than as three screens that happen to share a tab bar.
class DmAppBar extends StatelessWidget {
const DmAppBar({super.key, this.onSearch, this.onNotifications, this.unread = false});
final VoidCallback? onSearch;
final VoidCallback? onNotifications;
/// Puts the brand dot on the bell.
final bool unread;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 6, DmSpace.pad - 4, 2),
child: SizedBox(
height: 46,
child: Row(
children: [
const DmWordmark(),
const Spacer(),
_BarIcon(
icon: LucideIcons.search,
tooltip: 'Search orders',
onPressed: onSearch,
),
_BarIcon(
icon: LucideIcons.bell,
tooltip: 'Notifications',
dot: unread,
onPressed: onNotifications,
),
],
),
),
);
}
}
/// "Doormile ·" — the name, then the mark's dot.
///
/// The dot is the whole logo at this size. A 100pt lockup in a 46pt bar would
/// be a picture of a logo; a word and a full stop in the brand colour is a
/// wordmark, which is what a bar wants.
class DmWordmark extends StatelessWidget {
const DmWordmark({super.key, this.size = 17});
final double size;
@override
Widget build(BuildContext context) {
return Semantics(
label: 'Doormile',
header: true,
excludeSemantics: true,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Doormile',
style: DmText.cardTitle.copyWith(
fontSize: size,
letterSpacing: -0.45,
fontWeight: FontWeight.w700,
fontVariations: const [FontVariation('wght', 700)],
),
),
SizedBox(width: size * 0.22),
Container(
width: size * 0.26,
height: size * 0.26,
decoration: const BoxDecoration(
color: DmColors.brand,
shape: BoxShape.circle,
),
),
],
),
);
}
}
class _BarIcon extends StatelessWidget {
const _BarIcon({
required this.icon,
required this.tooltip,
this.onPressed,
this.dot = false,
});
final IconData icon;
final String tooltip;
final VoidCallback? onPressed;
final bool dot;
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: InkResponse(
onTap: onPressed,
radius: 22,
child: SizedBox(
width: 44,
height: 44,
child: Stack(
alignment: Alignment.center,
children: [
Icon(icon, size: 20, color: DmColors.ink),
if (dot)
Positioned(
top: 11,
right: 11,
child: Container(
width: 7,
height: 7,
decoration: BoxDecoration(
color: DmColors.brand,
shape: BoxShape.circle,
border: Border.all(color: DmColors.canvas, width: 1.5),
),
),
),
],
),
),
),
);
}
}
/// A status mark: a dot, a word, and a tint the colour of the state.
///
/// ── The shape the whole app now says "what is happening" in ──
///
/// It replaces three different objects: the bordered `DmPill`, the bare dot
/// and word on the flat order rows, and the hand-built chip on the live card.
/// One shape, three intensities of the same idea — a coloured dot, uppercase
/// micro-type, and six percent of the dot's colour behind them.
class DmStatusChip extends StatelessWidget {
const DmStatusChip({
super.key,
required this.label,
this.tone,
this.icon,
this.quiet = false,
});
final String label;
/// Defaults to the brand. `ok` for delivered, `ink4` for cancelled.
final Color? tone;
/// Replaces the dot — the lightning bolt on "EXPRESS PRIORITY".
final IconData? icon;
/// A finished state. Same shape, less of it.
final bool quiet;
@override
Widget build(BuildContext context) {
final colour = tone ?? DmColors.brand;
return Container(
padding: EdgeInsets.fromLTRB(icon == null ? 8 : 7, 4.5, 9, 5),
decoration: BoxDecoration(
color: colour.withValues(alpha: quiet ? 0.055 : 0.085),
borderRadius: DmRadius.all(DmRadius.chip),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null)
Icon(icon, size: 11, color: colour)
else
Container(
width: 5.5,
height: 5.5,
decoration: BoxDecoration(color: colour, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Flexible(
child: Text(
label.toUpperCase(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.eyebrow.copyWith(
fontSize: 9.5,
letterSpacing: 0.85,
color: quiet ? DmColors.ink3 : DmColors.ink,
),
),
),
],
),
);
}
}

View File

@@ -259,6 +259,7 @@ class DmRow extends StatelessWidget {
this.onTap, this.onTap,
this.showChevron = false, this.showChevron = false,
this.icon, this.icon,
this.note,
}); });
final String label; final String label;
@@ -277,6 +278,12 @@ class DmRow extends StatelessWidget {
/// just as well. /// just as well.
final IconData? icon; final IconData? icon;
/// The line under the label — "3 locations configured", "Real-time SMS &
/// Push". It is what turns a menu into a list of states the customer can
/// check without opening anything, which is most of what a settings screen
/// is for.
final String? note;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return Material(
@@ -286,38 +293,64 @@ class DmRow extends StatelessWidget {
splashColor: DmColors.brandSoft, splashColor: DmColors.brandSoft,
highlightColor: DmColors.brandSoft.withValues(alpha: 0.5), highlightColor: DmColors.brandSoft.withValues(alpha: 0.5),
child: Container( child: Container(
constraints: const BoxConstraints(minHeight: 56), constraints: const BoxConstraints(minHeight: 58),
padding: const EdgeInsets.fromLTRB(16, 11, 16, 11), padding: const EdgeInsets.fromLTRB(18, 13, 18, 13),
child: Row( child: Row(
children: [ children: [
if (icon != null) ...[ if (icon != null) ...[
Icon(icon, size: 19, color: DmColors.ink3), // A glyph in a well, not a glyph. Six bare strokes down a
const SizedBox(width: 14), // settings list read as a column of marks; six wells read as
// six rows, and the well is what gives the label a left edge
// to sit against.
Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: DmColors.surfaceAlt,
borderRadius: DmRadius.all(DmRadius.xs),
),
child: Icon(icon, size: 17, color: DmColors.ink2),
),
const SizedBox(width: 13),
], ],
// The label takes the width it needs and the value takes what // The label is what the row IS and it is never the thing that
// is left. A fixed 3:2 split gave the label room it was not // truncates. Two rounds of flex arithmetic here produced
// using — "Saved addresses" is short — and rendered the value // "Payment metho…" and "38 Mettupal…" in turn; the fix was not a
// beside it as "38 Mettupal…", which is a truncation nobody can // third ratio, it was to stop passing values that cannot fit.
// read anything out of. The label stays Flexible so a long one // A [value] is a word — "UPI", "SMS and push" — and anything
// at large text sizes can still give way. // longer belongs on the screen the row opens.
Flexible( Expanded(
child: Text( child: Column(
label, crossAxisAlignment: CrossAxisAlignment.start,
style: DmText.cardTitle, mainAxisSize: MainAxisSize.min,
maxLines: 1, children: [
overflow: TextOverflow.ellipsis, Text(
label,
style: DmText.cardTitle.copyWith(fontSize: 15.5),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (note != null)
Text(
note!,
style: DmText.small.copyWith(
fontSize: 12.5,
color: DmColors.ink3,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
), ),
), ),
if (value != null) ...[ if (value != null) ...[
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Text(
child: Text( value!,
value!, style: DmText.value,
style: DmText.value, textAlign: TextAlign.right,
textAlign: TextAlign.right, maxLines: 1,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
), ),
], ],
?trailing, ?trailing,
@@ -337,7 +370,18 @@ class DmRow extends StatelessWidget {
} }
} }
/// Card that rules a group of [DmRow]s together. /// A run of [DmRow]s on one surface.
///
/// ── Card → rules → card ──
///
/// It was a bordered card, then bare ruled rows on the canvas. The bare rows
/// were right about the border — three decorations were doing one job — and
/// wrong about the surface: with nothing under them Account read as a text
/// file, while every other list in the app had come back onto a card.
///
/// It is a [DmCard] again, which now separates by lift rather than by edge.
/// The rules stay, because they belong to the card and are what let one
/// [DmRow] work alone or in a stack of five.
class DmRowGroup extends StatelessWidget { class DmRowGroup extends StatelessWidget {
const DmRowGroup({super.key, required this.children}); const DmRowGroup({super.key, required this.children});

View File

@@ -28,6 +28,7 @@ class DmMilestones extends StatelessWidget {
this.title = 'Recent updates', this.title = 'Recent updates',
this.maxRows, this.maxRows,
this.onExpand, this.onExpand,
this.showStep = false,
}); });
final Booking booking; final Booking booking;
@@ -44,6 +45,10 @@ class DmMilestones extends StatelessWidget {
/// Tapped from the "Full timeline" action. Null hides the action. /// Tapped from the "Full timeline" action. Null hides the action.
final VoidCallback? onExpand; final VoidCallback? onExpand;
/// Puts "Step N of 5" opposite the title. Only where the whole rail is on
/// screen — a counter over a compressed list counts rows nobody can see.
final bool showStep;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final current = stage ?? booking.stage; final current = stage ?? booking.stage;
@@ -89,6 +94,20 @@ class DmMilestones extends StatelessWidget {
style: DmText.eyebrow, style: DmText.eyebrow,
), ),
), ),
// ── "Step 2 of 5" ──
//
// The rail shows where the parcel is; the counter says how much
// of the journey that is. A customer looking at five rows with
// one of them lit has to count to know, and counting is the one
// thing a progress indicator exists to save them.
if (showStep)
Text(
'Step ${currentIndex + 1} of ${milestones.length}',
style: DmText.label.copyWith(
fontSize: 12.5,
color: DmColors.brand,
),
),
if (onExpand != null && shown.length < ordered.length) if (onExpand != null && shown.length < ordered.length)
DmTextAction( DmTextAction(
// Was "All 7 stages". The number counts something the // Was "All 7 stages". The number counts something the
@@ -258,9 +277,11 @@ class _MilestoneRow extends StatelessWidget {
const SizedBox(width: 10), const SizedBox(width: 10),
Text( Text(
time, time,
style: DmText.monoSm.copyWith( // Always quiet. The crimson on the current row is
color: isCurrent ? DmColors.brand : DmColors.ink3, // the "you are here" marker and it belongs to the
), // milestone; a timestamp set in it made the accent
// mean "recent" on one row and "now" on the next.
style: DmText.monoSm.copyWith(color: DmColors.ink3),
), ),
], ],
], ],

View File

@@ -8,8 +8,12 @@ enum DmPillTone {
/// for a Miler, out for delivery. Crimson field, white label, solid dot. /// for a Miler, out for delivery. Crimson field, white label, solid dot.
live, live,
/// Moving, but between beats — assigned, in transit. The crimson is held in /// Moving, but between beats — assigned, in transit.
/// an outline so a list of six orders is not six red blocks. ///
/// The crimson used to be held in an outline *and* in the label, so a list
/// of six orders was six red words. It is held in the dot now: the colour
/// still says "this one is running", and the word is read at ink weight
/// like every other word on the card.
neutral, neutral,
/// Finished. Grey, with a filled dot: the journey happened. /// Finished. Grey, with a filled dot: the journey happened.
@@ -32,8 +36,8 @@ class DmPill extends StatelessWidget {
DmPillTone.live => (DmColors.brand, Colors.white, null), DmPillTone.live => (DmColors.brand, Colors.white, null),
DmPillTone.neutral => ( DmPillTone.neutral => (
DmColors.surface, DmColors.surface,
DmColors.brand, DmColors.ink,
Border.all(color: DmColors.brandLine), Border.all(color: DmColors.border),
), ),
DmPillTone.done => (DmColors.surfaceAlt, DmColors.ink3, null), DmPillTone.done => (DmColors.surfaceAlt, DmColors.ink3, null),
DmPillTone.off => (DmColors.surfaceAlt, DmColors.ink3, null), DmPillTone.off => (DmColors.surfaceAlt, DmColors.ink3, null),
@@ -43,6 +47,10 @@ class DmPill extends StatelessWidget {
// for anything that happened or is happening, hollow for what never did. // for anything that happened or is happening, hollow for what never did.
final hollow = tone == DmPillTone.off; final hollow = tone == DmPillTone.off;
// On the neutral pill the dot is the only crimson left, so it does not
// take its colour from the label the way the other tones do.
final dot = tone == DmPillTone.neutral ? DmColors.brand : fg;
return Container( return Container(
padding: const EdgeInsets.fromLTRB(11, 5, 11, 5), padding: const EdgeInsets.fromLTRB(11, 5, 11, 5),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -58,9 +66,9 @@ class DmPill extends StatelessWidget {
height: 6, height: 6,
margin: const EdgeInsets.only(right: 7), margin: const EdgeInsets.only(right: 7),
decoration: BoxDecoration( decoration: BoxDecoration(
color: hollow ? Colors.transparent : fg, color: hollow ? Colors.transparent : dot,
shape: BoxShape.circle, shape: BoxShape.circle,
border: hollow ? Border.all(color: fg, width: 1.5) : null, border: hollow ? Border.all(color: dot, width: 1.5) : null,
), ),
), ),
Flexible( Flexible(
@@ -163,34 +171,73 @@ class DmAvatar extends StatelessWidget {
super.key, super.key,
required this.initials, required this.initials,
this.size = 42, this.size = 42,
this.onBrand = false, this.quiet = false,
this.badge = false,
}); });
final String initials; final String initials;
final double size; final double size;
final bool onBrand;
/// A wash with the initials in crimson, instead of a crimson disc.
///
/// The solid disc is right where the avatar stands in for a person the
/// customer is looking at — the Miler on tracking. It is wrong where the
/// avatar is chrome: on Home it put a second saturated circle on a screen
/// whose whole design rests on there being exactly one, and the one that
/// matters is the sphere you press.
final bool quiet;
/// A brand dot on the lower-right, cut out of the surface behind it. Home
/// wears it when a booking is live, which is the one thing the avatar's
/// destination — Account — would not tell you.
final bool badge;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( final disc = Container(
width: size, width: size,
height: size, height: size,
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
color: onBrand ? DmColors.onBrandRaise : DmColors.brand, color: quiet ? DmColors.brandSoft : DmColors.brand,
shape: BoxShape.circle, shape: BoxShape.circle,
border: quiet ? Border.all(color: DmColors.brandLine) : null,
), ),
child: Text( child: Text(
initials, initials,
style: DmText.label.copyWith( style: DmText.label.copyWith(
fontSize: size < 40 ? 12 : 13, // Scales with the disc. It was capped at 13pt, which is right on a
// 42pt avatar in a row and looks like a typo on the 86pt one at the
// top of Account.
fontSize: size < 40 ? 12 : (size <= 48 ? 13 : size * 0.30),
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
fontVariations: const [FontVariation('wght', 800)], fontVariations: const [FontVariation('wght', 800)],
letterSpacing: 0.13, letterSpacing: 0.13,
color: Colors.white, color: quiet ? DmColors.brand : Colors.white,
), ),
), ),
); );
if (!badge) return disc;
return Stack(
clipBehavior: Clip.none,
children: [
disc,
Positioned(
right: 0,
bottom: 1,
child: Container(
width: size * 0.28,
height: size * 0.28,
decoration: BoxDecoration(
color: DmColors.brand,
shape: BoxShape.circle,
border: Border.all(color: DmColors.canvas, width: 2),
),
),
),
],
);
} }
} }

View File

@@ -59,20 +59,27 @@ class _DmOptionTileState extends State<DmOptionTile> {
final selected = widget.selected; final selected = widget.selected;
final enabled = widget.enabled; final enabled = widget.enabled;
// ── Selection is a tint and an edge, not a crimson slab ──
//
// A chosen slot used to fill solid brand and carry [DmShadow.lift] — the
// 80%-crimson glow written for the one committing button on a screen. In
// a list of six windows that made the choice the loudest object in the
// sheet, louder than the button that confirms it, and the white text
// inside it broke the type colour every other row was set in.
//
// A wash with a crimson edge says *chosen* just as unambiguously, keeps
// the row's type where it was, and leaves the crimson fill to the footer
// button — which is the thing the customer presses next.
final Color background = selected final Color background = selected
? DmColors.brand ? DmColors.brandSoft
: (enabled ? DmColors.surface : DmColors.surfaceAlt); : (enabled ? DmColors.surface : DmColors.surfaceAlt);
final Color edge = selected
? DmColors.brand
: (enabled ? DmColors.border : DmColors.border);
final titleColor = selected final titleColor = selected
? Colors.white ? DmColors.brandPress
: (enabled ? DmColors.ink : DmColors.ink3); : (enabled ? DmColors.ink : DmColors.ink3);
final subColor = selected final subColor = selected
? DmColors.onBrand2 ? DmColors.brandPress
: (enabled ? DmColors.ink2 : DmColors.ink3); : (enabled ? DmColors.ink2 : DmColors.ink3);
return Semantics( return Semantics(
@@ -107,8 +114,13 @@ class _DmOptionTileState extends State<DmOptionTile> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: background, color: background,
borderRadius: DmRadius.all(DmRadius.md), borderRadius: DmRadius.all(DmRadius.md),
border: Border.all(color: edge), border: selected
boxShadow: selected ? DmShadow.lift : null, ? Border.all(color: DmColors.brand, width: 1.5)
: null,
// An unchosen, available option is a card like any other: lifted
// off the sheet rather than outlined on it. A disabled one is
// flat, because it is not an object you can pick up.
boxShadow: enabled && !selected ? DmShadow.card : null,
), ),
child: Row( child: Row(
children: [ children: [
@@ -116,7 +128,7 @@ class _DmOptionTileState extends State<DmOptionTile> {
Icon( Icon(
widget.leading, widget.leading,
size: 20, size: 20,
color: selected ? Colors.white : DmColors.ink3, color: selected ? DmColors.brand : DmColors.ink3,
), ),
const SizedBox(width: 13), const SizedBox(width: 13),
], ],
@@ -178,9 +190,11 @@ class _Radio extends StatelessWidget {
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
// The mark takes the brand now that the tile behind it is a wash
// rather than a crimson field — white on `brandSoft` is invisible.
border: Border.all( border: Border.all(
color: selected color: selected
? Colors.white ? DmColors.brand
: (enabled ? DmColors.ink4 : DmColors.border), : (enabled ? DmColors.ink4 : DmColors.border),
width: selected ? 2 : 1.5, width: selected ? 2 : 1.5,
), ),
@@ -190,7 +204,7 @@ class _Radio extends StatelessWidget {
width: 10, width: 10,
height: 10, height: 10,
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: Colors.white, color: DmColors.brand,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
) )

View File

@@ -114,25 +114,29 @@ class DmChoiceChip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final fg = selected // ── A segmented control, not a crimson block ──
? Colors.white //
: (enabled ? DmColors.ink2 : DmColors.ink4); // "Today" was a solid brand fill with [DmShadow.liftSm] under it, sitting
// eight points above a list of windows and two hundred above the crimson
// button that confirms one. Three crimson objects on a sheet that asks a
// single question, and the loudest of them was the day — which the
// customer changes once, if ever.
//
// It is a lifted white chip on a sunken track now: the same convention
// iOS and every banking app use for a segment, where *selected* means
// raised rather than coloured.
final fg = selected ? DmColors.ink : (enabled ? DmColors.ink3 : DmColors.ink4);
final chip = AnimatedContainer( final chip = AnimatedContainer(
duration: DmMotion.base, duration: DmMotion.base,
curve: DmMotion.ease, curve: DmMotion.ease,
constraints: const BoxConstraints(minHeight: 38), constraints: const BoxConstraints(minHeight: 40),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
color: selected color: selected ? DmColors.surface : Colors.transparent,
? DmColors.brand borderRadius: DmRadius.all(DmRadius.sm),
: (enabled ? DmColors.surface : DmColors.surfaceAlt), boxShadow: selected ? DmShadow.card : null,
border: Border.all(
color: selected ? DmColors.brand : DmColors.border,
),
borderRadius: DmRadius.all(DmRadius.xs),
boxShadow: selected ? DmShadow.liftSm : null,
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -143,7 +147,11 @@ class DmChoiceChip extends StatelessWidget {
label, label,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: DmText.label.copyWith(fontSize: 13, color: fg), style: DmText.label.copyWith(
fontSize: 13.5,
color: fg,
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
),
), ),
), ),
if (count != null) ...[ if (count != null) ...[
@@ -151,14 +159,14 @@ class DmChoiceChip extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: selected ? const Color(0x3DFFFFFF) : DmColors.surfaceAlt, color: selected ? DmColors.surfaceAlt : DmColors.surface,
borderRadius: DmRadius.all(DmRadius.pill), borderRadius: DmRadius.all(DmRadius.pill),
), ),
child: Text( child: Text(
'$count', '$count',
style: DmText.monoSm.copyWith( style: DmText.monoSm.copyWith(
fontSize: 11, fontSize: 11,
color: selected ? Colors.white : DmColors.ink3, color: DmColors.ink3,
), ),
), ),
), ),

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../tokens.dart'; import '../tokens.dart';
@@ -20,15 +21,15 @@ enum DmNodeKind {
/// Where the parcel starts. A hollow circle: a door, not a decision. /// Where the parcel starts. A hollow circle: a door, not a decision.
origin, origin,
/// A sorting hub in between. An outlined diamond — the parcel passes /// A sorting hub in between. A hollow crimson ring — the parcel passes
/// through, it does not stop here. /// through, it does not stop here.
hub, hub,
/// The destination. A filled crimson diamond, the only solid node on the /// The destination. A filled map pin, the only solid node on the thread, so
/// thread, so the eye lands on where the parcel is going. /// the eye lands on where the parcel is going.
drop, drop,
/// A leg that has not been arranged yet — dashed outline. /// A leg that has not been arranged yet — an outlined pin.
pending, pending,
} }
@@ -243,44 +244,31 @@ class _Node extends StatelessWidget {
border: Border.all(color: DmColors.ink4, width: 2), border: Border.all(color: DmColors.ink4, width: 2),
), ),
), ),
// Rotated squares rather than a second circle: on a thread of three // ── The destination is a pin, not a diamond ──
// identical dots nothing tells you which end matters, and at 11pt a //
// diamond is distinguishable from a circle at a glance where two // A rotated square distinguishes the ends of the thread, which was
// shades of the same dot are not. // the whole argument for it, and it does that by being a shape with
DmNodeKind.hub => Transform.rotate( // no meaning attached. A map pin is the mark every customer already
angle: 0.785398, // reads as *a place*, so the thread stops needing a legend: hollow
child: Container( // circle where you are, pin where it is going.
width: 10, DmNodeKind.hub => Container(
height: 10, width: 10,
decoration: BoxDecoration( height: 10,
color: DmColors.surface, decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2), shape: BoxShape.circle,
border: Border.all(color: DmColors.brand, width: 2), color: DmColors.surface,
), border: Border.all(color: DmColors.brand, width: 2),
), ),
), ),
DmNodeKind.drop => Transform.rotate( DmNodeKind.drop => const Icon(
angle: 0.785398, LucideIcons.mapPin,
child: Container( size: 15,
width: 10, color: DmColors.brand,
height: 10,
decoration: BoxDecoration(
color: DmColors.brand,
borderRadius: BorderRadius.circular(2),
),
),
), ),
DmNodeKind.pending => Transform.rotate( DmNodeKind.pending => const Icon(
angle: 0.785398, LucideIcons.mapPin,
child: Container( size: 15,
width: 10, color: DmColors.ink4,
height: 10,
decoration: BoxDecoration(
color: DmColors.surface,
borderRadius: BorderRadius.circular(2),
border: Border.all(color: DmColors.ink4, width: 1.5),
),
),
), ),
}, },
), ),

View File

@@ -87,28 +87,19 @@ flutter:
# the material Icons class. # the material Icons class.
uses-material-design: true uses-material-design: true
# Poppins (Indian Type Foundry, SIL Open Font License) is the app's voice as # Switzer (Indian Type Foundry, via Fontshare) is the app's voice as of
# of 2026-09-22. # 2026-09-23. One variable file, so the weight is selected through the `wght`
# axis in `DmText` rather than by bundling a file per cut.
# #
# Unlike the faces before it, Poppins has **no variable release** — Google # It replaced Poppins, which has no variable release: four static cuts at
# Fonts ships it only as static cuts. So it is four files, one per weight the # ~155 KB each, 625 KB of sans. This is 141 KB and covers 100-900.
# 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: fonts:
- family: Poppins - family: PlusJakartaSans
fonts: fonts:
- asset: assets/fonts/Poppins-Medium.ttf - asset: assets/fonts/PlusJakartaSans-Variable.ttf
weight: 500 - family: Switzer
- asset: assets/fonts/Poppins-SemiBold.ttf fonts:
weight: 600 - asset: assets/fonts/Switzer-Variable.ttf
- asset: assets/fonts/Poppins-Bold.ttf
weight: 700
- asset: assets/fonts/Poppins-ExtraBold.ttf
weight: 800
- family: GeistMono - family: GeistMono
fonts: fonts:
- asset: assets/fonts/GeistMono-VF.ttf - asset: assets/fonts/GeistMono-VF.ttf

View File

@@ -7,7 +7,6 @@ import 'package:doormile_cx/main.dart';
import 'package:doormile_cx/state/app_state.dart'; import 'package:doormile_cx/state/app_state.dart';
import 'package:doormile_cx/ui/widgets/buttons.dart'; import 'package:doormile_cx/ui/widgets/buttons.dart';
import 'package:doormile_cx/ui/widgets/map_panel.dart'; import 'package:doormile_cx/ui/widgets/map_panel.dart';
import 'package:doormile_cx/ui/widgets/chrome.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -86,7 +85,7 @@ Future<void> serverAdvance(
/// and the form is what the answer leads to, so every booking test is /// and the form is what the answer leads to, so every booking test is
/// [openSend] followed by [pickCity]. /// [openSend] followed by [pickCity].
Future<void> openSend(WidgetTester tester) async { Future<void> openSend(WidgetTester tester) async {
await tester.tap(find.text('BOOK')); await tester.tap(find.text('BOOK NOW'));
await settle(tester); await settle(tester);
} }
@@ -99,6 +98,15 @@ Future<void> openSend(WidgetTester tester) async {
/// The destination is reached by searching rather than by opening its state: /// 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 /// 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. /// customer who already knows where they are sending actually takes.
/// A finder restricted to the open bottom sheet.
///
/// Home's live card heads with the district alone — "Chennai", not
/// "Chennai, Tamil Nadu" — so a bare `find.text('Chennai')` matches the screen
/// behind the scrim as well as the row in the sheet. Every assertion about
/// what a sheet is offering goes through this.
Finder inSheet(Finder matching) =>
find.descendant(of: find.byType(BottomSheet), matching: matching);
Future<void> pickCity( Future<void> pickCity(
WidgetTester tester, WidgetTester tester,
String name, { String name, {
@@ -106,11 +114,20 @@ Future<void> pickCity(
}) async { }) async {
await tester.enterText(find.byType(TextField).first, name); await tester.enterText(find.byType(TextField).first, name);
await settle(tester, 250); await settle(tester, 250);
// A `Text`, explicitly: `find.text` also matches `EditableText`, so once the // A `Text` inside the sheet, explicitly.
// 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. // `find.text` also matches `EditableText`, so once the city's name is in the
// search box the plain finder returns the box as well — and Home, behind the
// scrim, now heads its live card with the district alone, so an unscoped
// finder can land on a widget the sheet is covering. Scoping to the modal
// route fixes both.
await tester.tap( await tester.tap(
find.byWidgetPredicate((w) => w is Text && w.data == name).first, find
.descendant(
of: find.byType(BottomSheet),
matching: find.byWidgetPredicate((w) => w is Text && w.data == name),
)
.first,
); );
await settle(tester); await settle(tester);
@@ -137,7 +154,7 @@ Future<void> pickCity(
Future<void> openPickupEditor(WidgetTester tester) async { Future<void> openPickupEditor(WidgetTester tester) async {
await openSend(tester); await openSend(tester);
await pickCity(tester, 'Chennai'); await pickCity(tester, 'Chennai');
await tester.tap(find.text('FROM')); await tester.tap(find.text('ORIGIN'));
await settle(tester); await settle(tester);
} }
@@ -192,17 +209,20 @@ void main() {
await settle(tester); await settle(tester);
// All four digits arrived, so it verified and moved on. // All four digits arrived, so it verified and moved on.
expect(find.text('BOOK'), findsOneWidget); expect(find.text('BOOK NOW'), findsOneWidget);
await drainToasts(tester); await drainToasts(tester);
}); });
testWidgets('signs in and lands on Home', (tester) async { testWidgets('signs in and lands on Home', (tester) async {
await signIn(tester); await signIn(tester);
expect(find.text('Send a parcel'), findsOneWidget); // Home greets by name, carries the sphere, and ends on the live card.
expect(find.text('BOOK'), findsOneWidget); expect(find.text('Hello, Joe'), findsOneWidget);
// The seeded active booking surfaces on Home. expect(find.text('BOOK NOW'), findsOneWidget);
expect(find.text('Miler assigned'), findsOneWidget); // The seeded active booking surfaces on Home. Its state is the uppercase
// micro-label beside the destination, in the chip vocabulary — "Assigned",
// not the rail's "Miler assigned".
expect(find.text('ASSIGNED'), findsOneWidget);
await drainToasts(tester); await drainToasts(tester);
}); });
@@ -233,7 +253,7 @@ void main() {
await tester.tap(find.text('Use this address')); await tester.tap(find.text('Use this address'));
await settle(tester); await settle(tester);
// Back on the send screen: its title is the route, not a heading. // Back on the send screen: its title is the route, not a heading.
expect(find.text('TO'), findsOneWidget); expect(find.text('DESTINATION'), findsOneWidget);
expect(find.text(corrected), findsOneWidget); expect(find.text(corrected), findsOneWidget);
await drainToasts(tester); await drainToasts(tester);
}); });
@@ -282,7 +302,7 @@ void main() {
// …and that one point is what the booking carries forward. // …and that one point is what the booking carries forward.
await tester.tap(find.text('Use this address')); await tester.tap(find.text('Use this address'));
await settle(tester); await settle(tester);
expect(find.text('TO'), findsOneWidget); expect(find.text('DESTINATION'), findsOneWidget);
expect(state.draftPickup!.lat, searched.lat); expect(state.draftPickup!.lat, searched.lat);
expect(state.draftPickup!.lng, searched.lng); expect(state.draftPickup!.lng, searched.lng);
expect(state.draftPickup!.title, 'Brookefields Mall'); expect(state.draftPickup!.title, 'Brookefields Mall');
@@ -398,8 +418,8 @@ void main() {
// Everything else the booking needs is on one screen, and the route to // 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. // the top of it is two rows with no labels but FROM and TO.
expect(find.text('FROM'), findsOneWidget); expect(find.text('ORIGIN'), findsOneWidget);
expect(find.text('TO'), findsOneWidget); expect(find.text('DESTINATION'), findsOneWidget);
// Nothing is asked for here at all any more: no address fields, and no // 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. // disclosure hiding three more. The route, the packages and the button.
expect(find.byType(TextField), findsNothing); expect(find.byType(TextField), findsNothing);
@@ -408,9 +428,9 @@ void main() {
// The customer answered the window sheet on the way in, so the booking // The customer answered the window sheet on the way in, so the booking
// already carries a slot they chose. // already carries a slot they chose.
expect(state.draftSlotId, isNotNull); expect(state.draftSlotId, isNotNull);
expect(find.text('Book pickup'), findsOneWidget); expect(find.text('Confirm Booking'), findsOneWidget);
await tester.tap(find.text('Book pickup')); await tester.tap(find.text('Confirm Booking'));
await settle(tester); await settle(tester);
expect(find.text('Pickup booked'), findsOneWidget); expect(find.text('Pickup booked'), findsOneWidget);
@@ -441,7 +461,7 @@ void main() {
expect(state.draftContactPhone, isNull); expect(state.draftContactPhone, isNull);
// Changed, it is the number the Miler will ring at the door. // Changed, it is the number the Miler will ring at the door.
await tester.tap(find.text('FROM')); await tester.tap(find.text('ORIGIN'));
await settle(tester); await settle(tester);
await tester.enterText(find.byType(TextField).first, '9003144518'); await tester.enterText(find.byType(TextField).first, '9003144518');
await settle(tester, 200); await settle(tester, 200);
@@ -458,12 +478,12 @@ void main() {
await openSend(tester); await openSend(tester);
await pickCity(tester, 'Coimbatore'); await pickCity(tester, 'Coimbatore');
expect(find.text('1 package'), findsOneWidget); expect(find.text('1 parcel'), findsOneWidget);
await tester.tap(find.byIcon(LucideIcons.plus)); await tester.tap(find.byIcon(LucideIcons.plus));
await tester.pump(); await tester.pump();
await tester.tap(find.byIcon(LucideIcons.plus)); await tester.tap(find.byIcon(LucideIcons.plus));
await tester.pump(); await tester.pump();
expect(find.text('3 packages'), findsOneWidget); expect(find.text('3 parcels'), findsOneWidget);
expect(state.draftTotalPackages, 3); expect(state.draftTotalPackages, 3);
// Push past the cap on the state; the clamp is the model's, not the UI's. // Push past the cap on the state; the clamp is the model's, not the UI's.
@@ -471,7 +491,7 @@ void main() {
await tester.pump(); await tester.pump();
expect(state.draftTotalPackages, state.limits.maxPackages); expect(state.draftTotalPackages, state.limits.maxPackages);
await tester.tap(find.text('Book pickup')); await tester.tap(find.text('Confirm Booking'));
await settle(tester); await settle(tester);
expect(state.trackedBooking!.totalPackages, state.limits.maxPackages); expect(state.trackedBooking!.totalPackages, state.limits.maxPackages);
await drainToasts(tester); await drainToasts(tester);
@@ -485,9 +505,10 @@ void main() {
await pickCity(tester, 'Chennai'); await pickCity(tester, 'Chennai');
final first = state.draftSlotId; final first = state.draftSlotId;
// The window lives in the footer now, beside the price it is quoted // The window is a row on the review list, under the route. It carries no
// against, and the whole left column opens the sheet. // label — the clock glyph and the time are the whole row — so it is found
await tester.tap(find.text('PICKUP')); // by the window itself.
await tester.tap(find.textContaining('2:00 – 4:00 PM').first);
await settle(tester); await settle(tester);
expect(find.text('Pickup window'), findsOneWidget); expect(find.text('Pickup window'), findsOneWidget);
@@ -508,26 +529,26 @@ void main() {
await openSend(tester); await openSend(tester);
// Step one is the states, not sixty district names. // Step one is the states, not sixty district names.
expect(find.text('Tamil Nadu'), findsOneWidget); expect(inSheet(find.text('Tamil Nadu')), findsOneWidget);
expect(find.text('Kerala'), findsOneWidget); expect(inSheet(find.text('Kerala')), findsOneWidget);
expect(find.text('Chennai'), findsNothing); expect(inSheet(find.text('Chennai')), findsNothing);
// Step two is that state's districts. // Step two is that state's districts.
await tester.tap(find.text('Tamil Nadu')); await tester.tap(find.text('Tamil Nadu'));
await settle(tester); await settle(tester);
expect(find.text('Chennai'), findsOneWidget); expect(inSheet(find.text('Chennai')), findsOneWidget);
expect(find.text('Coimbatore'), findsWidgets); expect(inSheet(find.text('Coimbatore')), findsWidgets);
// Districts that are not open are never offered, and neither is a state // Districts that are not open are never offered, and neither is a state
// whose every district is closed. // whose every district is closed.
expect(find.text('Madurai'), findsNothing); expect(inSheet(find.text('Madurai')), findsNothing);
expect(find.text('Puducherry'), findsNothing); expect(inSheet(find.text('Puducherry')), findsNothing);
// Back out, then let search cut across states: Ernakulam is in Kerala and // Back out, then let search cut across states: Ernakulam is in Kerala and
// the customer should not have to know that to find it. // the customer should not have to know that to find it.
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first); await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
await settle(tester); await settle(tester);
expect(find.text('Chennai'), findsNothing); expect(inSheet(find.text('Chennai')), findsNothing);
await pickCity(tester, 'Ernakulam'); await pickCity(tester, 'Ernakulam');
expect(find.textContaining('Ernakulam'), findsWidgets); expect(find.textContaining('Ernakulam'), findsWidgets);
@@ -538,19 +559,21 @@ void main() {
final state = AppState(); final state = AppState();
await signIn(tester, state: state); await signIn(tester, state: state);
await tester.tap(find.text('Orders')); await tester.tap(find.bySemanticsLabel('Orders'));
await settle(tester); await settle(tester);
// Before collection: one row, owned by the pickup. // Before collection: one row, owned by the pickup. The reference shares a
expect(find.text('DM-482913'), findsOneWidget); // line with the date and the contents now, so it is a span inside a
// paragraph rather than a Text of its own.
expect(find.textContaining('DM-482913'), findsOneWidget);
// After collection: the seeded multi-destination pickup is in transit, so // After collection: the seeded multi-destination pickup is in transit, so
// each destination stands on its own row. // each destination stands on its own row.
final booking = state.byReference('DM-471200')!; final booking = state.byReference('DM-471200')!;
expect(booking.hasOrders, isTrue); expect(booking.hasOrders, isTrue);
expect(find.text('Pickup DM-471200'), findsNothing); expect(find.text('Pickup DM-471200'), findsNothing);
expect(find.text('Ernakulam, Kerala'), findsOneWidget); expect(find.text('Ernakulam'), findsOneWidget);
expect(find.text('Coimbatore, Tamil Nadu'), findsOneWidget); expect(find.text('Coimbatore'), findsOneWidget);
// Each row is identified by its OWN tracking number, not by the visit that // 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 // produced it. "Collected on DM-471200" used to run under both rows; it
@@ -558,7 +581,7 @@ void main() {
// them, so it moved to the order's own screen. // them, so it moved to the order's own screen.
expect(find.text('Collected on DM-471200'), findsNothing); expect(find.text('Collected on DM-471200'), findsNothing);
for (final group in booking.destinations) { for (final group in booking.destinations) {
expect(find.text(group.trackingId!), findsOneWidget); expect(find.textContaining(group.trackingId!), findsOneWidget);
} }
// Independently tracked. // Independently tracked.
expect( expect(
@@ -566,10 +589,12 @@ void main() {
booking.destinations.length, booking.destinations.length,
); );
// Opening one follows that order, not the whole visit. // Opening one follows that order, not the whole visit: the head card's
await tester.tap(find.text('Ernakulam, Kerala')); // route ends at that destination, and the reference is its own tracking id.
await tester.tap(find.text('Ernakulam'));
await settle(tester); await settle(tester);
expect(find.text('To Ernakulam, Kerala'), findsOneWidget); expect(find.text('Ernakulam, Kerala'), findsWidgets);
expect(find.text('YOUR PACKAGES'), findsOneWidget);
expect(find.text('YOUR PACKAGES'), findsOneWidget); expect(find.text('YOUR PACKAGES'), findsOneWidget);
await drainToasts(tester); await drainToasts(tester);
}); });
@@ -579,7 +604,10 @@ void main() {
final state = AppState(); final state = AppState();
await signIn(tester, state: state); await signIn(tester, state: state);
await tester.tap(find.text('Miler assigned')); // The live card, by its reference. Home's quick-dispatch chips are
// named after cities too, so a bare 'Chennai' finder now hits a
// shortcut that starts a new booking instead.
await tester.tap(find.text('DM-482913'));
await settle(tester); await settle(tester);
// The whole shipment is on screen the moment tracking opens — five rows // The whole shipment is on screen the moment tracking opens — five rows
@@ -613,12 +641,17 @@ void main() {
final state = AppState(); final state = AppState();
await signIn(tester, state: state); await signIn(tester, state: state);
await tester.tap(find.text('Miler assigned')); // The live card, by its reference. Home's quick-dispatch chips are
// named after cities too, so a bare 'Chennai' finder now hits a
// shortcut that starts a new booking instead.
await tester.tap(find.text('DM-482913'));
await settle(tester); await settle(tester);
// The live card, not an eyebrow: a pickup in flight is the screen's one // The head card names the state in a pill and draws the route as a rail.
// crimson surface. // It used to be a filled crimson card headed "LIVE" — crimson is the
expect(find.text('LIVE'), findsOneWidget); // action colour, and tracking is a screen you read.
expect(find.text('Miler assigned'), findsWidgets);
expect(find.text('12 Nehru Street'), findsWidgets);
expect(find.text('Cancel this pickup'), findsOneWidget); expect(find.text('Cancel this pickup'), findsOneWidget);
// No per-package breakdown before collection. // No per-package breakdown before collection.
expect(find.text('YOUR PACKAGES'), findsNothing); expect(find.text('YOUR PACKAGES'), findsNothing);
@@ -638,7 +671,10 @@ void main() {
final state = AppState(); final state = AppState();
await signIn(tester, state: state); await signIn(tester, state: state);
await tester.tap(find.text('Miler assigned')); // The live card, by its reference. Home's quick-dispatch chips are
// named after cities too, so a bare 'Chennai' finder now hits a
// shortcut that starts a new booking instead.
await tester.tap(find.text('DM-482913'));
await settle(tester); await settle(tester);
await tester.tap(find.text('Cancel this pickup')); await tester.tap(find.text('Cancel this pickup'));
@@ -650,15 +686,19 @@ void main() {
await tester.tap(find.text('Yes, cancel pickup')); await tester.tap(find.text('Yes, cancel pickup'));
await settle(tester); await settle(tester);
expect(find.text('Booking cancelled'), findsOneWidget); // The head card carries the state in its pill now, not as a headline.
expect(find.textContaining('Cancelled'), findsWidgets);
final booking = state.byReference('DM-482913')!; final booking = state.byReference('DM-482913')!;
expect(booking.status, BookingStatus.cancelled); expect(booking.status, BookingStatus.cancelled);
await drainToasts(tester); await drainToasts(tester);
}); });
testWidgets('shows the empty service-area state', (tester) async { testWidgets('shows the empty service-area state', (tester) async {
await signIn(tester); // Set before signing in. Home's quick-dispatch row calls `loadCities()`
// as soon as it builds, which warms `statesCache` — good for the sheet,
// which now opens instantly, and fatal to a flag flipped afterwards.
api.flags.emptyServiceAreas = true; api.flags.emptyServiceAreas = true;
await signIn(tester);
await openSend(tester); await openSend(tester);
@@ -688,12 +728,12 @@ void main() {
testWidgets('a delivered order opens its receipt', (tester) async { testWidgets('a delivered order opens its receipt', (tester) async {
await signIn(tester); await signIn(tester);
await tester.tap(find.text('Orders')); await tester.tap(find.bySemanticsLabel('Orders'));
await settle(tester); await settle(tester);
await tester.tap(find.text('Completed')); await tester.tap(find.textContaining('Past'));
await settle(tester); await settle(tester);
await tester.tap(find.text('Salem, Tamil Nadu')); await tester.tap(find.text('Salem'));
await settle(tester); await settle(tester);
expect(find.text('Delivered'), findsWidgets); expect(find.text('Delivered'), findsWidgets);
@@ -717,12 +757,12 @@ void main() {
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
await signIn(tester); // login + OTP await signIn(tester); // login + OTP
expect(find.text('BOOK'), findsOneWidget); // home expect(find.text('BOOK NOW'), findsOneWidget); // home
await openSend(tester); // the whole booking, one screen await openSend(tester); // the whole booking, one screen
await pickCity(tester, 'Chennai'); await pickCity(tester, 'Chennai');
await tester.tap(find.text('Book pickup')); await tester.tap(find.text('Confirm Booking'));
await settle(tester); // confirmation await settle(tester); // confirmation
await tester.tap(find.text('Track pickup')); await tester.tap(find.text('Track pickup'));
await settle(tester); // tracking await settle(tester); // tracking
@@ -760,11 +800,15 @@ void main() {
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
await signIn(tester); await signIn(tester);
await tester.tap(find.text('Orders')); await tester.tap(find.bySemanticsLabel('Orders'));
await settle(tester); await settle(tester);
await tester.tap(find.text('Completed')); // At 1.8x the three tabs are wider than the screen, so the second one has
// to be scrolled to before it can be tapped.
await tester.ensureVisible(find.textContaining('Past'));
await tester.pump();
await tester.tap(find.textContaining('Past'));
await settle(tester); await settle(tester);
await tester.tap(find.text('Salem, Tamil Nadu')); await tester.tap(find.text('Salem'));
await settle(tester); // receipt await settle(tester); // receipt
await drainToasts(tester); await drainToasts(tester);
@@ -774,22 +818,26 @@ void main() {
final state = AppState(); final state = AppState();
await signIn(tester, state: state); await signIn(tester, state: state);
await tester.tap(find.text('Orders')); await tester.tap(find.bySemanticsLabel('Orders'));
await settle(tester); await settle(tester);
// Each tab carries its own count, inside the header's segmented control — // Each tab carries its own count — the figure is the rows that tab will
// and the figure is the rows that tab will actually show, which for a // actually show, which for a collected multi-destination pickup is one
// collected multi-destination pickup is one per destination. // per destination. The count shares a paragraph with the label, so it is
// a span rather than a Text of its own.
expect( expect(
find.descendant( find.textContaining(
of: find.byType(DmSegmented), 'Active (${state.entriesOf(BookingStatus.active).length})',
matching: find.text('${state.entriesOf(BookingStatus.active).length}'),
), ),
findsOneWidget, findsOneWidget,
); );
await tester.tap(find.text('Cancelled')); // The tab row scrolls — three labels plus their counts do not fit a 390pt
// phone at large text sizes.
await tester.ensureVisible(find.textContaining('Cancelled').first);
await tester.pump();
await tester.tap(find.textContaining('Cancelled').first);
await settle(tester); await settle(tester);
expect(find.text('Erode, Tamil Nadu'), findsOneWidget); expect(find.text('Erode'), findsOneWidget);
await drainToasts(tester); await drainToasts(tester);
}); });
} }

View File

@@ -28,12 +28,8 @@ import 'package:flutter_test/flutter_test.dart';
/// everything in Ahem's black boxes and the pictures are worthless. /// everything in Ahem's black boxes and the pictures are worthless.
Future<void> _loadFonts() async { Future<void> _loadFonts() async {
for (final (family, asset) in const [ for (final (family, asset) in const [
// Every cut, because Poppins is static: a FontLoader given only one file ('PlusJakartaSans', 'assets/fonts/PlusJakartaSans-Variable.ttf'),
// renders w800 headings by smearing the one weight it has. ('Switzer', 'assets/fonts/Switzer-Variable.ttf'),
('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'), ('GeistMono', 'assets/fonts/GeistMono-VF.ttf'),
// The icon font too, or every glyph in the picture is a hollow box. // The icon font too, or every glyph in the picture is a hollow box.
('packages/lucide_icons_flutter/Lucide', ('packages/lucide_icons_flutter/Lucide',
@@ -152,25 +148,31 @@ void main() {
await tester.tapAt(const Offset(200, 40)); // through the scrim await tester.tapAt(const Offset(200, 40)); // through the scrim
await _settle(tester); await _settle(tester);
await tester.tap(find.text('Orders')); await tester.tap(find.bySemanticsLabel('Orders'));
await _settle(tester); await _settle(tester);
await _shot(tester, '04-orders'); await _shot(tester, '04-orders');
await tester.tap(find.text('Account')); await tester.tap(find.bySemanticsLabel('Account'));
await _settle(tester); await _settle(tester);
await _shot(tester, '05-account'); await _shot(tester, '05-account');
await tester.tap(find.text('Home')); await tester.tap(find.bySemanticsLabel('Home'));
await _settle(tester); await _settle(tester);
await tester.tap(find.text('Miler assigned')); // The live card, by its reference. Home's quick-dispatch chips are
// named after cities too, so a bare 'Chennai' finder now hits a
// shortcut that starts a new booking instead.
await tester.tap(find.text('DM-482913'));
await _settle(tester); await _settle(tester);
await _shot(tester, '06-tracking'); await _shot(tester, '06-tracking');
await tester.dragUntilVisible( // `ensureVisible`, not `dragUntilVisible`.
find.text('Cancel this pickup'), //
find.byType(Scrollable).first, // `IndexedStack` keeps all three tab roots alive under the pushed route,
const Offset(0, -200), // so a positional `Scrollable` finder picks Home's list — which the
); // tracking screen is covering. `ensureVisible` finds the target's own
// scrollable ancestor and needs no finder at all.
await tester.ensureVisible(find.text('Cancel this pickup'));
await tester.pump();
await tester.tap(find.text('Cancel this pickup')); await tester.tap(find.text('Cancel this pickup'));
await _settle(tester); await _settle(tester);
await _shot(tester, '06b-cancel-sheet'); await _shot(tester, '06b-cancel-sheet');
@@ -181,7 +183,7 @@ void main() {
await _settle(tester); await _settle(tester);
// Booking: state, district, window, then one screen. // Booking: state, district, window, then one screen.
await tester.tap(find.text('BOOK')); await tester.tap(find.text('BOOK NOW'));
await _settle(tester); await _settle(tester);
await _shot(tester, '07-destination-states'); await _shot(tester, '07-destination-states');
@@ -189,7 +191,10 @@ void main() {
await _settle(tester); await _settle(tester);
await _shot(tester, '07b-destination-districts'); await _shot(tester, '07b-destination-districts');
await tester.tap(find.text('Chennai').first); // The live card, by its reference. Home's quick-dispatch chips are
// named after cities too, so a bare 'Chennai' finder now hits a
// shortcut that starts a new booking instead.
await tester.tap(find.text('DM-482913'));
await _settle(tester); await _settle(tester);
// The dev backend allows several destinations, so the row ticked rather // The dev backend allows several destinations, so the row ticked rather
// than closing the sheet — the choice is confirmed. // than closing the sheet — the choice is confirmed.
@@ -204,25 +209,25 @@ void main() {
// The map editor behind the FROM row: the one screen in the flow whose // The map editor behind the FROM row: the one screen in the flow whose
// subject is a picture. // subject is a picture.
await tester.tap(find.text('FROM')); await tester.tap(find.text('ORIGIN'));
await _settle(tester); await _settle(tester);
await _shot(tester, '10-pickup-map'); await _shot(tester, '10-pickup-map');
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first); await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
await _settle(tester); await _settle(tester);
await tester.tap(find.text('Book pickup')); await tester.tap(find.text('Confirm Booking'));
await _settle(tester); await _settle(tester);
await _shot(tester, '11-booked'); await _shot(tester, '11-booked');
// A finished order, for the receipt treatment. // A finished order, for the receipt treatment.
await tester.tap(find.text('Back to home')); await tester.tap(find.text('Back to home'));
await _settle(tester); await _settle(tester);
await tester.tap(find.text('Orders')); await tester.tap(find.bySemanticsLabel('Orders'));
await _settle(tester); await _settle(tester);
await tester.tap(find.text('Completed')); await tester.tap(find.textContaining('Past'));
await _settle(tester); await _settle(tester);
await _shot(tester, '04b-orders-completed'); await _shot(tester, '04b-orders-completed');
await tester.tap(find.text('Salem, Tamil Nadu')); await tester.tap(find.text('Salem'));
await _settle(tester); await _settle(tester);
await _shot(tester, '12-receipt'); await _shot(tester, '12-receipt');

View File

@@ -13,7 +13,7 @@ import 'package:http/testing.dart';
/// against the customer API document. /// against the customer API document.
/// ///
/// These are not parser tests. Every one of them exists because a plausible, /// These are not parser tests. Every one of them exists because a plausible,
/// readable, wrong spelling — `otp` for `code`, `lat` for `latitude`, `min` for /// readable, wrong spelling — `otp` for `code`, `latitude` for `lat`, `min` for
/// `minRupees` — costs a whole feature at runtime and nothing at compile time. /// `minRupees` — costs a whole feature at runtime and nothing at compile time.
/// ///
/// Where the written contract and the running server disagree, these tests /// Where the written contract and the running server disagree, these tests
@@ -96,9 +96,18 @@ void main() {
], ],
); );
// ── `lat`/`lng`, not `latitude`/`longitude` ──
//
// The written contract says the long spelling and this app sent it. The
// server reads the short one, so it saw a pickup with no coordinates, could
// not place it in a serviceable area, and answered every booking with
// 422 `unserviceable`. Verified against production on 2026-09-23 one
// request apart — same pickup, same slot: long spelling 422, short
// spelling 201 and booking DM-252803.
final pickup = t.sent.body['pickup'] as Map<String, dynamic>; final pickup = t.sent.body['pickup'] as Map<String, dynamic>;
expect(pickup['latitude'], 13.0827); expect(pickup['lat'], 13.0827);
expect(pickup['longitude'], 80.2707); expect(pickup['lng'], 80.2707);
expect(pickup.containsKey('latitude'), isFalse);
final destination = final destination =
(t.sent.body['destinations'] as List).single as Map<String, dynamic>; (t.sent.body['destinations'] as List).single as Map<String, dynamic>;
@@ -176,7 +185,7 @@ void main() {
final body = t.sent.body; final body = t.sent.body;
final pickup = body['pickup'] as Map<String, dynamic>; final pickup = body['pickup'] as Map<String, dynamic>;
expect(pickup['latitude'], 13.0827); expect(pickup['lat'], 13.0827);
expect(pickup['contactName'], 'Alex Kumar'); expect(pickup['contactName'], 'Alex Kumar');
expect(pickup['contactPhone'], '+919876543210'); expect(pickup['contactPhone'], '+919876543210');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 633 KiB

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 494 KiB

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 455 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 358 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 416 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 377 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 183 KiB