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
50
README.md
@@ -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
|
||||
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
|
||||
|
||||
A shipping build has **one** API implementation, `LiveDoormileApi`, and it talks
|
||||
|
||||
@@ -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 license is copied below, and is also available with a FAQ at:
|
||||
BIN
assets/fonts/PlusJakartaSans-Variable.ttf
Normal file
132
assets/fonts/Switzer-LICENSE.txt
Normal 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
|
||||
------------------------------------------------------------------
|
||||
BIN
assets/fonts/Switzer-Variable.ttf
Normal file
181
design/DESIGN.md
@@ -95,32 +95,28 @@ their own position without being told.
|
||||
|
||||
### 2.2 Typography
|
||||
|
||||
**Poppins for voice, Geist Mono for data.** Poppins (Indian Type Foundry, SIL
|
||||
Open Font License) is the app's voice as of 2026-09-22.
|
||||
**Switzer for voice, Geist Mono for data.** Switzer (Indian Type Foundry, via
|
||||
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,
|
||||
so `pubspec.yaml` declares one file per weight and the sans styles below set
|
||||
`fontWeight` and nothing else — `fontVariations` on a static font is ignored in
|
||||
silence, which is worse than useless, because it reads as if it were doing the
|
||||
work. Geist Mono is still variable and its three styles still carry the axis.
|
||||
It is a neue-grotesque: flat-sided bowls, near-vertical terminals, a
|
||||
straight-tailed `y`. It was chosen against a reference whose own face is almost
|
||||
certainly PP Neue Montreal or Neue Haas Grotesk Display — both commercial, and
|
||||
neither identifiable with certainty from a skewed mockup. Switzer is the
|
||||
closest thing that can actually be bundled.
|
||||
|
||||
Only the four weights the design uses are bundled — **500, 600, 700, 800** —
|
||||
because each is a separate ~155 KB file. That is ~625 KB of sans against the
|
||||
165 KB a variable file cost, and it is the price of this typeface. Asking for
|
||||
w900 anywhere would not fail; Flutter would quietly synthesise it from Bold,
|
||||
and it would look like it.
|
||||
**One variable file again, 141 KB.** Poppins held this slot for a day and has
|
||||
no variable release: four static cuts at ~155 KB each, 625 KB of sans, and for
|
||||
that release the sans styles carried `fontWeight` alone because
|
||||
`fontVariations` on a static font is ignored in silence. The axis is back, so
|
||||
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
|
||||
on near-circles and carries far more ink at the same nominal weight than a
|
||||
humanist sans. What was w900 is w800, w800 is w700, w700 is w600. At the old
|
||||
numbers the headings looked inflated rather than strong.
|
||||
|
||||
**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.
|
||||
**Tighter than anything before it.** A grotesque at display size wants pulling
|
||||
in hard, and the reference sets its headlines almost touching: the header runs
|
||||
at **−1.4**. Body sizes stay near zero. Tracking belongs to a typeface, not to
|
||||
a design, and this is the third time these numbers have been rewritten for that
|
||||
reason.
|
||||
|
||||
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
|
||||
@@ -129,13 +125,13 @@ tight.
|
||||
|
||||
| Style | Size / Height / Weight | Tracking | Used for |
|
||||
| --- | --- | --- | --- |
|
||||
| `headerTitle` | 34 / 1.0 / 800 | **−1.2** | The brand header's own title |
|
||||
| `display` | 31 / 1.1 / 800 | −0.95 | The one dominant line on a screen |
|
||||
| `title` | 25 / 1.16 / 800 | −0.65 | Screen headings on canvas |
|
||||
| `sheetTitle` | 21 / 1.2 / 800 | −0.55 | Bottom-sheet titles |
|
||||
| `heading` | 18 / 1.25 / 700 | −0.4 | The head above a group of cards |
|
||||
| `cardTitle` | 15 / 1.4 / 600 | −0.15 | A row's own line — the workhorse |
|
||||
| `body` | 15 / 1.5 / 500 | −0.1 | Paragraph text |
|
||||
| `headerTitle` | 34 / 1.0 / 800 | **−1.4** | The tab root's own title |
|
||||
| `display` | 31 / 1.1 / 800 | −1.1 | The one dominant line on a screen |
|
||||
| `title` | 25 / 1.16 / 800 | −0.8 | Screen headings on canvas |
|
||||
| `sheetTitle` | 21 / 1.2 / 800 | −0.65 | Bottom-sheet titles |
|
||||
| `heading` | 18 / 1.25 / 700 | −0.45 | The head above a group of cards |
|
||||
| `cardTitle` | 15 / 1.4 / 600 | −0.2 | A row's own line — the workhorse |
|
||||
| `body` | 15 / 1.5 / 500 | −0.12 | Paragraph text |
|
||||
| `lede` | 14.5 / 1.55 / 500 | — | Paragraph under a screen heading |
|
||||
| `small` | 13.5 / 1.45 / 500 | — | The supporting line under a row title |
|
||||
| `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
|
||||
`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**
|
||||
first — every cut, because Poppins is static and a `FontLoader` given one file
|
||||
renders w800 headings by smearing the one weight it has —
|
||||
The harness loads Switzer, Geist Mono **and the Lucide icon font** first —
|
||||
without that, Flutter's test harness draws Ahem's black boxes and hollow
|
||||
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.
|
||||
2. Ask what the screen's one decision is. If your addition is not it, it is
|
||||
|
||||
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 633 KiB After Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 494 KiB After Width: | Height: | Size: 403 KiB |
|
Before Width: | Height: | Size: 455 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 358 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 416 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 271 KiB |
|
Before Width: | Height: | Size: 377 KiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 296 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 415 KiB After Width: | Height: | Size: 317 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 183 KiB |
@@ -345,7 +345,25 @@ class LiveDoormileApi extends DoormileApi {
|
||||
// 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
|
||||
// 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': [
|
||||
for (final d in destinations)
|
||||
{
|
||||
|
||||
@@ -114,6 +114,23 @@ enum JourneyStage {
|
||||
JourneyStage.outForDelivery => 'Out for delivery',
|
||||
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.
|
||||
@@ -418,11 +435,31 @@ class Place {
|
||||
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() => {
|
||||
'title': title,
|
||||
'sub': sub,
|
||||
'latitude': ?lat,
|
||||
'longitude': ?lng,
|
||||
'lat': ?lat,
|
||||
'lng': ?lng,
|
||||
};
|
||||
|
||||
/// `POST /customer/bookings` ▸ `pickup`.
|
||||
@@ -471,6 +508,11 @@ class Destination {
|
||||
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) {
|
||||
final t = v?.trim();
|
||||
return (t == null || t.isEmpty) ? null : t;
|
||||
|
||||
@@ -111,7 +111,7 @@ ThemeData buildDoormileTheme() {
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
fontFamily: 'Poppins',
|
||||
fontFamily: 'Switzer',
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: DmColors.bg,
|
||||
splashFactory: InkSparkle.splashFactory,
|
||||
|
||||
@@ -3,14 +3,16 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../data/app_config.dart';
|
||||
import '../../state/app_scope.dart';
|
||||
import '../format.dart';
|
||||
import '../../data/models.dart';
|
||||
import '../tokens.dart';
|
||||
import '../widgets/misc.dart';
|
||||
import '../format.dart';
|
||||
import 'shell_screen.dart';
|
||||
import '../widgets/buttons.dart';
|
||||
import '../widgets/cards.dart';
|
||||
import '../widgets/chrome.dart';
|
||||
import '../widgets/feedback.dart';
|
||||
import '../widgets/inputs.dart';
|
||||
import '../widgets/misc.dart';
|
||||
import 'auth/login_screen.dart';
|
||||
import 'sheets/place_search_sheet.dart';
|
||||
|
||||
@@ -35,49 +37,133 @@ class AccountScreen extends StatelessWidget {
|
||||
final name = customer?.name ?? '';
|
||||
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(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DmBrandHeader(
|
||||
eyebrow: 'Account',
|
||||
title: name.isEmpty ? 'Your account' : name,
|
||||
leading: DmAvatar(initials: initialsOf(name), onBrand: true),
|
||||
action: DmIconButton(
|
||||
icon: LucideIcons.circleHelp,
|
||||
tooltip: 'Help',
|
||||
background: DmColors.onBrandRaise,
|
||||
foreground: Colors.white,
|
||||
border: Colors.transparent,
|
||||
radius: DmRadius.lg,
|
||||
size: 42,
|
||||
onPressed: () => DmToast.show(context, 'Support is on the way'),
|
||||
),
|
||||
child: phone.isEmpty
|
||||
? null
|
||||
: DmHeaderTrack(
|
||||
trailing: DmTextAction(
|
||||
label: 'Edit',
|
||||
color: Colors.white,
|
||||
minHeight: 24,
|
||||
onPressed: () => DmToast.show(context, 'Editing is coming soon'),
|
||||
DmAppBar(
|
||||
unread: app.activeBookings.isNotEmpty,
|
||||
onSearch: () => ShellScreen.of(context)?.goToTab(1),
|
||||
onNotifications: () =>
|
||||
DmToast.show(context, 'Nothing new right now'),
|
||||
),
|
||||
// ── The profile, centred ──
|
||||
//
|
||||
// It was a 34pt name left-aligned with the number under it, which is
|
||||
// the shape a *list* screen takes. The reference puts the person at
|
||||
// the top of their own account: a large avatar with an edit badge, the
|
||||
// name, the number, and the account's standing — and that is what
|
||||
// makes Account read as somewhere you are rather than as a menu.
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 6, DmSpace.pad, 18),
|
||||
child: Column(
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
DmAvatar(initials: initialsOf(name), size: 86),
|
||||
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(
|
||||
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: [
|
||||
const DmMicroHead('Preferences', top: 16, brand: false),
|
||||
const DmMicroHead('Preferences', top: 4, brand: false),
|
||||
DmRowGroup(
|
||||
children: [
|
||||
DmRow(
|
||||
icon: LucideIcons.mapPin,
|
||||
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,
|
||||
onTap: () async {
|
||||
final place = await showPlaceSearchSheet(context);
|
||||
@@ -90,19 +176,49 @@ class AccountScreen extends StatelessWidget {
|
||||
const DmRow(
|
||||
icon: LucideIcons.bell,
|
||||
label: 'Notifications',
|
||||
value: 'SMS and push',
|
||||
note: 'Real-time SMS and push',
|
||||
showChevron: true,
|
||||
),
|
||||
const DmRow(
|
||||
icon: LucideIcons.wallet,
|
||||
label: 'Payment methods',
|
||||
value: 'UPI',
|
||||
note: 'UPI, on delivery',
|
||||
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,
|
||||
label: 'Help and support',
|
||||
note: 'Talk to the courier desk',
|
||||
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.
|
||||
if (!AppConfig.isProd || AppConfig.authBypassed) ...[
|
||||
const DmMicroHead('Build', brand: false),
|
||||
DmCard(
|
||||
tone: DmCardTone.sunken,
|
||||
children: [
|
||||
DmCardCell(
|
||||
// Not a DmRow: that lays a short label against a short
|
||||
// value on one line, and this value is a URL plus every
|
||||
// flag the build carries. It overflowed by 59px the
|
||||
// moment a bypass was on — which is exactly when someone
|
||||
// needs to read it.
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('API', style: DmText.eyebrow),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
AppConfig.describe,
|
||||
style: DmText.mono.copyWith(
|
||||
height: 1.5,
|
||||
color: DmColors.ink2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Not a DmRow: that lays a short label against a short value
|
||||
// on one line, and this value is a URL plus every flag the
|
||||
// build carries. It overflowed by 59px the moment a bypass was
|
||||
// on — which is exactly when someone needs to read it.
|
||||
//
|
||||
// Not a card either. It is a line of mono on the page, like
|
||||
// every other thing on this screen.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2, bottom: 4),
|
||||
child: Text(
|
||||
AppConfig.describe,
|
||||
style: DmText.mono.copyWith(
|
||||
height: 1.5,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
DmButton(
|
||||
label: 'Sign out',
|
||||
label: 'Log Out',
|
||||
icon: LucideIcons.logOut,
|
||||
iconLeading: true,
|
||||
kind: DmButtonKind.ghost,
|
||||
danger: true,
|
||||
onPressed: () {
|
||||
@@ -156,9 +265,9 @@ class AccountScreen extends StatelessWidget {
|
||||
TextSpan(
|
||||
style: DmText.tiny.copyWith(color: DmColors.ink4),
|
||||
children: [
|
||||
const TextSpan(text: 'Doormile Customer '),
|
||||
const TextSpan(text: 'DOORMILE CUSTOMER '),
|
||||
TextSpan(
|
||||
text: 'v1.0.0',
|
||||
text: 'v${AppConfig.appVersion}',
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import '../../tokens.dart';
|
||||
import '../../widgets/buttons.dart';
|
||||
import '../../widgets/cards.dart';
|
||||
import '../../widgets/chrome.dart';
|
||||
import '../../widgets/misc.dart';
|
||||
import '../../widgets/pieces.dart';
|
||||
import '../../widgets/route_rail.dart';
|
||||
import '../tracking_screen.dart';
|
||||
@@ -145,22 +144,24 @@ class _ConfirmedScreenState extends State<ConfirmedScreen>
|
||||
),
|
||||
if (booking != null) ...[
|
||||
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(
|
||||
tone: DmCardTone.wash,
|
||||
raised: true,
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(16, 13, 8, 13),
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 8, 14),
|
||||
child: DmCopyRow(
|
||||
label: 'Booking reference',
|
||||
value: booking.reference,
|
||||
onWash: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
DmCard(
|
||||
children: [
|
||||
DmCardCell(
|
||||
child: DmRouteRail(
|
||||
pickup: booking.pickup.title,
|
||||
@@ -184,21 +185,22 @@ class _ConfirmedScreenState extends State<ConfirmedScreen>
|
||||
DmCardCell(
|
||||
background: DmColors.brandSoft,
|
||||
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(
|
||||
children: [
|
||||
const DmPill('Searching', tone: DmPillTone.live),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Finding the nearest Miler',
|
||||
style: DmText.small.copyWith(
|
||||
color: DmColors.brand,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
style: DmText.cardTitle.copyWith(fontSize: 14.5),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const SizedBox(
|
||||
width: 15,
|
||||
height: 15,
|
||||
|
||||
@@ -5,9 +5,11 @@ import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../../data/doormile_api.dart';
|
||||
import '../../../data/models.dart';
|
||||
import '../../../state/app_scope.dart';
|
||||
import '../../../state/app_state.dart';
|
||||
import '../../tokens.dart';
|
||||
import '../../widgets/cards.dart';
|
||||
import '../../widgets/buttons.dart';
|
||||
import '../../widgets/chrome.dart';
|
||||
import '../../widgets/feedback.dart';
|
||||
@@ -108,9 +110,8 @@ class _SendScreenState extends State<SendScreen> {
|
||||
final slot = app.slotById(app.draftSlotId);
|
||||
final fare = app.draftFare;
|
||||
|
||||
final ready = city.districtCode != null &&
|
||||
app.draftSlotId != null &&
|
||||
app.draftReady;
|
||||
final ready =
|
||||
city.districtCode != null && app.draftSlotId != null && app.draftReady;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: DmColors.canvas,
|
||||
@@ -118,79 +119,152 @@ class _SendScreenState extends State<SendScreen> {
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 24),
|
||||
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
|
||||
// and a Change action, then a bordered line with a label, an
|
||||
// address and a second Change. Seven strings and two identical
|
||||
// words to say what a thread between two points says without any.
|
||||
_RouteHead(
|
||||
from: pickup?.title ?? 'Set a pickup address',
|
||||
to: [for (final g in app.draftDestinations) g.destination.label],
|
||||
onTapFrom: () => Navigator.of(context).push(
|
||||
bookingRoute<void>(
|
||||
BookingRoutes.pickup,
|
||||
(_) => const PickupLocationScreen(),
|
||||
),
|
||||
),
|
||||
onTapTo: () async {
|
||||
final picked = await showDestinationSheet(context);
|
||||
if (picked != null && picked.isNotEmpty && context.mounted) {
|
||||
app.setDestinations(picked);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
// A numbered chip and the step in words, because a customer who
|
||||
// arrived here from Home's sphere has answered two questions and
|
||||
// wants to know this is the last one. Three dots said the same thing
|
||||
// and made you count them.
|
||||
const _StepHead(step: 3, of: 3),
|
||||
const SizedBox(height: 14),
|
||||
// One row, no card around it: a card holding a single control is a
|
||||
// border drawn for its own sake.
|
||||
Row(
|
||||
// The route, as one card with the two ends and a thread between
|
||||
// them. It was four ruled rows of equal weight, so where it comes
|
||||
// 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: [
|
||||
const Icon(
|
||||
LucideIcons.package,
|
||||
size: 17,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${group.packageCount} '
|
||||
'${group.packageCount == 1 ? 'package' : 'packages'}',
|
||||
style: DmText.cardTitle,
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
|
||||
child: _RouteHead(
|
||||
from: pickup?.title ?? 'Set a pickup address',
|
||||
fromNote: pickup?.sub,
|
||||
to: [for (final g in app.draftDestinations) g.destination],
|
||||
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);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
DmStepper(
|
||||
value: group.packageCount,
|
||||
min: 1,
|
||||
canIncrease: app.canAddPackageTo(0),
|
||||
onChanged: (v) => app.setPackageCount(0, v),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
// Was three sentences about weighing, charging and cancelling. The
|
||||
// customer is about to commit, so the only part that changes the
|
||||
// decision is that committing costs nothing yet.
|
||||
Text(
|
||||
'Nothing is charged now · Free cancellation',
|
||||
textAlign: TextAlign.center,
|
||||
style: DmText.tiny.copyWith(color: DmColors.ink3),
|
||||
const SizedBox(height: 10),
|
||||
_DetailCard(
|
||||
icon: LucideIcons.calendar,
|
||||
label: 'PICKUP WINDOW',
|
||||
value: slot == null
|
||||
? 'Choose a window'
|
||||
: '${slot.day}, ${slot.window}',
|
||||
onTap: () => showWindowSheet(context),
|
||||
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(
|
||||
edge: true,
|
||||
children: [
|
||||
_Commitments(
|
||||
window: slot == null ? null : '${slot.day} · ${slot.window}',
|
||||
price: fare?.range,
|
||||
onChangeWindow: () => showWindowSheet(context),
|
||||
// ── Stacked, and left ──
|
||||
//
|
||||
// Label opposite figure meant the two were competing for one line,
|
||||
// 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(
|
||||
label: 'Book pickup',
|
||||
label: 'Confirm Booking',
|
||||
icon: ready ? LucideIcons.arrowRight : null,
|
||||
busy: _busy,
|
||||
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
|
||||
/// 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.
|
||||
/// ── Dots → a numbered chip ──
|
||||
///
|
||||
/// There are no "Change" labels. A chevron at the end of a row is the whole
|
||||
/// sentence, and this screen had two of the word stacked one above the other.
|
||||
class _RouteHead extends StatelessWidget {
|
||||
const _RouteHead({
|
||||
required this.from,
|
||||
required this.to,
|
||||
required this.onTapFrom,
|
||||
required this.onTapTo,
|
||||
});
|
||||
/// Three dots with the current one elongated is a shape you have to count.
|
||||
/// A numeral in a disc and "Step 3 of 3" beside it is the same information
|
||||
/// read rather than parsed, and it leaves the screen's own title to the thing
|
||||
/// the customer is about to press.
|
||||
class _StepHead extends StatelessWidget {
|
||||
const _StepHead({required this.step, required this.of});
|
||||
|
||||
final String from;
|
||||
|
||||
/// One row per destination. A pickup that fans out to three places is three
|
||||
/// rows on one thread, not "3 destinations" standing in for them — the whole
|
||||
/// point of the screen is that the customer can see what they arranged.
|
||||
final List<String> to;
|
||||
|
||||
final VoidCallback onTapFrom;
|
||||
final VoidCallback onTapTo;
|
||||
final int step;
|
||||
final int of;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: DmColors.border)),
|
||||
),
|
||||
child: Column(
|
||||
return Semantics(
|
||||
header: true,
|
||||
label: 'Step $step of $of',
|
||||
excludeSemantics: true,
|
||||
child: Row(
|
||||
children: [
|
||||
_RouteRow(
|
||||
icon: LucideIcons.locateFixed,
|
||||
eyebrow: 'FROM',
|
||||
value: from,
|
||||
onTap: onTapFrom,
|
||||
),
|
||||
for (var i = 0; i < to.length; i++)
|
||||
_RouteRow(
|
||||
icon: LucideIcons.mapPin,
|
||||
// Numbered only when there is more than one, because "TO 1" on a
|
||||
// single-destination booking is a count of one.
|
||||
eyebrow: to.length == 1 ? 'TO' : 'TO · ${i + 1}',
|
||||
value: to[i],
|
||||
onTap: onTapTo,
|
||||
strong: true,
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
color: DmColors.ink,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
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 {
|
||||
const _RouteRow({
|
||||
required this.icon,
|
||||
required this.eyebrow,
|
||||
required this.value,
|
||||
required this.onTap,
|
||||
this.strong = false,
|
||||
/// Where it comes from, where it goes, on one thread.
|
||||
///
|
||||
/// The two ends of a journey are not two rows of a list. A black dot for the
|
||||
/// door it leaves and a brand pin for the door it arrives at, with a rule
|
||||
/// running between them, says *route* in a way four equal rows cannot.
|
||||
class _RouteHead extends StatelessWidget {
|
||||
const _RouteHead({
|
||||
required this.from,
|
||||
required this.to,
|
||||
required this.onTapFrom,
|
||||
required this.onTapTo,
|
||||
this.fromNote,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String eyebrow;
|
||||
final String value;
|
||||
final VoidCallback onTap;
|
||||
final String from;
|
||||
final String? fromNote;
|
||||
|
||||
/// The destination is set in the brand colour: it is the only choice on this
|
||||
/// screen that moves the price.
|
||||
final bool strong;
|
||||
/// One stop per destination. A pickup that fans out to three places is three
|
||||
/// stops on one thread, not "3 destinations" standing in for them.
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: '$eyebrow $value',
|
||||
label: '$label $value',
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: DmSpace.tap),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: strong ? DmColors.brand : DmColors.ink3,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
// The node and the thread it hangs from. The thread is drawn by
|
||||
// the stop *above* the join, so the last one ends clean.
|
||||
SizedBox(
|
||||
width: 22,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(eyebrow, style: DmText.eyebrow),
|
||||
Text(
|
||||
value,
|
||||
// One weight down from where this started. At w700
|
||||
// crimson, five destinations stacked read as five
|
||||
// headings — the page shouted its own contents back. A
|
||||
// list of places the customer chose is a list, not a
|
||||
// series of titles.
|
||||
style: DmText.body.copyWith(
|
||||
fontSize: 15.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: strong ? DmColors.brand : DmColors.ink,
|
||||
const SizedBox(height: 15),
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: origin ? DmColors.ink : DmColors.brand,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (!last)
|
||||
const Expanded(
|
||||
child: VerticalDivider(
|
||||
width: 1,
|
||||
thickness: 1,
|
||||
color: DmColors.border,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 17,
|
||||
color: DmColors.ink4,
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(0, 8, 0, last ? 4 : 16),
|
||||
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.
|
||||
///
|
||||
/// The window used to be a row in a card in the middle of the form and the
|
||||
/// price used to appear in the footer only once everything was valid, which
|
||||
/// meant the customer had to scroll back up to check what time they had been
|
||||
/// given. Both live here now, and the left half is tappable.
|
||||
class _Commitments extends StatelessWidget {
|
||||
const _Commitments({
|
||||
required this.window,
|
||||
required this.price,
|
||||
required this.onChangeWindow,
|
||||
/// One particular of the booking, on its own card: a glyph well, a micro-label
|
||||
/// over a value, and whatever acts on it.
|
||||
class _DetailCard extends StatelessWidget {
|
||||
const _DetailCard({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.note,
|
||||
this.onTap,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
final String? window;
|
||||
final String? price;
|
||||
final VoidCallback onChangeWindow;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final String? note;
|
||||
final VoidCallback? onTap;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
return DmCard(
|
||||
onTap: onTap,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Semantics(
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onChangeWindow,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('PICKUP', style: DmText.eyebrow),
|
||||
const SizedBox(height: 3),
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
window ?? 'Choose a window',
|
||||
style: DmText.cardTitle.copyWith(
|
||||
color: window == null ? DmColors.brand : null,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surfaceAlt,
|
||||
borderRadius: DmRadius.all(DmRadius.xs),
|
||||
),
|
||||
child: Icon(icon, size: 18, color: DmColors.ink2),
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label, style: DmText.eyebrow),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
|
||||
import '../../data/models.dart';
|
||||
import '../../state/app_scope.dart';
|
||||
import '../format.dart';
|
||||
import '../tokens.dart';
|
||||
import 'shell_screen.dart';
|
||||
import '../format.dart';
|
||||
import '../widgets/cards.dart';
|
||||
import '../widgets/book_orb.dart';
|
||||
import '../widgets/chrome.dart';
|
||||
import '../widgets/misc.dart';
|
||||
import 'booking/booking_routes.dart';
|
||||
import 'booking/send_screen.dart';
|
||||
import 'shell_screen.dart';
|
||||
import 'sheets/destination_sheet.dart';
|
||||
import 'sheets/place_search_sheet.dart';
|
||||
import 'sheets/window_sheet.dart';
|
||||
@@ -89,105 +91,179 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context);
|
||||
final active = app.activeBookings.isEmpty ? null : app.activeBookings.first;
|
||||
final firstRun = app.orders.isEmpty;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DmBrandHeader(
|
||||
eyebrow: 'Welcome, ${_firstName(app.customer?.name)}',
|
||||
title: firstRun ? 'Send your first parcel' : 'Send a parcel',
|
||||
action: InkResponse(
|
||||
onTap: () => ShellScreen.of(context)?.goToTab(2),
|
||||
radius: 26,
|
||||
child: DmAvatar(
|
||||
initials: initialsOf(app.customer?.name),
|
||||
onBrand: true,
|
||||
// The sphere's own field, floored so a very short window leaves it a
|
||||
// sphere rather than a slot. It is a *minimum*: the scroll view hands it
|
||||
// whatever is left once the card below has taken what it needs.
|
||||
final orbHeight = math.max(300.0, MediaQuery.sizeOf(context).height * 0.38);
|
||||
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// No wordmark bar here.
|
||||
//
|
||||
// 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
|
||||
// in the header as a live value the customer can change in place —
|
||||
// not as the first question of the flow.
|
||||
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,
|
||||
Expanded(
|
||||
child: AnimatedScale(
|
||||
scale: _handingOver ? 0.96 : 1,
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
child: RefreshIndicator(
|
||||
color: DmColors.brand,
|
||||
backgroundColor: DmColors.surface,
|
||||
onRefresh: () async {
|
||||
await Future.wait([
|
||||
app.detectPickupLocation(),
|
||||
app.refreshOrders(),
|
||||
]);
|
||||
},
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
16,
|
||||
DmSpace.pad,
|
||||
28,
|
||||
),
|
||||
children: [
|
||||
const SizedBox(height: 6),
|
||||
DmBookOrb(
|
||||
onTap: _book,
|
||||
held: _handingOver,
|
||||
// A returning customer knows what BOOK does. The line is
|
||||
// for the first run only, and one line is enough to say
|
||||
// what kind of app this is.
|
||||
caption: firstRun
|
||||
? 'A Miler collects from your door.'
|
||||
: null,
|
||||
).animate().fadeIn(duration: 420.ms).scale(
|
||||
begin: const Offset(0.82, 0.82),
|
||||
end: const Offset(1, 1),
|
||||
duration: 620.ms,
|
||||
// A shade of overshoot, so it arrives rather than
|
||||
// appears.
|
||||
curve: Curves.easeOutBack,
|
||||
),
|
||||
if (active != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 22),
|
||||
child: _LiveRow(booking: active),
|
||||
).animate().fadeIn(duration: 380.ms, delay: 160.ms).slideY(
|
||||
begin: 0.12,
|
||||
curve: DmMotion.ease,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _handingOver ? 0.45 : 1,
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
child: RefreshIndicator(
|
||||
color: DmColors.brand,
|
||||
backgroundColor: DmColors.surface,
|
||||
onRefresh: () async {
|
||||
await Future.wait([
|
||||
app.detectPickupLocation(),
|
||||
app.refreshOrders(),
|
||||
]);
|
||||
},
|
||||
child: LayoutBuilder(
|
||||
builder: (context, box) => ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
0,
|
||||
DmSpace.pad,
|
||||
0,
|
||||
),
|
||||
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
|
||||
/// things from it — *what is happening* and *how long* — and everything else it
|
||||
/// carried was available one tap away on the screen this row opens. The route,
|
||||
/// the reference and the package count all went back there.
|
||||
class _LiveRow extends StatelessWidget {
|
||||
const _LiveRow({required this.booking});
|
||||
/// ── The loader went, and the card grew ──
|
||||
///
|
||||
/// It carried a thin filled line under the destination — a progress bar across
|
||||
/// the five customer milestones. It was the one fact that cannot be written as
|
||||
/// a word, and it still read as a *loader*: a bar that fills is the shape an
|
||||
/// 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;
|
||||
|
||||
@@ -212,162 +298,259 @@ class _LiveRow extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.read(context);
|
||||
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:
|
||||
// before the handover that is when the Miler is coming, after it that is
|
||||
// where the parcel is going.
|
||||
final support = switch (booking.stage) {
|
||||
// The district alone. "Chennai, Tamil Nadu" shares its row with the state
|
||||
// and the reference, and on a 402pt device it rendered "Chenna…" — the
|
||||
// half of the name that identifies nothing.
|
||||
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
|
||||
when slot != null =>
|
||||
'${slot.day} · ${slot.window}',
|
||||
JourneyStage.arrived => 'At your door now',
|
||||
_ =>
|
||||
booking.isMultiDestination
|
||||
? 'To ${booking.destinations.length} destinations'
|
||||
: 'To ${booking.destination.label}',
|
||||
_ when booking.expectedDelivery != null =>
|
||||
'Expected ${booking.expectedDelivery}',
|
||||
_ => booking.stage.milestoneLabel,
|
||||
};
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
app.openTracking(booking.reference);
|
||||
Navigator.of(context).push(
|
||||
bookingRoute<void>(
|
||||
BookingRoutes.tracking,
|
||||
(_) => const TrackingScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 64),
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 12, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.brandSoft,
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
border: Border.all(color: DmColors.brandLine),
|
||||
return DmCard(
|
||||
raised: true,
|
||||
onTap: () {
|
||||
app.openTracking(booking.reference);
|
||||
Navigator.of(context).push(
|
||||
bookingRoute<void>(
|
||||
BookingRoutes.tracking,
|
||||
(_) => const TrackingScreen(),
|
||||
),
|
||||
child: Row(
|
||||
);
|
||||
},
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(18, 17, 18, 18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const DmLiveDot(size: 7),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
booking.stage.milestoneLabel,
|
||||
style: DmText.cardTitle.copyWith(
|
||||
color: DmColors.brandPress,
|
||||
Row(
|
||||
children: [
|
||||
const DmLiveDot(size: 7),
|
||||
const SizedBox(width: 9),
|
||||
Flexible(
|
||||
child: Text(
|
||||
booking.stage.chipLabel.toUpperCase(),
|
||||
style: DmText.eyebrow.copyWith(
|
||||
fontSize: 10,
|
||||
letterSpacing: 0.9,
|
||||
color: DmColors.brand,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
support,
|
||||
style: DmText.small,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Text(
|
||||
booking.displayId,
|
||||
style: DmText.monoSm.copyWith(color: DmColors.ink3),
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 1,
|
||||
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 is a timeline now — glyph, hairline, title — and the subtitles are gone:
|
||||
/// they described the step the title had already named, on the one screen where
|
||||
/// a new customer is least willing to read.
|
||||
class _HowItWorks extends StatelessWidget {
|
||||
const _HowItWorks();
|
||||
|
||||
static const _steps = [
|
||||
(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});
|
||||
/// It was a full sentence — "A Miler collects from your door." — shown on the
|
||||
/// first run only. That is the app explaining itself to somebody who has
|
||||
/// already pressed nothing; what a customer actually wants before they commit
|
||||
/// is how long they will wait, and that is worth saying every time.
|
||||
class _PromiseChip extends StatelessWidget {
|
||||
const _PromiseChip({required this.icon, required this.label});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final bool last;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
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(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// The rail: a washed disc per step, joined by a hairline that runs
|
||||
// the height of that step's text. Sized by [IntrinsicHeight] so the
|
||||
// connector always meets the next disc, whatever the text wraps to.
|
||||
SizedBox(
|
||||
width: 34,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: DmColors.brandSoft,
|
||||
),
|
||||
child: Icon(icon, size: 15, color: DmColors.brand),
|
||||
),
|
||||
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),
|
||||
),
|
||||
Icon(icon, size: 13, color: DmColors.brand),
|
||||
const SizedBox(width: 7),
|
||||
Text(
|
||||
label,
|
||||
style: DmText.label.copyWith(fontSize: 12.5, color: DmColors.ink),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -97,8 +97,19 @@ class OrderDetailsScreen extends StatelessWidget {
|
||||
],
|
||||
)
|
||||
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(
|
||||
tone: DmCardTone.fill,
|
||||
raised: true,
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 18),
|
||||
@@ -110,13 +121,13 @@ class OrderDetailsScreen extends StatelessWidget {
|
||||
height: 42,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
color: DmColors.onBrandRaise,
|
||||
color: DmColors.okSoft,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.check,
|
||||
size: 22,
|
||||
color: Colors.white,
|
||||
color: DmColors.ok,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
@@ -124,16 +135,13 @@ class OrderDetailsScreen extends StatelessWidget {
|
||||
recipient == null
|
||||
? 'Delivered'
|
||||
: 'Delivered to $recipient',
|
||||
style: DmText.display.copyWith(
|
||||
fontSize: 26,
|
||||
color: Colors.white,
|
||||
),
|
||||
style: DmText.display.copyWith(fontSize: 26),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${booking.destination.districtName} · '
|
||||
'${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(
|
||||
LucideIcons.package,
|
||||
size: 15,
|
||||
color: DmColors.onBrand3,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
@@ -153,14 +161,14 @@ class OrderDetailsScreen extends StatelessWidget {
|
||||
booking.packageSummary ??
|
||||
'${booking.totalPackages} '
|
||||
'${booking.totalPackages == 1 ? 'package' : 'packages'}',
|
||||
style: DmText.small.copyWith(color: DmColors.onBrand2),
|
||||
style: DmText.small.copyWith(color: DmColors.ink2),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
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),
|
||||
child: Text(
|
||||
'PACKAGES',
|
||||
style: DmText.eyebrow.copyWith(color: DmColors.brand),
|
||||
style: DmText.eyebrow,
|
||||
),
|
||||
),
|
||||
for (final group in booking.destinations)
|
||||
@@ -231,7 +239,7 @@ class OrderDetailsScreen extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Text(
|
||||
'PAYMENT',
|
||||
style: DmText.eyebrow.copyWith(color: DmColors.brand),
|
||||
style: DmText.eyebrow,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
@@ -370,7 +378,7 @@ class _VerificationCard extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Text(
|
||||
'COLLECTED AT YOUR DOOR',
|
||||
style: DmText.eyebrow.copyWith(color: DmColors.brand),
|
||||
style: DmText.eyebrow,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
@@ -558,12 +566,15 @@ class _Card extends StatelessWidget {
|
||||
|
||||
@override
|
||||
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(
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
border: Border.all(color: DmColors.border),
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
borderRadius: DmRadius.all(DmRadius.card),
|
||||
boxShadow: DmShadow.card,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
||||
@@ -4,32 +4,35 @@ import '../../data/models.dart';
|
||||
import '../../state/app_scope.dart';
|
||||
import '../format.dart';
|
||||
import '../tokens.dart';
|
||||
import '../widgets/chrome.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../widgets/cards.dart';
|
||||
import '../widgets/misc.dart';
|
||||
import '../widgets/route_rail.dart';
|
||||
import 'booking/booking_routes.dart';
|
||||
import 'order_details_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
|
||||
/// its own tracking number and journey, so it gets its own card with a quiet
|
||||
/// line back to the visit they were collected in.
|
||||
/// ── The shape it landed on ──
|
||||
///
|
||||
/// ── 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
|
||||
/// my parcel — so the state is the first thing on every card, at the same
|
||||
/// height on each.
|
||||
/// #DM-6912 ● OUT FOR DELIVERY ⏱ Arriving in 30m
|
||||
/// Bengaluru 1 parcel · 2.4 kg
|
||||
/// 38 Mettupalayam Road → Indiranagar
|
||||
/// ─────────────────────────────────────────────────────
|
||||
/// ⊕ Rajesh K. · On the way [ Track → ]
|
||||
///
|
||||
/// The reference used to share a crimson-washed strip with it, which gave a
|
||||
/// string nobody reads while scanning the same weight as the only word they do.
|
||||
/// It is a quiet identifier on the same line now, and the strip is gone: one
|
||||
/// cell, not two.
|
||||
/// identity, destination, route, and whatever is happening right now. The
|
||||
/// fourth band is what stops four orders reading as four instances of one
|
||||
/// widget: it carries the Miler when there is one, the window when there is
|
||||
/// 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
|
||||
/// pickup, which is a question asked on the order, not in a list of them.
|
||||
/// Hub names — "Whitefield Hub → Indiranagar 100ft Rd" in the reference — are
|
||||
/// 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 {
|
||||
const OrderCard({super.key, required this.entry});
|
||||
|
||||
@@ -43,104 +46,190 @@ class OrderCard extends StatelessWidget {
|
||||
final live = booking.status == BookingStatus.active;
|
||||
|
||||
final stage = group?.stage ?? booking.stage;
|
||||
final (label, tone) = switch (booking.status) {
|
||||
BookingStatus.cancelled => ('Cancelled', DmPillTone.off),
|
||||
BookingStatus.completed => ('Delivered', DmPillTone.done),
|
||||
BookingStatus.active => (
|
||||
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 (label, Color tone) = switch (booking.status) {
|
||||
BookingStatus.cancelled => ('Cancelled', DmColors.ink4),
|
||||
BookingStatus.completed => ('Delivered', DmColors.ok),
|
||||
BookingStatus.active => (stage.chipLabel, DmColors.brand),
|
||||
};
|
||||
|
||||
final drop = entry.isPickup
|
||||
final destination = entry.isPickup
|
||||
? (booking.isMultiDestination
|
||||
? '${booking.destinations.length} destinations'
|
||||
: booking.destination.label)
|
||||
: group!.destination.label;
|
||||
: booking.destination.shortLabel)
|
||||
: group!.destination.shortLabel;
|
||||
|
||||
final reference = entry.isPickup
|
||||
? booking.reference
|
||||
: (group!.trackingId ?? booking.reference);
|
||||
|
||||
final packages = entry.isPickup ? booking.totalPackages : group!.packageCount;
|
||||
final meta = [
|
||||
'$packages ${packages == 1 ? 'package' : 'packages'}',
|
||||
if (booking.status == BookingStatus.completed && booking.amountPaid != null)
|
||||
'₹${booking.amountPaid} paid',
|
||||
final slot = app.slotById(booking.slotId);
|
||||
|
||||
// Top right: when. An ETA while a Miler is moving, otherwise the window
|
||||
// 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(' · ');
|
||||
|
||||
// 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(
|
||||
// The spine marks the one card the customer has to do something about.
|
||||
rail: live && stage == JourneyStage.arrived,
|
||||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
raised: live,
|
||||
onTap: open,
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 15),
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
// The date belongs at the right edge, on the same line on
|
||||
// every card, so the eye can run down the column. Left to the
|
||||
// default packing it sits wherever the status text happens to
|
||||
// end, which is a different place on all four.
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// Flexible, not Expanded: the pill is sized by its own text
|
||||
// and only gives way if the row runs out of room. Expanded
|
||||
// made it fill every spare pixel, which drew "In transit"
|
||||
// as a 600pt bar with one word in it. Expanding the *date*
|
||||
// instead — the version before that — clipped the only word
|
||||
// on the row that matters, to make space for "Yesterday".
|
||||
// Neither of them is flexible; the pill is.
|
||||
Flexible(child: DmPill(label, tone: tone)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 10),
|
||||
child: Text(
|
||||
dateLabel(booking.createdAt),
|
||||
maxLines: 1,
|
||||
style: DmText.tiny,
|
||||
Flexible(child: _RefChip(reference: reference)),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: DmStatusChip(
|
||||
label: label,
|
||||
tone: tone,
|
||||
quiet: !live,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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),
|
||||
DmRouteRail(
|
||||
compact: true,
|
||||
pickup: booking.pickup.title,
|
||||
drop: drop,
|
||||
),
|
||||
const SizedBox(height: 11),
|
||||
const SizedBox(height: 10),
|
||||
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,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Expanded(child: Text(meta, style: DmText.tiny)),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
reference,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.monoSm.copyWith(color: DmColors.ink4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
destination,
|
||||
style: DmText.headerTitle.copyWith(
|
||||
fontSize: 26,
|
||||
color: live ? DmColors.ink : DmColors.ink2,
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,54 +1,114 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../data/models.dart';
|
||||
import '../../state/app_scope.dart';
|
||||
import '../format.dart';
|
||||
import '../tokens.dart';
|
||||
import '../widgets/buttons.dart';
|
||||
import '../widgets/inputs.dart';
|
||||
import '../widgets/feedback.dart';
|
||||
import '../widgets/chrome.dart';
|
||||
import '../widgets/states.dart';
|
||||
import 'order_row.dart';
|
||||
|
||||
/// Orders — current bookings and past ones, as route cards.
|
||||
///
|
||||
/// The month, the title and the filter all live inside one crimson field at
|
||||
/// 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
|
||||
/// no chrome of its own — no title bar competing with the first card, no
|
||||
/// separate count row, because each tab carries its own figure.
|
||||
class OrdersScreen extends StatelessWidget {
|
||||
/// ── The filter came out of the header ──
|
||||
///
|
||||
/// It was a segmented control recessed into the crimson field, which made it
|
||||
/// look like part of the title bar rather than like something to press. A
|
||||
/// filter is a control the customer uses on every visit; it belongs on the
|
||||
/// 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});
|
||||
|
||||
@override
|
||||
State<OrdersScreen> createState() => _OrdersScreenState();
|
||||
}
|
||||
|
||||
class _OrdersScreenState extends State<OrdersScreen> {
|
||||
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
|
||||
Widget build(BuildContext 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);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DmBrandHeader(
|
||||
eyebrow: monthLabel(DateTime.now()),
|
||||
title: 'Orders',
|
||||
action: DmIconButton(
|
||||
icon: LucideIcons.refreshCw,
|
||||
tooltip: 'Refresh',
|
||||
background: DmColors.onBrandRaise,
|
||||
foreground: Colors.white,
|
||||
border: Colors.transparent,
|
||||
radius: DmRadius.lg,
|
||||
size: 42,
|
||||
onPressed: app.refreshOrders,
|
||||
),
|
||||
child: DmSegmented(
|
||||
labels: const ['Active', 'Completed', 'Cancelled'],
|
||||
counts: [for (final tab in _tabs) app.entriesOf(tab).length],
|
||||
index: index < 0 ? 0 : index,
|
||||
onChanged: (i) => app.setOrdersTab(_tabs[i]),
|
||||
DmAppBar(
|
||||
unread: app.activeBookings.isNotEmpty,
|
||||
onSearch: () => DmToast.show(context, 'Search is coming soon'),
|
||||
onNotifications: () =>
|
||||
DmToast.show(context, 'Nothing new right now'),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 4, DmSpace.pad, 14),
|
||||
// The filter gets its own row.
|
||||
//
|
||||
// Beside the title it had a third of the width for three segments,
|
||||
// and rendered "Act… Pa… Ca…" — the reference fits its control on
|
||||
// the title line because it carries two segments, not three. A row
|
||||
// is cheaper than an unreadable control.
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
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(
|
||||
@@ -58,11 +118,14 @@ class OrdersScreen extends StatelessWidget {
|
||||
onRefresh: app.refreshOrders,
|
||||
child: ListView.separated(
|
||||
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,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, i) =>
|
||||
rows.isEmpty ? _empty(app.ordersTab) : OrderCard(entry: rows[i]),
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, i) => rows.isEmpty
|
||||
? (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) {
|
||||
final (title, message) = switch (tab) {
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import '../../../state/app_scope.dart';
|
||||
import '../../state_marks.dart';
|
||||
import '../../tokens.dart';
|
||||
import '../../widgets/buttons.dart';
|
||||
import '../../widgets/cards.dart';
|
||||
import '../../widgets/feedback.dart';
|
||||
import '../../widgets/inputs.dart';
|
||||
import '../../widgets/states.dart';
|
||||
@@ -165,8 +164,29 @@ class _DestinationSheetState extends State<_DestinationSheet> {
|
||||
Expanded(
|
||||
child: DmSheetHeader(
|
||||
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(
|
||||
@@ -295,23 +315,22 @@ class _StateRow extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return _Row(
|
||||
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,
|
||||
subtitle:
|
||||
'$count ${count == 1 ? 'city' : 'cities'}'
|
||||
'${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
|
||||
? null
|
||||
: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.brand,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
),
|
||||
child: Text(
|
||||
'$chosen',
|
||||
style: DmText.label.copyWith(color: Colors.white),
|
||||
),
|
||||
: Text(
|
||||
'$chosen',
|
||||
style: DmText.cardTitle.copyWith(color: DmColors.brand),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -400,11 +419,27 @@ class _Row extends StatelessWidget {
|
||||
selected: selected,
|
||||
child: InkWell(
|
||||
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),
|
||||
padding: EdgeInsets.symmetric(vertical: leading == null ? 14 : 10),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: DmColors.hairline)),
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
|
||||
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(
|
||||
children: [
|
||||
|
||||
@@ -88,18 +88,27 @@ class _WindowSheetState extends State<_WindowSheet> {
|
||||
// One row of days rather than a heading per day: the list
|
||||
// under it stays short enough to take in at a glance, which
|
||||
// is the whole reason this is a sheet.
|
||||
Row(
|
||||
children: [
|
||||
for (final d in days) ...[
|
||||
if (d != days.first) const SizedBox(width: 8),
|
||||
DmChoiceChip(
|
||||
label: d,
|
||||
selected: d == day,
|
||||
expand: true,
|
||||
onTap: () => setState(() => _day = d),
|
||||
),
|
||||
// The segments sit in a groove, which is what makes the
|
||||
// chosen one read as raised rather than as merely white.
|
||||
Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.groove,
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
),
|
||||
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),
|
||||
for (final slot in shown) ...[
|
||||
|
||||
@@ -36,6 +36,9 @@ class ShellScreenState extends State<ShellScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
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(
|
||||
index: _index,
|
||||
children: const [HomeScreen(), OrdersScreen(), AccountScreen()],
|
||||
@@ -47,35 +50,22 @@ class ShellScreenState extends State<ShellScreen> {
|
||||
|
||||
/// 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
|
||||
/// under it, which put a second rounded surface between the content and the
|
||||
/// gesture bar and made every screen end in two stacked edges. It now sits on
|
||||
/// the edge with a single hairline above it, like the footers in the rest of
|
||||
/// the app.
|
||||
/// This bar has been three things. It hovered as a pill, then sat on the edge
|
||||
/// with a hairline over it and a crimson indicator travelling along it, then
|
||||
/// lost the indicator's edge. The reference the app is now drawn against puts
|
||||
/// it back in the air — and the reason is better than the first time round.
|
||||
///
|
||||
/// The indicator does the work instead: a crimson pill that travels to the tab
|
||||
/// you pick, with the glyph turning white as it arrives. Selection reads as one
|
||||
/// object moving rather than three tabs changing colour in place — and the
|
||||
/// label under it stays crimson, so the tab is still identifiable in the
|
||||
/// half-second the pill is in flight.
|
||||
/// The pill works *because* the screens above it stopped having edges. When
|
||||
/// every card was outlined, a floating bar was a fourth rounded surface in a
|
||||
/// stack of them. Now the content is white shapes lifted off a near-white
|
||||
/// page, and the bar is one more of those: same radius language, same shadow,
|
||||
/// read as the same kind of object.
|
||||
///
|
||||
/// ── What the 2026-09-22 pass changed ──
|
||||
///
|
||||
/// **The pill stopped glowing.** It carried [DmShadow.liftSm] — a 65% crimson
|
||||
/// shadow built for the committing button on a screen — which on a bar resting
|
||||
/// against the bottom edge read as a smear under the tab rather than as lift.
|
||||
/// It is a quarter of that strength now and sits closer to the shape.
|
||||
///
|
||||
/// **The glyph arrives.** The icon pops to 1.14 and settles as the pill lands
|
||||
/// under it, on the same overshoot curve the pill travels on, so the two read
|
||||
/// as one gesture instead of a moving rectangle and an icon that recolours when
|
||||
/// it happens to be covered.
|
||||
///
|
||||
/// **A tab gives way when pressed.** The whole column scales to 0.9 under the
|
||||
/// finger. Without it the only feedback for a tap was the pill starting to
|
||||
/// move, which on the tab you are already on is no feedback at all.
|
||||
/// 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
|
||||
/// of 86 — which is the space the pill is floating in.
|
||||
class _TabBar extends StatelessWidget {
|
||||
const _TabBar({required this.index, required this.onChanged});
|
||||
|
||||
@@ -91,80 +81,46 @@ class _TabBar extends StatelessWidget {
|
||||
(label: 'Account', icon: LucideIcons.userRound),
|
||||
];
|
||||
|
||||
/// The travelling pill's size. Narrower than its slot so it reads as an
|
||||
/// object sitting in the row rather than as a selected cell.
|
||||
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),
|
||||
),
|
||||
];
|
||||
static const double _slot = 62;
|
||||
static const double _height = 58;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
border: Border(top: BorderSide(color: DmColors.border)),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(6, 9, 6, 7),
|
||||
child: SizedBox(
|
||||
// The row is the pill plus the label, and the label is type: at
|
||||
// 1.8x accessibility text a fixed 58 clipped it by two points. The
|
||||
// bar gets taller instead, which is what a larger-text user asked
|
||||
// for.
|
||||
height: _pillHeight +
|
||||
5 +
|
||||
MediaQuery.textScalerOf(context).scale(11.5) * 1.3,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final slot = constraints.maxWidth / _items.length;
|
||||
return Stack(
|
||||
children: [
|
||||
AnimatedPositioned(
|
||||
duration: DmMotion.slow,
|
||||
curve: DmMotion.glide,
|
||||
left: slot * index + (slot - _pillWidth) / 2,
|
||||
width: _pillWidth,
|
||||
top: 0,
|
||||
height: _pillHeight,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.brand,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
boxShadow: _pillShadow,
|
||||
),
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10),
|
||||
// A Row, not a Center: `bottomNavigationBar` hands its child an
|
||||
// unbounded height, and a Center in that slot expands to fill the
|
||||
// whole screen with the pill floating in the middle of it.
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
height: _height,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
boxShadow: DmShadow.cardRaised,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < _items.length; i++)
|
||||
SizedBox(
|
||||
width: _slot,
|
||||
child: _TabButton(
|
||||
label: _items[i].label,
|
||||
icon: _items[i].icon,
|
||||
selected: index == i,
|
||||
onTap: () => onChanged(i),
|
||||
),
|
||||
),
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final selected = widget.selected;
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: widget.selected,
|
||||
selected: selected,
|
||||
label: widget.label,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (_) => setState(() => _down = true),
|
||||
@@ -203,54 +161,34 @@ class _TabButtonState extends State<_TabButton> {
|
||||
onTapCancel: () => setState(() => _down = false),
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedScale(
|
||||
// The whole column, including the label: a press that only moved the
|
||||
// glyph would read as the icon coming loose from its own tab.
|
||||
scale: _down ? 0.9 : 1,
|
||||
duration: DmMotion.fast,
|
||||
curve: DmMotion.ease,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: _TabBar._pillHeight,
|
||||
child: Center(
|
||||
child: AnimatedScale(
|
||||
// Lands with the pill, on the pill's own curve. The
|
||||
// overshoot is what ties the two together — the glyph is
|
||||
// reacting to the thing arriving under it.
|
||||
scale: widget.selected ? 1.14 : 1,
|
||||
duration: DmMotion.slow,
|
||||
curve: DmMotion.glide,
|
||||
child: AnimatedSwitcher(
|
||||
duration: DmMotion.base,
|
||||
child: Icon(
|
||||
widget.icon,
|
||||
key: ValueKey(widget.selected),
|
||||
size: 21,
|
||||
color: widget.selected ? Colors.white : DmColors.ink3,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: AnimatedContainer(
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
width: 42,
|
||||
height: 42,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? DmColors.brandSoft : Colors.transparent,
|
||||
borderRadius: DmRadius.all(DmRadius.sm),
|
||||
),
|
||||
child: AnimatedScale(
|
||||
// A shade of overshoot as the tint arrives under it, so the
|
||||
// two read as one gesture rather than as a glyph that
|
||||
// recolours when a rectangle happens to appear behind it.
|
||||
scale: selected ? 1.06 : 1,
|
||||
duration: DmMotion.slow,
|
||||
curve: DmMotion.glide,
|
||||
child: Icon(
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -12,11 +12,7 @@ import '../widgets/chrome.dart';
|
||||
import '../widgets/feedback.dart';
|
||||
import '../widgets/milestones.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/summary.dart';
|
||||
import 'booking/booking_routes.dart';
|
||||
import 'booking/send_screen.dart';
|
||||
import 'sheets/cancel_sheet.dart';
|
||||
@@ -42,7 +38,7 @@ class TrackingScreen extends StatelessWidget {
|
||||
if (booking == null) {
|
||||
return Scaffold(
|
||||
backgroundColor: DmColors.canvas,
|
||||
appBar: const DmTopBar(title: 'Live tracking'),
|
||||
appBar: const DmTopBar(title: 'Tracking'),
|
||||
body: const DmEmptyState(
|
||||
icon: LucideIcons.package,
|
||||
title: 'Nothing to track',
|
||||
@@ -68,18 +64,14 @@ class TrackingScreen extends StatelessWidget {
|
||||
booking.miler != null &&
|
||||
stage.index >= JourneyStage.assigned.index &&
|
||||
stage.index <= JourneyStage.pickedUp.index;
|
||||
final assigning = !cancelled && stage == JourneyStage.booked;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: DmColors.canvas,
|
||||
appBar: DmTopBar(
|
||||
title: cancelled ? 'Booking' : 'Live tracking',
|
||||
// The reference belongs in the bar, not in a card three scrolls down:
|
||||
// it is what a customer reads out when they call about this parcel.
|
||||
trailing: Text(
|
||||
focused?.trackingId ?? booking.displayId,
|
||||
style: DmText.mono,
|
||||
),
|
||||
title: cancelled ? 'Booking' : 'Live Order Tracking',
|
||||
// No reference here. The head card carries it at 26pt, which is what
|
||||
// a customer reads out over a phone, and the same string twice on one
|
||||
// screen is one of them doing nothing.
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
color: DmColors.brand,
|
||||
@@ -89,92 +81,25 @@ class TrackingScreen extends StatelessWidget {
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
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
|
||||
// glyph at its centre, then a banner counting "verified Milers
|
||||
// active in your zone". Both were animation standing in for an
|
||||
// answer: the radar is not looking at anything, and the count is a
|
||||
// number the customer cannot use. The rail below says *Booking
|
||||
// created* with the line stopped there, and the crimson card above
|
||||
// says what is happening in words. That is the answer, and it is
|
||||
// the same rail whether a Miler has been found or not.
|
||||
if (!assigning && !cancelled)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
0,
|
||||
DmSpace.pad,
|
||||
10,
|
||||
),
|
||||
// The map is a cell of a card, not a floating rounded rectangle
|
||||
// of its own: it gets the card's edge, and the line under it
|
||||
// carries the fact the picture cannot state precisely.
|
||||
child: DmCard(
|
||||
children: [
|
||||
DmRouteMap(
|
||||
height: 178,
|
||||
radius: 0,
|
||||
pickup: booking.pickup,
|
||||
destination: (focused ?? booking.destinations.first).hub,
|
||||
showMiler: milerEnRoute,
|
||||
progress: _routeProgress(stage),
|
||||
pickupLabel: booking.pickup.title,
|
||||
destinationLabel: booking.destination.districtName,
|
||||
badge: milerEnRoute && booking.milerDistanceKm != null
|
||||
? _Badge(
|
||||
icon: LucideIcons.bike,
|
||||
label: booking.milerDistanceKm == 0
|
||||
? 'At your door'
|
||||
: '${booking.miler?.name ?? 'Your Miler'}, '
|
||||
'${booking.milerDistanceKm} km away',
|
||||
)
|
||||
: null,
|
||||
),
|
||||
// Only when there is something to say. This cell used to
|
||||
// read "Tracking your parcel live" whenever no ETA existed,
|
||||
// beside a Refresh action — a sentence that described the
|
||||
// screen the customer was already looking at, and a button
|
||||
// for a gesture the list now handles by being pulled down.
|
||||
if (milerEnRoute && booking.milerEtaMinutes != null)
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.clock,
|
||||
size: 15,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Pickup in about '
|
||||
'${booking.milerEtaMinutes} min',
|
||||
style: DmText.cardTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (milerEnRoute)
|
||||
_MilerCard(person: booking.miler!, role: 'Your Miler'),
|
||||
if (!cancelled &&
|
||||
stage == JourneyStage.outForDelivery &&
|
||||
booking.deliveryAgent != null)
|
||||
_MilerCard(
|
||||
person: booking.deliveryAgent!,
|
||||
role: 'Delivery Miler',
|
||||
),
|
||||
|
||||
// The radar was a 200pt card with a pulsing sweep and a bike glyph
|
||||
// at its centre, beside a banner counting "verified Milers active
|
||||
// in your zone" — animation standing in for an answer.
|
||||
//
|
||||
// The map went for a harder reason. It was 178pt of basemap, two
|
||||
// labels and a pin, on a screen whose question is "where is my
|
||||
// parcel" and whose honest answer is a milestone. 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 live position, when there is one, is a sentence on the
|
||||
// Miler's own card.
|
||||
if (!cancelled)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
@@ -197,19 +122,26 @@ class TrackingScreen extends StatelessWidget {
|
||||
child: _YourPackages(booking: booking, focused: focused),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
0,
|
||||
DmSpace.pad,
|
||||
10,
|
||||
),
|
||||
child: _RouteOutline(
|
||||
booking: booking,
|
||||
slot: slot,
|
||||
focused: focused,
|
||||
),
|
||||
),
|
||||
// `_RouteOutline` was here: the same pickup, destination and
|
||||
// window the head card now draws as a rail, repeated at the foot
|
||||
// of the screen. It existed when the head was a crimson headline
|
||||
// that carried no route at all.
|
||||
|
||||
// ── Who is carrying it comes last ──
|
||||
//
|
||||
// The Miler's card sat directly under the head, between the
|
||||
// reference and the journey, which put a person between the
|
||||
// customer and the answer they opened the screen for. It is real
|
||||
// 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
|
||||
// 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(
|
||||
edge: true,
|
||||
children: _footerActions(context, app, booking),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// filled one and cancelling is a text button under it. Cancel used to be a
|
||||
@@ -260,10 +188,15 @@ class TrackingScreen extends StatelessWidget {
|
||||
}
|
||||
|
||||
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(
|
||||
label: 'Share live tracking',
|
||||
icon: LucideIcons.share2,
|
||||
kind: DmButtonKind.outline,
|
||||
iconLeading: true,
|
||||
onPressed: () => DmToast.show(context, 'Sharing is coming soon'),
|
||||
),
|
||||
// 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
|
||||
/// straight onto the canvas. It said the right words, but it looked like every
|
||||
/// other heading in the app, so the screen opened with no focal point and the
|
||||
/// map below it — a decoration — became the loudest thing on the page.
|
||||
/// The bordered card repeated the milestone the journey below it already
|
||||
/// listed. Stripping it to type fixed that and left the top of the screen with
|
||||
/// nothing on it but two words and two places.
|
||||
///
|
||||
/// It is now the one crimson card the design allows per screen. It carries the
|
||||
/// live pulse, the headline, the journey's own progress and the window, so the
|
||||
/// whole answer is in one object above the fold. A cancelled booking drops to a
|
||||
/// plain card: nothing is live, and a crimson field would keep insisting it is.
|
||||
class _StatusHeader extends StatelessWidget {
|
||||
const _StatusHeader({
|
||||
required this.booking,
|
||||
required this.slot,
|
||||
this.focused,
|
||||
});
|
||||
/// The reference's answer is better than both: the state stays the hero, but
|
||||
/// it earns a row of its own with the reference and a priority mark beside it,
|
||||
/// the promise sits under it, and the route becomes a card carrying the two
|
||||
/// ends *and* the three figures a customer checks while waiting — how far,
|
||||
/// what is carrying it, how heavy.
|
||||
class _TrackCard extends StatelessWidget {
|
||||
const _TrackCard({required this.booking, required this.slot, this.focused});
|
||||
|
||||
final Booking booking;
|
||||
final PickupSlot? slot;
|
||||
@@ -320,140 +249,225 @@ class _StatusHeader extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final cancelled = booking.isCancelled;
|
||||
final stage = focused?.stage ?? booking.stage;
|
||||
final group = focused ?? booking.destinations.first;
|
||||
|
||||
final headline = switch (true) {
|
||||
_ when cancelled => 'Booking cancelled',
|
||||
_ when stage == JourneyStage.booked => 'Finding your Miler',
|
||||
_ when stage == JourneyStage.assigned && booking.miler != null =>
|
||||
'${booking.miler!.name} is assigned',
|
||||
_ when stage == JourneyStage.onTheWay && booking.miler != null =>
|
||||
booking.milerEtaMinutes == null
|
||||
? '${booking.miler!.name} is on the way'
|
||||
: '${booking.miler!.name} is ${booking.milerEtaMinutes} minutes away',
|
||||
_ when stage == JourneyStage.arrived && booking.miler != null =>
|
||||
'${booking.miler!.name} is at your door',
|
||||
_ => stage.headline,
|
||||
// What the customer can act on: when, before the handover; where, after.
|
||||
final when = switch (true) {
|
||||
_ when cancelled => 'You were not charged',
|
||||
_ when booking.milerEtaMinutes != null && booking.milerEtaMinutes! > 0 =>
|
||||
'Arriving in about ${booking.milerEtaMinutes} min',
|
||||
_ when stage == JourneyStage.arrived => 'At your door now',
|
||||
_ when stage.index <= JourneyStage.assigned.index && slot != null =>
|
||||
'Today · ${slot!.window}',
|
||||
_ when booking.expectedDelivery != null =>
|
||||
'Expected ${booking.expectedDelivery}',
|
||||
_ => null,
|
||||
};
|
||||
|
||||
final sub = cancelled
|
||||
? 'This pickup was cancelled. You were not charged.'
|
||||
: stage.sub;
|
||||
final eta = booking.milerEtaMinutes;
|
||||
final reference = focused?.trackingId ?? booking.displayId;
|
||||
|
||||
final total = CustomerMilestone.values.length;
|
||||
final step = CustomerMilestone.of(stage).index + 1;
|
||||
|
||||
if (cancelled) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10),
|
||||
child: DmCard(
|
||||
children: [
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
// Only figures the backend actually sent. A strip with one cell in it is
|
||||
// a fact centred in a grey band, which is worse than the fact on its own
|
||||
// — so it is drawn at two or more.
|
||||
final figures = <Widget>[
|
||||
if (booking.fare?.routeKm != null)
|
||||
_Figure(
|
||||
label: 'Distance',
|
||||
value: '${booking.fare!.routeKm!.toStringAsFixed(1)} km',
|
||||
),
|
||||
);
|
||||
}
|
||||
_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(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10),
|
||||
child: DmCard(
|
||||
tone: DmCardTone.fill,
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 2, DmSpace.pad, 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const DmLiveDot(size: 9, color: Colors.white),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'LIVE',
|
||||
style: DmText.eyebrow.copyWith(
|
||||
fontSize: 11,
|
||||
color: Colors.white,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
DmStatusChip(
|
||||
label: reference.startsWith('#') ? reference : '#$reference',
|
||||
tone: cancelled ? DmColors.ink4 : DmColors.brand,
|
||||
quiet: cancelled,
|
||||
),
|
||||
// No priority chip. The reference carries "EXPRESS PRIORITY"
|
||||
// beside the reference; this backend has no express tier, and a
|
||||
// badge that every booking wears is decoration.
|
||||
|
||||
],
|
||||
),
|
||||
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.
|
||||
String _footnote(JourneyStage stage) {
|
||||
if (booking.milerEtaMinutes != null && booking.milerEtaMinutes! > 0) {
|
||||
return 'Arriving in about ${booking.milerEtaMinutes} min';
|
||||
}
|
||||
if (stage == JourneyStage.arrived) return 'Waiting at your door';
|
||||
if (stage.index <= JourneyStage.assigned.index && slot != null) {
|
||||
return '${slot!.day}, ${slot!.window}';
|
||||
}
|
||||
if (booking.expectedDelivery != null) {
|
||||
return 'Expected ${booking.expectedDelivery}';
|
||||
}
|
||||
return CustomerMilestone.of(stage).label;
|
||||
/// A labelled place. Two of these are the route.
|
||||
class _Place extends StatelessWidget {
|
||||
const _Place({
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.note,
|
||||
this.end = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final String? note;
|
||||
|
||||
/// 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) {
|
||||
return DmMilestones(
|
||||
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".
|
||||
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.
|
||||
/// Who is collecting, and how to reach them.
|
||||
///
|
||||
/// A wash rather than a plain card: this is the one row on the screen about a
|
||||
/// 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
|
||||
/// number plate at the door.
|
||||
/// ── Card → bare row → card ──
|
||||
///
|
||||
/// The first card was a bordered box with an avatar, a name, a plate tag and
|
||||
/// 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 {
|
||||
const _MilerCard({required this.person, required this.role});
|
||||
const _MilerCard({required this.person});
|
||||
|
||||
final Person person;
|
||||
final String role;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10),
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 6, DmSpace.pad, 10),
|
||||
child: DmCard(
|
||||
tone: DmCardTone.wash,
|
||||
raised: true,
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
|
||||
padding: const EdgeInsets.fromLTRB(16, 15, 16, 15),
|
||||
child: Row(
|
||||
children: [
|
||||
DmAvatar(initials: person.initials, size: 44),
|
||||
const SizedBox(width: 13),
|
||||
DmAvatar(initials: person.initials, size: 46),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(person.name, style: DmText.cardTitle),
|
||||
const SizedBox(height: 2),
|
||||
// Absent unless the backend sent a rating or a pickup
|
||||
// count. A default would put an invented reputation on a
|
||||
// real person's card.
|
||||
if (person.credentials != null)
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.star,
|
||||
size: 12,
|
||||
color: DmColors.brand,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Flexible(
|
||||
child: Text(
|
||||
person.credentials!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.tiny,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
Text(
|
||||
person.name,
|
||||
style: DmText.cardTitle.copyWith(fontSize: 16.5),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
// ── Reputation, then the plate ──
|
||||
//
|
||||
// The reference puts "★ 4.9 · 1,240 deliveries" under
|
||||
// the name, which is the line that makes a stranger at
|
||||
// your door a person somebody has vouched for. It is
|
||||
// shown only when the backend actually sent a rating or
|
||||
// a count — a default here would be an invented
|
||||
// reputation on a real person — and the plate takes the
|
||||
// line whenever it did not.
|
||||
if (person.rating != null || person.trips != null)
|
||||
_Credentials(person: person)
|
||||
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),
|
||||
ConstrainedBox(
|
||||
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',
|
||||
_Reach(
|
||||
icon: LucideIcons.phone,
|
||||
tooltip: 'Call ${person.name}',
|
||||
filled: true,
|
||||
onPressed: () =>
|
||||
DmToast.show(context, 'Calling ${person.name}…'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
DmMiniButton(
|
||||
label: 'Message',
|
||||
const SizedBox(width: 9),
|
||||
_Reach(
|
||||
icon: LucideIcons.messageSquare,
|
||||
tooltip: 'Message ${person.name}',
|
||||
onPressed: () =>
|
||||
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.
|
||||
class _RouteOutline extends StatelessWidget {
|
||||
const _RouteOutline({
|
||||
required this.booking,
|
||||
required this.slot,
|
||||
this.focused,
|
||||
/// Call and Message, as two discs.
|
||||
///
|
||||
/// They were full-width buttons in a second cell, which gave a phone call the
|
||||
/// same footprint as the screen's primary action. A round target beside the
|
||||
/// name is the shape every app that has ever put a courier on a screen
|
||||
/// 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 PickupSlot? slot;
|
||||
final DestinationGroup? focused;
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback onPressed;
|
||||
final bool filled;
|
||||
|
||||
@override
|
||||
State<_Reach> createState() => _ReachState();
|
||||
}
|
||||
|
||||
class _ReachState extends State<_Reach> {
|
||||
bool _down = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final group = focused;
|
||||
final recipient = (group ?? booking.destinations.first).details.recipient;
|
||||
|
||||
return DmCard(
|
||||
children: [
|
||||
DmCardCell(
|
||||
child: DmRouteRail(
|
||||
pickup: booking.pickup.title,
|
||||
pickupNote: booking.pickup.sub,
|
||||
drop: group != null
|
||||
? group.destination.label
|
||||
: booking.isMultiDestination
|
||||
? '${booking.destinations.length} destinations'
|
||||
: booking.destination.label,
|
||||
dropNote: group != null
|
||||
? '${group.packageCount} '
|
||||
'${group.packageCount == 1 ? 'package' : 'packages'}'
|
||||
: booking.isMultiDestination
|
||||
? booking.packageSummary
|
||||
: booking.details.exactAddress ??
|
||||
(recipient == null
|
||||
? 'Exact address collected at pickup'
|
||||
: 'Recipient · $recipient'),
|
||||
return Tooltip(
|
||||
message: widget.tooltip,
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: widget.tooltip,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (_) => setState(() => _down = true),
|
||||
onTapUp: (_) => setState(() => _down = false),
|
||||
onTapCancel: () => setState(() => _down = false),
|
||||
onTap: widget.onPressed,
|
||||
child: AnimatedScale(
|
||||
scale: _down ? 0.92 : 1,
|
||||
duration: DmMotion.fast,
|
||||
curve: DmMotion.ease,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.filled ? DmColors.brand : DmColors.surfaceAlt,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +27,22 @@ class DmColors {
|
||||
|
||||
// ── Brand ────────────────────────────────────────────────────────────────
|
||||
/// 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);
|
||||
|
||||
/// 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.
|
||||
static const brandPress = Color(0xFF730C05);
|
||||
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.
|
||||
static const brandSoft = Color(0xFFFBF3F2);
|
||||
|
||||
@@ -48,13 +57,25 @@ class DmColors {
|
||||
|
||||
// ── Surfaces ─────────────────────────────────────────────────────────────
|
||||
/// 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);
|
||||
|
||||
/// Filled inputs, quiet chips, tiles, skeleton bases. Also the "sunken"
|
||||
/// treatment for a disabled primary action.
|
||||
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.
|
||||
static const hairline = Color(0xFFF1EFEC);
|
||||
|
||||
@@ -111,7 +132,15 @@ class DmRadius {
|
||||
static const sm = 12.0;
|
||||
|
||||
/// 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.
|
||||
static const lg = 16.0;
|
||||
@@ -137,11 +166,14 @@ class DmRadius {
|
||||
class DmSpace {
|
||||
DmSpace._();
|
||||
|
||||
/// Horizontal page padding. 22 on the brand header's body, 24 on canvas
|
||||
/// content — the header's crimson field reads as tighter than it measures,
|
||||
/// so it takes the smaller inset.
|
||||
static const pad = 24.0;
|
||||
static const headerPad = 22.0;
|
||||
/// Horizontal page padding.
|
||||
///
|
||||
/// 24 once, 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 — 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.
|
||||
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.
|
||||
static const float = [
|
||||
BoxShadow(
|
||||
@@ -250,18 +329,33 @@ class DmMotion {
|
||||
class 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';
|
||||
|
||||
// The weight axis, for Geist Mono only. It ships as one variable file, so a
|
||||
// style has to move `wght` to change weight — `fontWeight` alone picks the
|
||||
// The weight axis. Switzer and Geist Mono both ship as one variable file, so
|
||||
// a style has to move `wght` to change weight — `fontWeight` alone picks the
|
||||
// nearest named instance and, on a single-file VF, that is always the
|
||||
// default. Both are set on its styles: the axis does the work, the enum keeps
|
||||
// the semantics (and the system-font fallback) correct.
|
||||
// default. Both are set on every style: the axis does the work, the enum
|
||||
// keeps the semantics (and the system-font fallback) correct.
|
||||
//
|
||||
// Poppins needs none of this. It is four static files and Flutter matches
|
||||
// them on `fontWeight`.
|
||||
// This came back when Poppins left. Poppins has no variable release, so for
|
||||
// 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 _w600 = [FontVariation('wght', 600)];
|
||||
static const _w700 = [FontVariation('wght', 700)];
|
||||
static const _w800 = [FontVariation('wght', 800)];
|
||||
|
||||
// Letter spacing is quoted in em by the design and converted here at each
|
||||
// size, because -0.035em is a different number of pixels on a 31pt display
|
||||
@@ -273,22 +367,26 @@ class DmText {
|
||||
/// Screen-dominating statement — "Pickup booked", "Arun is 12 minutes away".
|
||||
static const display = TextStyle(
|
||||
fontFamily: _f, fontSize: 31, height: 1.1,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.95, color: DmColors.ink,
|
||||
fontWeight: FontWeight.w800, fontVariations: _w800,
|
||||
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(
|
||||
fontFamily: _f, fontSize: 34, height: 1.0,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -1.2, color: Colors.white,
|
||||
fontWeight: FontWeight.w800, fontVariations: _w800,
|
||||
letterSpacing: -1.4, color: DmColors.ink,
|
||||
);
|
||||
|
||||
/// Screen headings on a canvas.
|
||||
static const title = TextStyle(
|
||||
fontFamily: _f, fontSize: 25, height: 1.16,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.65, color: DmColors.ink,
|
||||
fontWeight: FontWeight.w800, fontVariations: _w800,
|
||||
letterSpacing: -0.8, color: DmColors.ink,
|
||||
);
|
||||
|
||||
/// 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.
|
||||
static const heading = TextStyle(
|
||||
fontFamily: _f, fontSize: 18, height: 1.25,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.4, color: DmColors.ink,
|
||||
fontWeight: FontWeight.w700, fontVariations: _w700,
|
||||
letterSpacing: -0.45, color: DmColors.ink,
|
||||
);
|
||||
|
||||
static const sheetTitle = TextStyle(
|
||||
fontFamily: _f, fontSize: 21, height: 1.2,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.55, color: DmColors.ink,
|
||||
fontWeight: FontWeight.w800, fontVariations: _w800,
|
||||
letterSpacing: -0.65, color: DmColors.ink,
|
||||
);
|
||||
|
||||
// ── Rows ─────────────────────────────────────────────────────────────────
|
||||
@@ -312,8 +410,8 @@ class DmText {
|
||||
/// A row's own line — `t1` in the design, and the workhorse of the app.
|
||||
static const cardTitle = TextStyle(
|
||||
fontFamily: _f, fontSize: 15, height: 1.4,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.15, color: DmColors.ink,
|
||||
fontWeight: FontWeight.w600, fontVariations: _w600,
|
||||
letterSpacing: -0.2, color: DmColors.ink,
|
||||
);
|
||||
|
||||
/// Alias: the same weight of statement, used mid-form.
|
||||
@@ -321,55 +419,55 @@ class DmText {
|
||||
|
||||
static const body = TextStyle(
|
||||
fontFamily: _f, fontSize: 15, height: 1.5,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: -0.1, color: DmColors.ink,
|
||||
fontWeight: FontWeight.w500, fontVariations: _w500,
|
||||
letterSpacing: -0.12, color: DmColors.ink,
|
||||
);
|
||||
|
||||
/// Supporting line under a row title — `t2`.
|
||||
static const small = TextStyle(
|
||||
fontFamily: _f, fontSize: 13.5, height: 1.45,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w500, fontVariations: _w500,
|
||||
color: DmColors.ink2,
|
||||
);
|
||||
|
||||
/// The quietest line — `t3`. Timestamps, counts, "collected on".
|
||||
static const tiny = TextStyle(
|
||||
fontFamily: _f, fontSize: 13, height: 1.4,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w500, fontVariations: _w500,
|
||||
color: DmColors.ink3,
|
||||
);
|
||||
|
||||
/// Paragraph under a screen heading.
|
||||
static const lede = TextStyle(
|
||||
fontFamily: _f, fontSize: 14.5, height: 1.55,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w500, fontVariations: _w500,
|
||||
color: DmColors.ink2,
|
||||
);
|
||||
|
||||
/// The right-hand value in a settings row.
|
||||
static const value = TextStyle(
|
||||
fontFamily: _f, fontSize: 13.5, height: 1.4,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w600, fontVariations: _w600,
|
||||
color: DmColors.ink2,
|
||||
);
|
||||
|
||||
/// Field labels, tags, chips, inline actions.
|
||||
static const label = TextStyle(
|
||||
fontFamily: _f, fontSize: 12.5, height: 1.3,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w600, fontVariations: _w600,
|
||||
color: DmColors.ink2,
|
||||
);
|
||||
|
||||
static const button = TextStyle(
|
||||
fontFamily: _f, fontSize: 16, height: 1.2,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.2,
|
||||
fontWeight: FontWeight.w700, fontVariations: _w700,
|
||||
letterSpacing: -0.25,
|
||||
);
|
||||
|
||||
/// The text-only action under a primary button ("Back to home").
|
||||
static const textButton = TextStyle(
|
||||
fontFamily: _f, fontSize: 14.5, height: 1.2,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w600, fontVariations: _w600,
|
||||
color: DmColors.ink2,
|
||||
);
|
||||
|
||||
@@ -377,22 +475,22 @@ class DmText {
|
||||
/// it heads brand content, [DmColors.ink3] when it heads a neutral group.
|
||||
static const eyebrow = TextStyle(
|
||||
fontFamily: _f, fontSize: 10.5, height: 1.3,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w600, fontVariations: _w600,
|
||||
letterSpacing: 1.16, color: DmColors.ink3,
|
||||
);
|
||||
|
||||
/// The eyebrow as it appears on the crimson header.
|
||||
static const headerEyebrow = TextStyle(
|
||||
fontFamily: _f, fontSize: 11.5, height: 1.3,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w700, fontVariations: _w700,
|
||||
letterSpacing: 1.84, color: DmColors.onBrand3,
|
||||
);
|
||||
|
||||
/// Sentence-case heading above a group of rows.
|
||||
static const sectionHead = TextStyle(
|
||||
fontFamily: _f, fontSize: 15, height: 1.3,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.25, color: DmColors.ink,
|
||||
fontWeight: FontWeight.w700, fontVariations: _w700,
|
||||
letterSpacing: -0.3, color: DmColors.ink,
|
||||
);
|
||||
|
||||
/// Alias kept for existing call sites.
|
||||
@@ -401,7 +499,7 @@ class DmText {
|
||||
/// Field input text.
|
||||
static const field = TextStyle(
|
||||
fontFamily: _f, fontSize: 16, height: 1.3,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w600, fontVariations: _w600,
|
||||
color: DmColors.ink,
|
||||
);
|
||||
|
||||
@@ -431,7 +529,7 @@ class DmText {
|
||||
/// The OTP digits.
|
||||
static const otp = TextStyle(
|
||||
fontFamily: _f, fontSize: 24, height: 1.1,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w600, fontVariations: _w600,
|
||||
color: DmColors.ink,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,8 @@ class DmBookOrb extends StatefulWidget {
|
||||
const DmBookOrb({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
this.label = 'BOOK',
|
||||
this.label = 'BOOK NOW',
|
||||
this.strapline = 'INSTANT PARCEL',
|
||||
this.caption,
|
||||
this.held = false,
|
||||
});
|
||||
@@ -80,6 +81,9 @@ class DmBookOrb extends StatefulWidget {
|
||||
/// second use of this widget elsewhere is not forced to repeat itself.
|
||||
final String? caption;
|
||||
|
||||
/// The small line inside the sphere, under the label.
|
||||
final String strapline;
|
||||
|
||||
@override
|
||||
State<DmBookOrb> createState() => _DmBookOrbState();
|
||||
}
|
||||
@@ -117,8 +121,8 @@ class _DmBookOrbState extends State<DmBookOrb> with TickerProviderStateMixin {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: widget.caption == null
|
||||
? widget.label
|
||||
: '${widget.label}. ${widget.caption}',
|
||||
? '${widget.label}. ${widget.strapline}'
|
||||
: '${widget.label}. ${widget.strapline}. ${widget.caption}',
|
||||
child: Column(
|
||||
children: [
|
||||
GestureDetector(
|
||||
@@ -160,7 +164,11 @@ class _DmBookOrbState extends State<DmBookOrb> with TickerProviderStateMixin {
|
||||
scale: Tween(begin: 1.0, end: 1.012).animate(
|
||||
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:
|
||||
/// gradient fill, specular bloom, rim, then the word.
|
||||
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 String label;
|
||||
final String strapline;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -201,7 +214,7 @@ class _Sphere extends StatelessWidget {
|
||||
// takes its light from.
|
||||
center: Alignment(-0.38, -0.52),
|
||||
radius: 1.08,
|
||||
colors: [Color(0xFFC0230F), DmColors.brand, Color(0xFF5C0802)],
|
||||
colors: [DmColors.brandLit, DmColors.brand, Color(0xFF5C0802)],
|
||||
stops: [0.0, 0.5, 1.0],
|
||||
),
|
||||
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(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.navigation,
|
||||
size: 21,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(height: 11),
|
||||
Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: DmText.heading.copyWith(
|
||||
fontSize: 32,
|
||||
height: 1,
|
||||
letterSpacing: 5,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontVariations: const [FontVariation('wght', 800)],
|
||||
fontSize: 23,
|
||||
height: 1.05,
|
||||
letterSpacing: -0.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontVariations: const [FontVariation('wght', 700)],
|
||||
color: Colors.white,
|
||||
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 Icon(
|
||||
LucideIcons.arrowUpRight,
|
||||
size: 18,
|
||||
color: Color(0xB3FFFFFF),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
strapline,
|
||||
textAlign: TextAlign.center,
|
||||
style: DmText.eyebrow.copyWith(
|
||||
fontSize: 9.5,
|
||||
letterSpacing: 1.7,
|
||||
color: const Color(0xB3FFFFFF),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -102,6 +102,11 @@ class _DmButtonState extends State<DmButton> {
|
||||
? DmColors.surfaceAlt
|
||||
: (_down ? DmColors.brandPress : DmColors.brand);
|
||||
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;
|
||||
// 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.
|
||||
@@ -109,7 +114,7 @@ class _DmButtonState extends State<DmButton> {
|
||||
? const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFA6180B), DmColors.brand, Color(0xFF7A0C04)],
|
||||
colors: [DmColors.brandLit, DmColors.brand, DmColors.brandPress],
|
||||
stops: [0.0, 0.55, 1.0],
|
||||
)
|
||||
: null;
|
||||
|
||||
@@ -21,24 +21,40 @@ enum DmCardTone {
|
||||
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
|
||||
/// between rows belongs to the card, not to the rows: a row does not know
|
||||
/// whether it is first, and a card that is handed one child draws no rule at
|
||||
/// all, so the same [DmCardCell] works alone or in a stack of five.
|
||||
/// The rule between rows belongs to the card, not to the rows: a row does not
|
||||
/// know whether it is first, and a card that is handed one child draws no rule
|
||||
/// at 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 {
|
||||
const DmCard({
|
||||
super.key,
|
||||
required this.children,
|
||||
this.tone = DmCardTone.plain,
|
||||
this.rail = false,
|
||||
this.raised = false,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final List<Widget> children;
|
||||
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
|
||||
/// that needs the customer's attention now.
|
||||
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
|
||||
/// white rather than a darker line: a dark rule on a saturated field reads as
|
||||
/// 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) {
|
||||
DmCardTone.plain => DmColors.hairline,
|
||||
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
|
||||
// foreground decoration paints last, so every cell is clipped inside an
|
||||
// edge that is always visible.
|
||||
final radius = DmRadius.all(DmRadius.card);
|
||||
final card = DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: _background,
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
borderRadius: radius,
|
||||
boxShadow: _lifts ? (raised ? DmShadow.cardRaised : DmShadow.card) : null,
|
||||
),
|
||||
child: Container(
|
||||
foregroundDecoration: BoxDecoration(
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
border: Border.all(color: _edge),
|
||||
),
|
||||
// A tone that is not white keeps its edge: a crimson or washed card is
|
||||
// already separated from the canvas by its own colour, and a shadow
|
||||
// under a saturated fill reads as grime rather than as lift.
|
||||
foregroundDecoration: _lifts
|
||||
? null
|
||||
: BoxDecoration(
|
||||
borderRadius: radius,
|
||||
border: Border.all(color: _edge),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
borderRadius: radius,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -109,7 +135,8 @@ class DmCard extends StatelessWidget {
|
||||
? Container(
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.brand,
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
borderRadius: radius,
|
||||
boxShadow: _lifts ? DmShadow.cardRaised : null,
|
||||
),
|
||||
padding: const EdgeInsets.only(left: 3),
|
||||
child: card,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.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 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.
|
||||
/// ── It stopped being a crimson field ──
|
||||
///
|
||||
/// The arcs are drawn, not an asset: they are struck from the same geometry as
|
||||
/// the mark, they scale to any header height, and they cost no bytes.
|
||||
/// This was 260pt of solid brand on Home, Orders and Account — three of the
|
||||
/// 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 {
|
||||
const DmBrandHeader({
|
||||
super.key,
|
||||
@@ -421,57 +429,35 @@ class DmBrandHeader extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
bottom: Radius.circular(DmRadius.header),
|
||||
),
|
||||
child: DecoratedBox(
|
||||
// Lit from the top-left like every other brand object in the app, so
|
||||
// the crimson field has a direction instead of sitting flat. The spread
|
||||
// is deliberately narrow — a header that reads as a gradient rather
|
||||
// than as a colour has stopped being the brand.
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFA0150A), DmColors.brand, Color(0xFF7A0C04)],
|
||||
stops: [0.0, 0.52, 1.0],
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: const DmArcField(),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.headerPad,
|
||||
2,
|
||||
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!],
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(color: DmColors.canvas),
|
||||
child: CustomPaint(
|
||||
painter: const DmArcField(opacity: 0.035, colour: DmColors.brand),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
2,
|
||||
DmSpace.pad,
|
||||
14,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: Row(children: [?leading, const Spacer(), ?action]),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
if (eyebrow != null) ...[
|
||||
Text(eyebrow!.toUpperCase(), style: DmText.eyebrow),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
),
|
||||
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
|
||||
/// uses. Decorative only — it never carries meaning, so it is not announced.
|
||||
class DmArcField extends CustomPainter {
|
||||
const DmArcField({this.opacity = 0.15});
|
||||
const DmArcField({this.opacity = 0.15, this.colour = Colors.white});
|
||||
|
||||
final double opacity;
|
||||
|
||||
/// White on a crimson field; crimson, very faint, on the canvas.
|
||||
final Color colour;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
// 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()
|
||||
..style = PaintingStyle.stroke
|
||||
..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), 170, paint);
|
||||
@@ -521,126 +510,7 @@ class DmArcField extends CustomPainter {
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant DmArcField oldDelegate) =>
|
||||
oldDelegate.opacity != opacity;
|
||||
}
|
||||
|
||||
/// 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
oldDelegate.opacity != opacity || oldDelegate.colour != colour;
|
||||
}
|
||||
|
||||
/// The recessed strip under a header title that carries one live fact — the
|
||||
@@ -668,9 +538,14 @@ class DmHeaderTrack extends StatelessWidget {
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
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(
|
||||
color: DmColors.onBrandSunken,
|
||||
color: DmColors.surface,
|
||||
borderRadius: DmRadius.all(DmRadius.lg),
|
||||
border: Border.all(color: DmColors.border),
|
||||
),
|
||||
child: Row(
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,6 +259,7 @@ class DmRow extends StatelessWidget {
|
||||
this.onTap,
|
||||
this.showChevron = false,
|
||||
this.icon,
|
||||
this.note,
|
||||
});
|
||||
|
||||
final String label;
|
||||
@@ -277,6 +278,12 @@ class DmRow extends StatelessWidget {
|
||||
/// just as well.
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
@@ -286,38 +293,64 @@ class DmRow extends StatelessWidget {
|
||||
splashColor: DmColors.brandSoft,
|
||||
highlightColor: DmColors.brandSoft.withValues(alpha: 0.5),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 56),
|
||||
padding: const EdgeInsets.fromLTRB(16, 11, 16, 11),
|
||||
constraints: const BoxConstraints(minHeight: 58),
|
||||
padding: const EdgeInsets.fromLTRB(18, 13, 18, 13),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 19, color: DmColors.ink3),
|
||||
const SizedBox(width: 14),
|
||||
// A glyph in a well, not a glyph. Six bare strokes down a
|
||||
// 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
|
||||
// is left. A fixed 3:2 split gave the label room it was not
|
||||
// using — "Saved addresses" is short — and rendered the value
|
||||
// beside it as "38 Mettupal…", which is a truncation nobody can
|
||||
// read anything out of. The label stays Flexible so a long one
|
||||
// at large text sizes can still give way.
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: DmText.cardTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
// The label is what the row IS and it is never the thing that
|
||||
// truncates. Two rounds of flex arithmetic here produced
|
||||
// "Payment metho…" and "38 Mettupal…" in turn; the fix was not a
|
||||
// third ratio, it was to stop passing values that cannot fit.
|
||||
// A [value] is a word — "UPI", "SMS and push" — and anything
|
||||
// longer belongs on the screen the row opens.
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
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) ...[
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value!,
|
||||
style: DmText.value,
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
value!,
|
||||
style: DmText.value,
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 1,
|
||||
),
|
||||
],
|
||||
?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 {
|
||||
const DmRowGroup({super.key, required this.children});
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ class DmMilestones extends StatelessWidget {
|
||||
this.title = 'Recent updates',
|
||||
this.maxRows,
|
||||
this.onExpand,
|
||||
this.showStep = false,
|
||||
});
|
||||
|
||||
final Booking booking;
|
||||
@@ -44,6 +45,10 @@ class DmMilestones extends StatelessWidget {
|
||||
/// Tapped from the "Full timeline" action. Null hides the action.
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final current = stage ?? booking.stage;
|
||||
@@ -89,6 +94,20 @@ class DmMilestones extends StatelessWidget {
|
||||
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)
|
||||
DmTextAction(
|
||||
// Was "All 7 stages". The number counts something the
|
||||
@@ -258,9 +277,11 @@ class _MilestoneRow extends StatelessWidget {
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
time,
|
||||
style: DmText.monoSm.copyWith(
|
||||
color: isCurrent ? DmColors.brand : DmColors.ink3,
|
||||
),
|
||||
// Always quiet. The crimson on the current row is
|
||||
// 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),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -8,8 +8,12 @@ enum DmPillTone {
|
||||
/// for a Miler, out for delivery. Crimson field, white label, solid dot.
|
||||
live,
|
||||
|
||||
/// Moving, but between beats — assigned, in transit. The crimson is held in
|
||||
/// an outline so a list of six orders is not six red blocks.
|
||||
/// Moving, but between beats — assigned, in transit.
|
||||
///
|
||||
/// 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,
|
||||
|
||||
/// 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.neutral => (
|
||||
DmColors.surface,
|
||||
DmColors.brand,
|
||||
Border.all(color: DmColors.brandLine),
|
||||
DmColors.ink,
|
||||
Border.all(color: DmColors.border),
|
||||
),
|
||||
DmPillTone.done => (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.
|
||||
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(
|
||||
padding: const EdgeInsets.fromLTRB(11, 5, 11, 5),
|
||||
decoration: BoxDecoration(
|
||||
@@ -58,9 +66,9 @@ class DmPill extends StatelessWidget {
|
||||
height: 6,
|
||||
margin: const EdgeInsets.only(right: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: hollow ? Colors.transparent : fg,
|
||||
color: hollow ? Colors.transparent : dot,
|
||||
shape: BoxShape.circle,
|
||||
border: hollow ? Border.all(color: fg, width: 1.5) : null,
|
||||
border: hollow ? Border.all(color: dot, width: 1.5) : null,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
@@ -163,34 +171,73 @@ class DmAvatar extends StatelessWidget {
|
||||
super.key,
|
||||
required this.initials,
|
||||
this.size = 42,
|
||||
this.onBrand = false,
|
||||
this.quiet = false,
|
||||
this.badge = false,
|
||||
});
|
||||
|
||||
final String initials;
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
final disc = Container(
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: onBrand ? DmColors.onBrandRaise : DmColors.brand,
|
||||
color: quiet ? DmColors.brandSoft : DmColors.brand,
|
||||
shape: BoxShape.circle,
|
||||
border: quiet ? Border.all(color: DmColors.brandLine) : null,
|
||||
),
|
||||
child: Text(
|
||||
initials,
|
||||
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,
|
||||
fontVariations: const [FontVariation('wght', 800)],
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,20 +59,27 @@ class _DmOptionTileState extends State<DmOptionTile> {
|
||||
final selected = widget.selected;
|
||||
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
|
||||
? DmColors.brand
|
||||
? DmColors.brandSoft
|
||||
: (enabled ? DmColors.surface : DmColors.surfaceAlt);
|
||||
|
||||
final Color edge = selected
|
||||
? DmColors.brand
|
||||
: (enabled ? DmColors.border : DmColors.border);
|
||||
|
||||
final titleColor = selected
|
||||
? Colors.white
|
||||
? DmColors.brandPress
|
||||
: (enabled ? DmColors.ink : DmColors.ink3);
|
||||
|
||||
final subColor = selected
|
||||
? DmColors.onBrand2
|
||||
? DmColors.brandPress
|
||||
: (enabled ? DmColors.ink2 : DmColors.ink3);
|
||||
|
||||
return Semantics(
|
||||
@@ -107,8 +114,13 @@ class _DmOptionTileState extends State<DmOptionTile> {
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
border: Border.all(color: edge),
|
||||
boxShadow: selected ? DmShadow.lift : null,
|
||||
border: selected
|
||||
? 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(
|
||||
children: [
|
||||
@@ -116,7 +128,7 @@ class _DmOptionTileState extends State<DmOptionTile> {
|
||||
Icon(
|
||||
widget.leading,
|
||||
size: 20,
|
||||
color: selected ? Colors.white : DmColors.ink3,
|
||||
color: selected ? DmColors.brand : DmColors.ink3,
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
],
|
||||
@@ -178,9 +190,11 @@ class _Radio extends StatelessWidget {
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
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(
|
||||
color: selected
|
||||
? Colors.white
|
||||
? DmColors.brand
|
||||
: (enabled ? DmColors.ink4 : DmColors.border),
|
||||
width: selected ? 2 : 1.5,
|
||||
),
|
||||
@@ -190,7 +204,7 @@ class _Radio extends StatelessWidget {
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
color: DmColors.brand,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -114,25 +114,29 @@ class DmChoiceChip extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fg = selected
|
||||
? Colors.white
|
||||
: (enabled ? DmColors.ink2 : DmColors.ink4);
|
||||
// ── A segmented control, not a crimson block ──
|
||||
//
|
||||
// "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(
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
constraints: const BoxConstraints(minHeight: 38),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
constraints: const BoxConstraints(minHeight: 40),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? DmColors.brand
|
||||
: (enabled ? DmColors.surface : DmColors.surfaceAlt),
|
||||
border: Border.all(
|
||||
color: selected ? DmColors.brand : DmColors.border,
|
||||
),
|
||||
borderRadius: DmRadius.all(DmRadius.xs),
|
||||
boxShadow: selected ? DmShadow.liftSm : null,
|
||||
color: selected ? DmColors.surface : Colors.transparent,
|
||||
borderRadius: DmRadius.all(DmRadius.sm),
|
||||
boxShadow: selected ? DmShadow.card : null,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -143,7 +147,11 @@ class DmChoiceChip extends StatelessWidget {
|
||||
label,
|
||||
maxLines: 1,
|
||||
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) ...[
|
||||
@@ -151,14 +159,14 @@ class DmChoiceChip extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? const Color(0x3DFFFFFF) : DmColors.surfaceAlt,
|
||||
color: selected ? DmColors.surfaceAlt : DmColors.surface,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: DmText.monoSm.copyWith(
|
||||
fontSize: 11,
|
||||
color: selected ? Colors.white : DmColors.ink3,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../tokens.dart';
|
||||
|
||||
@@ -20,15 +21,15 @@ enum DmNodeKind {
|
||||
/// Where the parcel starts. A hollow circle: a door, not a decision.
|
||||
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.
|
||||
hub,
|
||||
|
||||
/// The destination. A filled crimson diamond, the only solid node on the
|
||||
/// thread, so the eye lands on where the parcel is going.
|
||||
/// The destination. A filled map pin, the only solid node on the thread, so
|
||||
/// the eye lands on where the parcel is going.
|
||||
drop,
|
||||
|
||||
/// A leg that has not been arranged yet — dashed outline.
|
||||
/// A leg that has not been arranged yet — an outlined pin.
|
||||
pending,
|
||||
}
|
||||
|
||||
@@ -243,44 +244,31 @@ class _Node extends StatelessWidget {
|
||||
border: Border.all(color: DmColors.ink4, width: 2),
|
||||
),
|
||||
),
|
||||
// Rotated squares rather than a second circle: on a thread of three
|
||||
// identical dots nothing tells you which end matters, and at 11pt a
|
||||
// diamond is distinguishable from a circle at a glance where two
|
||||
// shades of the same dot are not.
|
||||
DmNodeKind.hub => Transform.rotate(
|
||||
angle: 0.785398,
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
border: Border.all(color: DmColors.brand, width: 2),
|
||||
),
|
||||
// ── The destination is a pin, not a diamond ──
|
||||
//
|
||||
// A rotated square distinguishes the ends of the thread, which was
|
||||
// the whole argument for it, and it does that by being a shape with
|
||||
// no meaning attached. A map pin is the mark every customer already
|
||||
// reads as *a place*, so the thread stops needing a legend: hollow
|
||||
// circle where you are, pin where it is going.
|
||||
DmNodeKind.hub => Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: DmColors.surface,
|
||||
border: Border.all(color: DmColors.brand, width: 2),
|
||||
),
|
||||
),
|
||||
DmNodeKind.drop => Transform.rotate(
|
||||
angle: 0.785398,
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.brand,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
DmNodeKind.drop => const Icon(
|
||||
LucideIcons.mapPin,
|
||||
size: 15,
|
||||
color: DmColors.brand,
|
||||
),
|
||||
DmNodeKind.pending => Transform.rotate(
|
||||
angle: 0.785398,
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
border: Border.all(color: DmColors.ink4, width: 1.5),
|
||||
),
|
||||
),
|
||||
DmNodeKind.pending => const Icon(
|
||||
LucideIcons.mapPin,
|
||||
size: 15,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
},
|
||||
),
|
||||
|
||||
29
pubspec.yaml
@@ -87,28 +87,19 @@ flutter:
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
# Poppins (Indian Type Foundry, SIL Open Font License) is the app's voice as
|
||||
# of 2026-09-22.
|
||||
# Switzer (Indian Type Foundry, via Fontshare) is the app's voice as of
|
||||
# 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
|
||||
# Fonts ships it only as static cuts. So it is four files, one per weight the
|
||||
# design actually uses, and `DmText` selects between them with `fontWeight`
|
||||
# alone: there is no `wght` axis to move, and a `fontVariations` on a static
|
||||
# font is silently ignored. Four cuts is ~625 KB against Manrope's single
|
||||
# 165 KB variable file, which is the price of this typeface.
|
||||
#
|
||||
# Geist Mono stays variable, and its styles still carry `fontVariations`.
|
||||
# It replaced Poppins, which has no variable release: four static cuts at
|
||||
# ~155 KB each, 625 KB of sans. This is 141 KB and covers 100-900.
|
||||
fonts:
|
||||
- family: Poppins
|
||||
- family: PlusJakartaSans
|
||||
fonts:
|
||||
- asset: assets/fonts/Poppins-Medium.ttf
|
||||
weight: 500
|
||||
- asset: assets/fonts/Poppins-SemiBold.ttf
|
||||
weight: 600
|
||||
- asset: assets/fonts/Poppins-Bold.ttf
|
||||
weight: 700
|
||||
- asset: assets/fonts/Poppins-ExtraBold.ttf
|
||||
weight: 800
|
||||
- asset: assets/fonts/PlusJakartaSans-Variable.ttf
|
||||
- family: Switzer
|
||||
fonts:
|
||||
- asset: assets/fonts/Switzer-Variable.ttf
|
||||
- family: GeistMono
|
||||
fonts:
|
||||
- asset: assets/fonts/GeistMono-VF.ttf
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:doormile_cx/main.dart';
|
||||
import 'package:doormile_cx/state/app_state.dart';
|
||||
import 'package:doormile_cx/ui/widgets/buttons.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:lucide_icons_flutter/lucide_icons.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
|
||||
/// [openSend] followed by [pickCity].
|
||||
Future<void> openSend(WidgetTester tester) async {
|
||||
await tester.tap(find.text('BOOK'));
|
||||
await tester.tap(find.text('BOOK NOW'));
|
||||
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:
|
||||
/// 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.
|
||||
/// 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(
|
||||
WidgetTester tester,
|
||||
String name, {
|
||||
@@ -106,11 +114,20 @@ Future<void> pickCity(
|
||||
}) async {
|
||||
await tester.enterText(find.byType(TextField).first, name);
|
||||
await settle(tester, 250);
|
||||
// A `Text`, explicitly: `find.text` also matches `EditableText`, so once the
|
||||
// city's name is in the search box the plain finder returns two widgets and
|
||||
// `.first` is the box itself. Tapping that only refocuses it.
|
||||
// A `Text` inside the sheet, explicitly.
|
||||
//
|
||||
// `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(
|
||||
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);
|
||||
|
||||
@@ -137,7 +154,7 @@ Future<void> pickCity(
|
||||
Future<void> openPickupEditor(WidgetTester tester) async {
|
||||
await openSend(tester);
|
||||
await pickCity(tester, 'Chennai');
|
||||
await tester.tap(find.text('FROM'));
|
||||
await tester.tap(find.text('ORIGIN'));
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
@@ -192,17 +209,20 @@ void main() {
|
||||
await settle(tester);
|
||||
|
||||
// All four digits arrived, so it verified and moved on.
|
||||
expect(find.text('BOOK'), findsOneWidget);
|
||||
expect(find.text('BOOK NOW'), findsOneWidget);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('signs in and lands on Home', (tester) async {
|
||||
await signIn(tester);
|
||||
|
||||
expect(find.text('Send a parcel'), findsOneWidget);
|
||||
expect(find.text('BOOK'), findsOneWidget);
|
||||
// The seeded active booking surfaces on Home.
|
||||
expect(find.text('Miler assigned'), findsOneWidget);
|
||||
// Home greets by name, carries the sphere, and ends on the live card.
|
||||
expect(find.text('Hello, Joe'), findsOneWidget);
|
||||
expect(find.text('BOOK NOW'), 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);
|
||||
});
|
||||
|
||||
@@ -233,7 +253,7 @@ void main() {
|
||||
await tester.tap(find.text('Use this address'));
|
||||
await settle(tester);
|
||||
// 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);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
@@ -282,7 +302,7 @@ void main() {
|
||||
// …and that one point is what the booking carries forward.
|
||||
await tester.tap(find.text('Use this address'));
|
||||
await settle(tester);
|
||||
expect(find.text('TO'), findsOneWidget);
|
||||
expect(find.text('DESTINATION'), findsOneWidget);
|
||||
expect(state.draftPickup!.lat, searched.lat);
|
||||
expect(state.draftPickup!.lng, searched.lng);
|
||||
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
|
||||
// the top of it is two rows with no labels but FROM and TO.
|
||||
expect(find.text('FROM'), findsOneWidget);
|
||||
expect(find.text('TO'), findsOneWidget);
|
||||
expect(find.text('ORIGIN'), findsOneWidget);
|
||||
expect(find.text('DESTINATION'), findsOneWidget);
|
||||
// Nothing is asked for here at all any more: no address fields, and no
|
||||
// disclosure hiding three more. The route, the packages and the button.
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
@@ -408,9 +428,9 @@ void main() {
|
||||
// The customer answered the window sheet on the way in, so the booking
|
||||
// already carries a slot they chose.
|
||||
expect(state.draftSlotId, isNotNull);
|
||||
expect(find.text('Book pickup'), findsOneWidget);
|
||||
expect(find.text('Confirm Booking'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Book pickup'));
|
||||
await tester.tap(find.text('Confirm Booking'));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('Pickup booked'), findsOneWidget);
|
||||
@@ -441,7 +461,7 @@ void main() {
|
||||
expect(state.draftContactPhone, isNull);
|
||||
|
||||
// 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 tester.enterText(find.byType(TextField).first, '9003144518');
|
||||
await settle(tester, 200);
|
||||
@@ -458,12 +478,12 @@ void main() {
|
||||
await openSend(tester);
|
||||
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.pump();
|
||||
await tester.tap(find.byIcon(LucideIcons.plus));
|
||||
await tester.pump();
|
||||
expect(find.text('3 packages'), findsOneWidget);
|
||||
expect(find.text('3 parcels'), findsOneWidget);
|
||||
expect(state.draftTotalPackages, 3);
|
||||
|
||||
// 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();
|
||||
expect(state.draftTotalPackages, state.limits.maxPackages);
|
||||
|
||||
await tester.tap(find.text('Book pickup'));
|
||||
await tester.tap(find.text('Confirm Booking'));
|
||||
await settle(tester);
|
||||
expect(state.trackedBooking!.totalPackages, state.limits.maxPackages);
|
||||
await drainToasts(tester);
|
||||
@@ -485,9 +505,10 @@ void main() {
|
||||
await pickCity(tester, 'Chennai');
|
||||
final first = state.draftSlotId;
|
||||
|
||||
// The window lives in the footer now, beside the price it is quoted
|
||||
// against, and the whole left column opens the sheet.
|
||||
await tester.tap(find.text('PICKUP'));
|
||||
// The window is a row on the review list, under the route. It carries no
|
||||
// label — the clock glyph and the time are the whole row — so it is found
|
||||
// by the window itself.
|
||||
await tester.tap(find.textContaining('2:00 – 4:00 PM').first);
|
||||
await settle(tester);
|
||||
expect(find.text('Pickup window'), findsOneWidget);
|
||||
|
||||
@@ -508,26 +529,26 @@ void main() {
|
||||
await openSend(tester);
|
||||
|
||||
// Step one is the states, not sixty district names.
|
||||
expect(find.text('Tamil Nadu'), findsOneWidget);
|
||||
expect(find.text('Kerala'), findsOneWidget);
|
||||
expect(find.text('Chennai'), findsNothing);
|
||||
expect(inSheet(find.text('Tamil Nadu')), findsOneWidget);
|
||||
expect(inSheet(find.text('Kerala')), findsOneWidget);
|
||||
expect(inSheet(find.text('Chennai')), findsNothing);
|
||||
|
||||
// Step two is that state's districts.
|
||||
await tester.tap(find.text('Tamil Nadu'));
|
||||
await settle(tester);
|
||||
expect(find.text('Chennai'), findsOneWidget);
|
||||
expect(find.text('Coimbatore'), findsWidgets);
|
||||
expect(inSheet(find.text('Chennai')), findsOneWidget);
|
||||
expect(inSheet(find.text('Coimbatore')), findsWidgets);
|
||||
|
||||
// Districts that are not open are never offered, and neither is a state
|
||||
// whose every district is closed.
|
||||
expect(find.text('Madurai'), findsNothing);
|
||||
expect(find.text('Puducherry'), findsNothing);
|
||||
expect(inSheet(find.text('Madurai')), findsNothing);
|
||||
expect(inSheet(find.text('Puducherry')), findsNothing);
|
||||
|
||||
// Back out, then let search cut across states: Ernakulam is in Kerala and
|
||||
// the customer should not have to know that to find it.
|
||||
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
|
||||
await settle(tester);
|
||||
expect(find.text('Chennai'), findsNothing);
|
||||
expect(inSheet(find.text('Chennai')), findsNothing);
|
||||
|
||||
await pickCity(tester, 'Ernakulam');
|
||||
expect(find.textContaining('Ernakulam'), findsWidgets);
|
||||
@@ -538,19 +559,21 @@ void main() {
|
||||
final state = AppState();
|
||||
await signIn(tester, state: state);
|
||||
|
||||
await tester.tap(find.text('Orders'));
|
||||
await tester.tap(find.bySemanticsLabel('Orders'));
|
||||
await settle(tester);
|
||||
|
||||
// Before collection: one row, owned by the pickup.
|
||||
expect(find.text('DM-482913'), findsOneWidget);
|
||||
// Before collection: one row, owned by the pickup. The reference shares a
|
||||
// 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
|
||||
// each destination stands on its own row.
|
||||
final booking = state.byReference('DM-471200')!;
|
||||
expect(booking.hasOrders, isTrue);
|
||||
expect(find.text('Pickup DM-471200'), findsNothing);
|
||||
expect(find.text('Ernakulam, Kerala'), findsOneWidget);
|
||||
expect(find.text('Coimbatore, Tamil Nadu'), findsOneWidget);
|
||||
expect(find.text('Ernakulam'), findsOneWidget);
|
||||
expect(find.text('Coimbatore'), findsOneWidget);
|
||||
|
||||
// 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
|
||||
@@ -558,7 +581,7 @@ void main() {
|
||||
// them, so it moved to the order's own screen.
|
||||
expect(find.text('Collected on DM-471200'), findsNothing);
|
||||
for (final group in booking.destinations) {
|
||||
expect(find.text(group.trackingId!), findsOneWidget);
|
||||
expect(find.textContaining(group.trackingId!), findsOneWidget);
|
||||
}
|
||||
// Independently tracked.
|
||||
expect(
|
||||
@@ -566,10 +589,12 @@ void main() {
|
||||
booking.destinations.length,
|
||||
);
|
||||
|
||||
// Opening one follows that order, not the whole visit.
|
||||
await tester.tap(find.text('Ernakulam, Kerala'));
|
||||
// Opening one follows that order, not the whole visit: the head card's
|
||||
// route ends at that destination, and the reference is its own tracking id.
|
||||
await tester.tap(find.text('Ernakulam'));
|
||||
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);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
@@ -579,7 +604,10 @@ void main() {
|
||||
final state = AppState();
|
||||
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);
|
||||
|
||||
// The whole shipment is on screen the moment tracking opens — five rows
|
||||
@@ -613,12 +641,17 @@ void main() {
|
||||
final state = AppState();
|
||||
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);
|
||||
|
||||
// The live card, not an eyebrow: a pickup in flight is the screen's one
|
||||
// crimson surface.
|
||||
expect(find.text('LIVE'), findsOneWidget);
|
||||
// The head card names the state in a pill and draws the route as a rail.
|
||||
// It used to be a filled crimson card headed "LIVE" — crimson is the
|
||||
// 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);
|
||||
// No per-package breakdown before collection.
|
||||
expect(find.text('YOUR PACKAGES'), findsNothing);
|
||||
@@ -638,7 +671,10 @@ void main() {
|
||||
final state = AppState();
|
||||
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 tester.tap(find.text('Cancel this pickup'));
|
||||
@@ -650,15 +686,19 @@ void main() {
|
||||
await tester.tap(find.text('Yes, cancel pickup'));
|
||||
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')!;
|
||||
expect(booking.status, BookingStatus.cancelled);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
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;
|
||||
await signIn(tester);
|
||||
|
||||
await openSend(tester);
|
||||
|
||||
@@ -688,12 +728,12 @@ void main() {
|
||||
testWidgets('a delivered order opens its receipt', (tester) async {
|
||||
await signIn(tester);
|
||||
|
||||
await tester.tap(find.text('Orders'));
|
||||
await tester.tap(find.bySemanticsLabel('Orders'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('Completed'));
|
||||
await tester.tap(find.textContaining('Past'));
|
||||
await settle(tester);
|
||||
|
||||
await tester.tap(find.text('Salem, Tamil Nadu'));
|
||||
await tester.tap(find.text('Salem'));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('Delivered'), findsWidgets);
|
||||
@@ -717,12 +757,12 @@ void main() {
|
||||
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||
|
||||
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 pickCity(tester, 'Chennai');
|
||||
|
||||
await tester.tap(find.text('Book pickup'));
|
||||
await tester.tap(find.text('Confirm Booking'));
|
||||
await settle(tester); // confirmation
|
||||
await tester.tap(find.text('Track pickup'));
|
||||
await settle(tester); // tracking
|
||||
@@ -760,11 +800,15 @@ void main() {
|
||||
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||
|
||||
await signIn(tester);
|
||||
await tester.tap(find.text('Orders'));
|
||||
await tester.tap(find.bySemanticsLabel('Orders'));
|
||||
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 tester.tap(find.text('Salem, Tamil Nadu'));
|
||||
await tester.tap(find.text('Salem'));
|
||||
await settle(tester); // receipt
|
||||
|
||||
await drainToasts(tester);
|
||||
@@ -774,22 +818,26 @@ void main() {
|
||||
final state = AppState();
|
||||
await signIn(tester, state: state);
|
||||
|
||||
await tester.tap(find.text('Orders'));
|
||||
await tester.tap(find.bySemanticsLabel('Orders'));
|
||||
await settle(tester);
|
||||
// Each tab carries its own count, inside the header's segmented control —
|
||||
// and the figure is the rows that tab will actually show, which for a
|
||||
// collected multi-destination pickup is one per destination.
|
||||
// Each tab carries its own count — the figure is the rows that tab will
|
||||
// actually show, which for a collected multi-destination pickup is one
|
||||
// per destination. The count shares a paragraph with the label, so it is
|
||||
// a span rather than a Text of its own.
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(DmSegmented),
|
||||
matching: find.text('${state.entriesOf(BookingStatus.active).length}'),
|
||||
find.textContaining(
|
||||
'Active (${state.entriesOf(BookingStatus.active).length})',
|
||||
),
|
||||
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);
|
||||
expect(find.text('Erode, Tamil Nadu'), findsOneWidget);
|
||||
expect(find.text('Erode'), findsOneWidget);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,12 +28,8 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
/// everything in Ahem's black boxes and the pictures are worthless.
|
||||
Future<void> _loadFonts() async {
|
||||
for (final (family, asset) in const [
|
||||
// Every cut, because Poppins is static: a FontLoader given only one file
|
||||
// renders w800 headings by smearing the one weight it has.
|
||||
('Poppins', 'assets/fonts/Poppins-Medium.ttf'),
|
||||
('Poppins', 'assets/fonts/Poppins-SemiBold.ttf'),
|
||||
('Poppins', 'assets/fonts/Poppins-Bold.ttf'),
|
||||
('Poppins', 'assets/fonts/Poppins-ExtraBold.ttf'),
|
||||
('PlusJakartaSans', 'assets/fonts/PlusJakartaSans-Variable.ttf'),
|
||||
('Switzer', 'assets/fonts/Switzer-Variable.ttf'),
|
||||
('GeistMono', 'assets/fonts/GeistMono-VF.ttf'),
|
||||
// The icon font too, or every glyph in the picture is a hollow box.
|
||||
('packages/lucide_icons_flutter/Lucide',
|
||||
@@ -152,25 +148,31 @@ void main() {
|
||||
await tester.tapAt(const Offset(200, 40)); // through the scrim
|
||||
await _settle(tester);
|
||||
|
||||
await tester.tap(find.text('Orders'));
|
||||
await tester.tap(find.bySemanticsLabel('Orders'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '04-orders');
|
||||
|
||||
await tester.tap(find.text('Account'));
|
||||
await tester.tap(find.bySemanticsLabel('Account'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '05-account');
|
||||
|
||||
await tester.tap(find.text('Home'));
|
||||
await tester.tap(find.bySemanticsLabel('Home'));
|
||||
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 _shot(tester, '06-tracking');
|
||||
|
||||
await tester.dragUntilVisible(
|
||||
find.text('Cancel this pickup'),
|
||||
find.byType(Scrollable).first,
|
||||
const Offset(0, -200),
|
||||
);
|
||||
// `ensureVisible`, not `dragUntilVisible`.
|
||||
//
|
||||
// `IndexedStack` keeps all three tab roots alive under the pushed route,
|
||||
// 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 _settle(tester);
|
||||
await _shot(tester, '06b-cancel-sheet');
|
||||
@@ -181,7 +183,7 @@ void main() {
|
||||
await _settle(tester);
|
||||
|
||||
// Booking: state, district, window, then one screen.
|
||||
await tester.tap(find.text('BOOK'));
|
||||
await tester.tap(find.text('BOOK NOW'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '07-destination-states');
|
||||
|
||||
@@ -189,7 +191,10 @@ void main() {
|
||||
await _settle(tester);
|
||||
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);
|
||||
// The dev backend allows several destinations, so the row ticked rather
|
||||
// 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
|
||||
// subject is a picture.
|
||||
await tester.tap(find.text('FROM'));
|
||||
await tester.tap(find.text('ORIGIN'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '10-pickup-map');
|
||||
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
|
||||
await _settle(tester);
|
||||
|
||||
await tester.tap(find.text('Book pickup'));
|
||||
await tester.tap(find.text('Confirm Booking'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '11-booked');
|
||||
|
||||
// A finished order, for the receipt treatment.
|
||||
await tester.tap(find.text('Back to home'));
|
||||
await _settle(tester);
|
||||
await tester.tap(find.text('Orders'));
|
||||
await tester.tap(find.bySemanticsLabel('Orders'));
|
||||
await _settle(tester);
|
||||
await tester.tap(find.text('Completed'));
|
||||
await tester.tap(find.textContaining('Past'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '04b-orders-completed');
|
||||
await tester.tap(find.text('Salem, Tamil Nadu'));
|
||||
await tester.tap(find.text('Salem'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '12-receipt');
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import 'package:http/testing.dart';
|
||||
/// against the customer API document.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// 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>;
|
||||
expect(pickup['latitude'], 13.0827);
|
||||
expect(pickup['longitude'], 80.2707);
|
||||
expect(pickup['lat'], 13.0827);
|
||||
expect(pickup['lng'], 80.2707);
|
||||
expect(pickup.containsKey('latitude'), isFalse);
|
||||
|
||||
final destination =
|
||||
(t.sent.body['destinations'] as List).single as Map<String, dynamic>;
|
||||
@@ -176,7 +185,7 @@ void main() {
|
||||
|
||||
final body = t.sent.body;
|
||||
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['contactPhone'], '+919876543210');
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 633 KiB After Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 494 KiB After Width: | Height: | Size: 403 KiB |
|
Before Width: | Height: | Size: 455 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 358 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 416 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 271 KiB |
|
Before Width: | Height: | Size: 377 KiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 296 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 415 KiB After Width: | Height: | Size: 317 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 183 KiB |