diff --git a/android/app/src/main/res/drawable/dm_mark_fg.png b/android/app/src/main/res/drawable/dm_mark_fg.png
index 25c6977..15ebbf4 100644
Binary files a/android/app/src/main/res/drawable/dm_mark_fg.png and b/android/app/src/main/res/drawable/dm_mark_fg.png differ
diff --git a/android/app/src/main/res/drawable/dm_mark_mono.png b/android/app/src/main/res/drawable/dm_mark_mono.png
new file mode 100644
index 0000000..8697303
Binary files /dev/null and b/android/app/src/main/res/drawable/dm_mark_mono.png differ
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
index 8fdb67d..80d75f4 100644
--- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -1,15 +1,20 @@
-
+ foreground The white mark, with no disc behind it, sized so its furthest
+ corner — the arrow tip — lands inside the 66dp circle Android
+ guarantees. Bigger looks better on a square mask and loses the
+ arrow on a round one.
+
+ monochrome The same silhouette with the colour discarded. The system
+ tints by alpha, so handing it the colour foreground themes as
+ a solid blob. -->
-
+
-
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
index 8fdb67d..80d75f4 100644
--- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -1,15 +1,20 @@
-
+ foreground The white mark, with no disc behind it, sized so its furthest
+ corner — the arrow tip — lands inside the 66dp circle Android
+ guarantees. Bigger looks better on a square mask and loses the
+ arrow on a round one.
+
+ monochrome The same silhouette with the colour discarded. The system
+ tints by alpha, so handing it the colour foreground themes as
+ a solid blob. -->
-
+
-
+
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
index 76b6de7..96f777b 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..28c9186
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
index 4dc9d2a..ae87a5b 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..543cfb2
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
index 2ed0860..72ad11d 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..b017ead
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
index a7c752b..05517d8 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..1e30bda
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
index b9cdb01..013cb58 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..f8b4ada
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
index 18fb6b0..21b66dc 100644
--- a/android/app/src/main/res/values/colors.xml
+++ b/android/app/src/main/res/values/colors.xml
@@ -3,4 +3,8 @@
#8F0F06
#FFFFFF
+
+ #BD0921
diff --git a/assets/images/doormile-logo-1.png b/assets/images/doormile-logo-1.png
new file mode 100644
index 0000000..dfdef34
Binary files /dev/null and b/assets/images/doormile-logo-1.png differ
diff --git a/assets/images/doormilelogo-white.png b/assets/images/doormilelogo-white.png
new file mode 100644
index 0000000..f2bf0bd
Binary files /dev/null and b/assets/images/doormilelogo-white.png differ
diff --git a/design/screens/04-home.png b/design/screens/04-home.png
index 9c46372..3dc8ec3 100644
Binary files a/design/screens/04-home.png and b/design/screens/04-home.png differ
diff --git a/design/screens/05-pickup-search.png b/design/screens/05-pickup-search.png
index 212648d..4788564 100644
Binary files a/design/screens/05-pickup-search.png and b/design/screens/05-pickup-search.png differ
diff --git a/design/screens/06-orders-active.png b/design/screens/06-orders-active.png
index f4c057b..0786a76 100644
Binary files a/design/screens/06-orders-active.png and b/design/screens/06-orders-active.png differ
diff --git a/design/screens/07-orders-completed.png b/design/screens/07-orders-completed.png
index 79392d4..e614195 100644
Binary files a/design/screens/07-orders-completed.png and b/design/screens/07-orders-completed.png differ
diff --git a/design/screens/09-tracking.png b/design/screens/09-tracking.png
index 2bc9c7d..44824da 100644
Binary files a/design/screens/09-tracking.png and b/design/screens/09-tracking.png differ
diff --git a/design/screens/11-destination-states.png b/design/screens/11-destination-states.png
index 855bc9e..874f538 100644
Binary files a/design/screens/11-destination-states.png and b/design/screens/11-destination-states.png differ
diff --git a/design/screens/12-destination-districts.png b/design/screens/12-destination-districts.png
index fd721df..d0d397c 100644
Binary files a/design/screens/12-destination-districts.png and b/design/screens/12-destination-districts.png differ
diff --git a/design/screens/13-pickup-window.png b/design/screens/13-pickup-window.png
index 4e9e7c8..86d0768 100644
Binary files a/design/screens/13-pickup-window.png and b/design/screens/13-pickup-window.png differ
diff --git a/design/screens/14-send-a-parcel.png b/design/screens/14-send-a-parcel.png
index cf72bd7..bf4e04f 100644
Binary files a/design/screens/14-send-a-parcel.png and b/design/screens/14-send-a-parcel.png differ
diff --git a/design/screens/16-pickup-booked.png b/design/screens/16-pickup-booked.png
index 246b23e..ea2f949 100644
Binary files a/design/screens/16-pickup-booked.png and b/design/screens/16-pickup-booked.png differ
diff --git a/design/screens/17-receipt.png b/design/screens/17-receipt.png
index f7bfe21..3b35506 100644
Binary files a/design/screens/17-receipt.png and b/design/screens/17-receipt.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
index dc9ada4..882e517 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
index 7353c41..56cc5f7 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
index 797d452..ef69796 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
index 6ed2d93..64d9953 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
index 4cd7b00..43baf71 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
index fe73094..ad96633 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
index 321773c..1b21b35 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
index 797d452..ef69796 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
index 502f463..e286381 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
index 0ec3034..e9fb223 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
index 0ec3034..e9fb223 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
index e9f5fea..9a59258 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
index 84ac32a..a6838d3 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
index 8953cba..82b0526 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
index 0467bf1..7d0ce05 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ
diff --git a/lib/data/models.dart b/lib/data/models.dart
index 9370243..9baae09 100644
--- a/lib/data/models.dart
+++ b/lib/data/models.dart
@@ -97,9 +97,9 @@ enum JourneyStage {
/// What a status pill should say.
///
/// Deliberately **not** [CustomerMilestone.of]. The rail collapses the whole
- /// pickup visit into "Booking created", which is right for a journey read
+ /// pickup visit into "Pickup booked", which is right for a journey read
/// over days — but a pill on Orders, or the live row on Home, is answering
- /// "what is happening to my parcel *now*", and "Booking created" is a poor
+ /// "what is happening to my parcel *now*", and "Pickup booked" is a poor
/// answer while a named Miler is two kilometres from the door. The two
/// surfaces want different granularity from the same stage, so they get
/// their own vocabulary.
@@ -151,11 +151,24 @@ enum JourneyStage {
/// rest of the journey — the part measured in days — the other three.
///
/// The rail's job is the shipment. Everything before the order exists is
-/// **Booking created**; the moment the Miler creates the order the parcel is a
+/// **Pickup booked**; the moment the Miler creates the order the parcel is a
/// consignment with a tracking number, and the four milestones after that are
/// the ones a customer checks back for over the following days.
+///
+/// ── Why the first one is not "Booking created" ──
+///
+/// It was, and it sat directly above "Order created". Two rows, one word
+/// apart, both past-tense creations of something the customer cannot
+/// distinguish — a booking and an order are the same parcel to them, and the
+/// rail's whole job is to show movement. Read down the rail it said: created,
+/// created, in transit.
+///
+/// "Pickup booked" is not a new coinage either. [JourneyStage.milestoneLabel]
+/// has called this stage exactly that all along, on Orders and on Home's live
+/// row, so the rail was the one surface using a different word for the state
+/// the rest of the app had already named.
enum CustomerMilestone {
- booked('Booking created'),
+ booked('Pickup booked'),
orderCreated('Order created'),
inTransit('In transit'),
outForDelivery('Out for delivery'),
diff --git a/lib/main.dart b/lib/main.dart
index 0b4b688..65e8732 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -142,25 +142,40 @@ ThemeData buildDoormileTheme() {
);
}
-/// Forward navigation, as a shared axis.
+/// Navigation, as a vertical axis.
///
/// ── What was wrong with the old one ──
///
-/// The incoming page slid 6% from the right and faded, and the page behind it
-/// did nothing at all — it sat still until it was covered. That is the single
-/// thing that makes a push feel like a slide projector rather than like
-/// movement: both surfaces have to travel, or the eye reads the new one as
-/// pasted over a photograph of the old one.
+/// It was horizontal. A push slid the incoming page 14% in from the right and
+/// nudged the page behind it left; going back ran that in reverse, so the
+/// screen the customer was leaving slid off to the **right**. Two things are
+/// wrong with that here.
///
-/// Now the outgoing page slides the other way and dims as it leaves, so the
-/// pair share one axis of motion; the incoming page carries a small scale-up
-/// so it arrives *towards* the viewer rather than merely across; and the fade
-/// is held back until the travel is a third done, which is what stops the two
-/// pages muddying into each other mid-transition.
+/// The first is that it is the system back gesture's direction. On Android the
+/// predictive-back swipe and on iOS the edge swipe both drag horizontally, so a
+/// page that also leaves horizontally under a button press reads as a gesture
+/// the customer did not make.
///
-/// The timing lives on [DmPageRoute]: 420ms in, 340ms back. Going back is
-/// always quicker than going forward — the customer already knows what is
-/// behind them.
+/// The second is what these pages *are*. Every push in this app is a layer over
+/// the one before it — the booking form over Home, the map pin over the form,
+/// tracking over an order. A layer arrives from below and falls away below; it
+/// does not slide in from beside the thing it is covering.
+///
+/// ── What it is ──
+///
+/// The incoming page rises from the bottom edge and covers the screen. The page
+/// it covers stays exactly where it is and dims, because it has not moved — it
+/// has been covered, and pretending otherwise was the horizontal version's
+/// parallax trick solving a problem a full-travel cover does not have.
+///
+/// Going back, the top page falls straight down off the bottom and the page
+/// underneath comes back up to full strength. Top to bottom, out of the way.
+///
+/// The travel is the full height rather than a token 14%, which is what makes
+/// the direction legible at all: a page that moves 55 points and fades has no
+/// direction a customer can name. The timing lives on [DmPageRoute] — 420ms in,
+/// 340ms back. Going back is always quicker than going forward, because the
+/// customer already knows what is behind them.
class _DmPageTransitions extends PageTransitionsBuilder {
const _DmPageTransitions();
@@ -175,6 +190,9 @@ class _DmPageTransitions extends PageTransitionsBuilder {
final enter = CurvedAnimation(
parent: animation,
curve: DmMotion.enter,
+ // Flipped on the way back: the emphasised decelerate leaves quickly and
+ // settles slowly, and a page falling away wants the same shape read from
+ // the other end — off the bottom edge decisively, not trailing.
reverseCurve: DmMotion.enter.flipped,
);
final exit = CurvedAnimation(
@@ -183,34 +201,17 @@ class _DmPageTransitions extends PageTransitionsBuilder {
reverseCurve: DmMotion.enter.flipped,
);
- return SlideTransition(
- // The page being covered leaves to the left, a third of the distance the
- // arriving one travels. Parallax: the thing further away moves less.
- position: Tween(
- begin: Offset.zero,
- end: const Offset(-0.09, 0),
- ).animate(exit),
- child: FadeTransition(
- opacity: Tween(begin: 1.0, end: 0.6).animate(exit),
- child: SlideTransition(
- position: Tween(
- begin: const Offset(0.14, 0),
- end: Offset.zero,
- ).animate(enter),
- child: FadeTransition(
- // Held back: at 0 the page is transparent, at 0.35 of the travel
- // it is solid. Fading over the whole distance leaves both pages
- // half-visible through each other for most of it.
- opacity: CurvedAnimation(
- parent: animation,
- curve: const Interval(0, 0.35, curve: Curves.easeOut),
- ),
- child: ScaleTransition(
- scale: Tween(begin: 0.97, end: 1.0).animate(enter),
- child: child,
- ),
- ),
- ),
+ return FadeTransition(
+ // This page, being covered by the one above it. Not to nothing: at 0.5
+ // the page behind is still legibly itself, which is what tells the
+ // customer the thing on top is a layer and not a new place.
+ opacity: Tween(begin: 1.0, end: 0.5).animate(exit),
+ child: SlideTransition(
+ position: Tween(
+ begin: const Offset(0, 1),
+ end: Offset.zero,
+ ).animate(enter),
+ child: child,
),
);
}
diff --git a/lib/ui/screens/account_screen.dart b/lib/ui/screens/account_screen.dart
index ab64436..08636d0 100644
--- a/lib/ui/screens/account_screen.dart
+++ b/lib/ui/screens/account_screen.dart
@@ -158,7 +158,7 @@ class AccountScreen extends StatelessWidget {
DmSpace.pad,
4,
DmSpace.pad,
- 74 + MediaQuery.paddingOf(context).bottom,
+ dmTabBarClear(context),
),
children: [
const DmMicroHead('Preferences', top: 4, brand: false),
diff --git a/lib/ui/screens/booking/send_screen.dart b/lib/ui/screens/booking/send_screen.dart
index 0bbb121..0a42958 100644
--- a/lib/ui/screens/booking/send_screen.dart
+++ b/lib/ui/screens/booking/send_screen.dart
@@ -414,8 +414,20 @@ class _RouteHead extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
+ // ── The same two words the rest of the app uses ──
+ //
+ // This said ORIGIN and DESTINATION. Tracking says PICKUP and DROP, and
+ // so does the pick/drop form on Home — the argument is recorded on the
+ // tracking card and it is right: those are logistics words for a
+ // consignment, and the customer's parcel is collected from somewhere
+ // and dropped somewhere.
+ //
+ // One journey was being named twice, in two vocabularies, on three
+ // screens a customer walks through in order. Whichever pair is better
+ // matters less than there being one pair, and the other two surfaces
+ // had already chosen.
_Stop(
- label: 'ORIGIN',
+ label: 'PICKUP',
value: from,
note: fromNote,
onTap: onTapFrom,
@@ -424,7 +436,7 @@ class _RouteHead extends StatelessWidget {
),
for (var i = 0; i < to.length; i++)
_Stop(
- label: to.length == 1 ? 'DESTINATION' : 'DESTINATION ${i + 1}',
+ label: to.length == 1 ? 'DROP' : 'DROP ${i + 1}',
// BOOK no longer asks where it is going, so this row is where the
// question lands. Unset it reads as an invitation rather than as
// the em dash `shortLabel` falls back to.
diff --git a/lib/ui/screens/home_screen.dart b/lib/ui/screens/home_screen.dart
index cf75c8d..5b092da 100644
--- a/lib/ui/screens/home_screen.dart
+++ b/lib/ui/screens/home_screen.dart
@@ -135,7 +135,10 @@ class _HomeScreenState extends State {
// this is the top of the screen, so it takes the inset the bar used
// to hold.
Padding(
- padding: const EdgeInsets.fromLTRB(DmSpace.pad, 26, DmSpace.pad, 0),
+ // 18, not 26. The inset was set when the greeting was one line
+ // with nothing under it; the pill below it now carries its own
+ // height, and 26 on top of that started the screen twice.
+ padding: const EdgeInsets.fromLTRB(DmSpace.pad, 18, DmSpace.pad, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -176,6 +179,15 @@ class _HomeScreenState extends State {
),
],
),
+ const SizedBox(height: 12),
+ _PickupLine(
+ area: _areaOf(app.pickup),
+ resolving: app.resolvingPickup,
+ onTap: () async {
+ final place = await showPlaceSearchSheet(context);
+ if (place != null && context.mounted) app.setPickup(place);
+ },
+ ),
],
),
),
@@ -246,6 +258,20 @@ class _HomeScreenState extends State {
DmBookOrb(
onTap: _book,
held: _handingOver,
+ // ── The caption used to fall off ──
+ //
+ // The sphere's glow field was a constant
+ // 320, the caption sat under it inside a
+ // scroll viewport, and every time Home
+ // grew — a live card, a pickup pill — the
+ // viewport clipped the caption in silence.
+ //
+ // The field is now whatever is left once
+ // the caption has its line. See
+ // [DmBookOrb.field]; it clamps itself, so
+ // a very short screen shrinks the glow
+ // rather than eating the sentence.
+ field: box.maxHeight - _captionRoom,
// Short enough to sit on one line, and
// written to be read against the DROP
// row's note rather than on its own:
@@ -288,7 +314,11 @@ class _HomeScreenState extends State {
// round it, so a chip pinned directly under the column overlapped
// the shadow the sphere throws.
Padding(
- padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 0),
+ // 14 on top: the sphere's caption ends where this begins, and with
+ // nothing between them the sentence read as the card's heading.
+ // It costs the sphere 14 points of glow and nothing else — the
+ // field is whatever is left over now. See [DmBookOrb.field].
+ padding: const EdgeInsets.fromLTRB(DmSpace.pad, 14, DmSpace.pad, 0),
child: _PickDropForm(
pickup: app.pickup?.title,
onPickup: () async {
@@ -299,22 +329,42 @@ class _HomeScreenState extends State {
).animate().fadeIn(duration: 380.ms, delay: 300.ms),
),
- // 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),
+ // Clears the floating tab bar — see [dmTabBarClear], which also
+ // records why this used to be twice as tall as it needed to be.
+ SizedBox(height: dmTabBarClear(context)),
],
),
);
}
+ /// The line under the sphere, plus its gap. Reserved out of the sphere's
+ /// field rather than laid out after it and hoped for.
+ static const _captionRoom = 28.0;
+
static String _firstName(String? name) {
final first = (name ?? '').trim().split(RegExp(r'\s+')).first;
return first.isEmpty ? 'there' : first;
}
+
+ /// The locality the pickup sits in — `Gandhipuram, Coimbatore 641012` — as
+ /// distinct from the door, which is [Place.title].
+ ///
+ /// ── Why the pill and the form do not print the same string ──
+ ///
+ /// Both are the pickup, and if both said `12 Nehru Street` the screen would
+ /// be answering one question twice, four hundred points apart. They are not
+ /// the same question. The pill under the customer's name is *where Doormile
+ /// is serving you* — the thing you check at a glance and change when you
+ /// have travelled. The PICKUP row in the form is *the door a Miler rings*,
+ /// and it wants the number and the street.
+ ///
+ /// So the pill takes the area line and the form keeps the door. When the
+ /// geocoder sends no area, the door is better than an empty pill.
+ static String? _areaOf(Place? place) {
+ if (place == null) return null;
+ final sub = place.sub.trim();
+ return sub.isEmpty ? place.title : sub;
+ }
}
/// The live booking, at the head of Home.
@@ -460,6 +510,107 @@ class _LiveCard extends StatelessWidget {
}
}
+/// Where Doormile is serving this customer, under their name.
+///
+/// ── It went missing, and the screen lost its footing ──
+///
+/// This pill has been most shapes — box, line, pill, row — and at one point it
+/// was taken off Home entirely on the argument that the booking form below
+/// already carried the pickup. The argument was wrong in a way that is only
+/// obvious once it is gone: the form is four hundred points down the screen,
+/// under a sphere, and a customer who has just got off a train wants to know
+/// *before* they touch anything whether the app has followed them. A location
+/// you can only see by scrolling to a form is a location you do not trust.
+///
+/// So it is back, directly under the greeting, where the first glance lands.
+/// A brand pin, the locality, and a chevron-down — down rather than right,
+/// because what it opens is a chooser and not another screen.
+///
+/// ── The state it never used to show ──
+///
+/// It printed whatever [AppState.pickup] held and nothing else, so while the
+/// fix and the reverse-geocode were in flight it showed the *last* place the
+/// customer was — confidently, with a pin beside it. Now the resolving case is
+/// its own: a muted pin, a dimmed line, and the word for what is happening.
+class _PickupLine extends StatelessWidget {
+ const _PickupLine({
+ required this.area,
+ required this.resolving,
+ required this.onTap,
+ });
+
+ /// The locality line, or null when nothing has been resolved yet.
+ final String? area;
+
+ /// A fix or a reverse-geocode is in flight.
+ final bool resolving;
+
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final unset = area == null;
+ final label = resolving
+ ? 'Finding your location\u2026'
+ : (area ?? 'Set a pickup address');
+ // Muted while it is a guess or a prompt; full brand once it is an answer.
+ final pinColour = resolving || unset ? DmColors.ink4 : DmColors.brand;
+
+ return Semantics(
+ button: true,
+ label: resolving
+ ? 'Finding your pickup location'
+ : 'Picking up from $label. Change',
+ child: GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: onTap,
+ child: Align(
+ alignment: Alignment.centerLeft,
+ child: AnimatedContainer(
+ duration: DmMotion.base,
+ curve: DmMotion.ease,
+ 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: [
+ Icon(LucideIcons.mapPin, size: 15, color: pinColour),
+ const SizedBox(width: 8),
+ Flexible(
+ child: AnimatedDefaultTextStyle(
+ duration: DmMotion.base,
+ curve: DmMotion.ease,
+ style: DmText.cardTitle.copyWith(
+ fontSize: 14,
+ color: resolving || unset ? DmColors.ink3 : null,
+ ),
+ child: Text(
+ label,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ),
+ const SizedBox(width: 6),
+ const Icon(
+ LucideIcons.chevronDown,
+ size: 15,
+ color: DmColors.ink4,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
/// Pickup over drop, on one card — the shape every ride app asks a journey in.
///
/// ── Why the app has two ways to start and only one of them is a sphere ──
diff --git a/lib/ui/screens/orders_screen.dart b/lib/ui/screens/orders_screen.dart
index eda327e..92feb9a 100644
--- a/lib/ui/screens/orders_screen.dart
+++ b/lib/ui/screens/orders_screen.dart
@@ -133,7 +133,7 @@ class _OrdersScreenState extends State {
DmSpace.pad,
0,
DmSpace.pad,
- 74 + MediaQuery.paddingOf(context).bottom,
+ dmTabBarClear(context),
),
itemCount: rows.isEmpty ? 1 : rows.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
diff --git a/lib/ui/tokens.dart b/lib/ui/tokens.dart
index 2c35532..0fa1038 100644
--- a/lib/ui/tokens.dart
+++ b/lib/ui/tokens.dart
@@ -278,6 +278,29 @@ class DmShadow {
static const three = sheet;
}
+/// Clearance for the floating tab bar.
+///
+/// ── It was being counted twice ──
+///
+/// Every root padded its foot by `MediaQuery.paddingOf(context).bottom + 74`,
+/// on the reasoning that the inset is the device's home indicator and 74 is
+/// the bar floating over it. That is not what the body is handed. `Scaffold`
+/// with `extendBody: true` **replaces** the body's bottom padding with the
+/// laid-out height of the `bottomNavigationBar` — the bar's own SafeArea, its
+/// 10 of padding and its 62 of pill, all of it. Measured: a 34pt device inset
+/// and this bar gives the body `padding.bottom = 106`, which is the bar
+/// exactly.
+///
+/// So the roots were clearing the bar, and then clearing it again. It cost 74
+/// points of empty canvas under the last card on Home, Orders and Account —
+/// enough that Home's caption had nowhere to go while a hole sat under the
+/// form.
+///
+/// The inset is the clearance. [gap] is the air between the last card and the
+/// top of the pill, which is a design decision rather than a measurement.
+double dmTabBarClear(BuildContext context, {double gap = 12}) =>
+ MediaQuery.paddingOf(context).bottom + gap;
+
class DmMotion {
DmMotion._();
static const fast = Duration(milliseconds: 140);
diff --git a/lib/ui/widgets/book_orb.dart b/lib/ui/widgets/book_orb.dart
index 55ddf60..f942d2f 100644
--- a/lib/ui/widgets/book_orb.dart
+++ b/lib/ui/widgets/book_orb.dart
@@ -63,8 +63,20 @@ class DmBookOrb extends StatefulWidget {
this.label = 'ONE TOUCH',
this.caption,
this.held = false,
+ this.field = maxField,
});
+ /// The sphere itself. Fixed — it is the one object on the screen and it does
+ /// not negotiate its size with the furniture around it.
+ static const disc = 204.0;
+
+ /// The room the pulses are given when the screen can spare it.
+ static const maxField = disc + 116;
+
+ /// The floor. The comet orbits at `disc / 2 + 16`, so anything under this
+ /// clips the one detail that stops the sphere looking switched off.
+ static const minField = disc + 44;
+
final VoidCallback onTap;
final String label;
@@ -80,6 +92,26 @@ class DmBookOrb extends StatefulWidget {
/// second use of this widget elsewhere is not forced to repeat itself.
final String? caption;
+ /// The square the sphere floats in, pulses and all.
+ ///
+ /// ── Why this is not a constant any more ──
+ ///
+ /// It was [maxField], always, and the caption under it was therefore the
+ /// first thing off the screen whenever Home grew. That has now happened
+ /// four separate times, most recently when the pickup pill came back under
+ /// the greeting and took 52 points with it: the sphere still drew, the
+ /// caption still existed, and the scroll viewport simply clipped it. Nothing
+ /// failed and nothing said so.
+ ///
+ /// The glow is the part that can afford to give. [_RadarPainter] is already
+ /// parametric — a pulse is born at the sphere's edge and dies at the field's,
+ /// so a smaller field is a shorter pulse rather than a broken one. The
+ /// sphere is [disc] at every size.
+ ///
+ /// The caller passes what it can spare; this clamps it to something the
+ /// comet still fits inside.
+ final double field;
+
@override
State createState() => _DmBookOrbState();
}
@@ -132,8 +164,8 @@ class _DmBookOrbState extends State with TickerProviderStateMixin {
@override
Widget build(BuildContext context) {
- const disc = 204.0;
- const field = disc + 116;
+ const disc = DmBookOrb.disc;
+ final field = widget.field.clamp(DmBookOrb.minField, DmBookOrb.maxField);
return Semantics(
button: true,
@@ -170,7 +202,7 @@ class _DmBookOrbState extends State with TickerProviderStateMixin {
child: AnimatedBuilder(
animation: _radar,
builder: (_, _) => CustomPaint(
- size: const Size.square(field),
+ size: Size.square(field),
painter: _RadarPainter(_radar.value, disc / 2),
),
),
diff --git a/pubspec.yaml b/pubspec.yaml
index e8a5f10..7b019bb 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -105,7 +105,16 @@ flutter:
- asset: assets/fonts/GeistMono-VF.ttf
assets:
- - assets/images/
+ # ── Named one by one, not as a folder ──
+ #
+ # `assets/images/` bundled the whole directory, and that directory is also
+ # where the launcher-icon master lives — 974 KB of PNG that `tool/icons.py`
+ # reads at build time on a developer's machine and the app never loads.
+ # Declaring the folder shipped it to every customer, along with anything
+ # else dropped in there to work from. These two are the only images any
+ # code actually opens; see `splash_screen.dart` and `auth_scaffold.dart`.
+ - assets/images/doormile-icon.png
+ - assets/images/doormile-logo.png
- assets/animations/
# To add assets to your application, add an assets section, like this:
diff --git a/test/booking_flow_test.dart b/test/booking_flow_test.dart
index 734d8a4..4684512 100644
--- a/test/booking_flow_test.dart
+++ b/test/booking_flow_test.dart
@@ -151,7 +151,7 @@ Future pickCity(
Future openPickupEditor(WidgetTester tester) async {
await openSend(tester);
await pickCity(tester, 'Chennai');
- await tester.tap(find.text('ORIGIN'));
+ await tester.tap(find.text('PICKUP'));
await settle(tester);
}
@@ -250,7 +250,7 @@ void main() {
await tester.tap(find.text('Confirm pickup point'));
await settle(tester);
// Back on the send screen: its title is the route, not a heading.
- expect(find.text('DESTINATION'), findsOneWidget);
+ expect(find.text('DROP'), findsOneWidget);
expect(find.text(corrected), findsOneWidget);
await drainToasts(tester);
});
@@ -396,7 +396,7 @@ void main() {
// …and that one point is what the booking carries forward.
await tester.tap(find.text('Confirm pickup point'));
await settle(tester);
- expect(find.text('DESTINATION'), findsOneWidget);
+ expect(find.text('DROP'), findsOneWidget);
expect(state.draftPickup!.lat, searched.lat);
expect(state.draftPickup!.lng, searched.lng);
expect(state.draftPickup!.title, 'Brookefields Mall');
@@ -517,8 +517,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('ORIGIN'), findsOneWidget);
- expect(find.text('DESTINATION'), findsOneWidget);
+ expect(find.text('PICKUP'), findsOneWidget);
+ expect(find.text('DROP'), 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);
@@ -565,7 +565,7 @@ void main() {
expect(state.draftContactPhone, isNull);
// Changed, it is the number the Miler will ring at the door.
- await tester.tap(find.text('ORIGIN'));
+ await tester.tap(find.text('PICKUP'));
await settle(tester);
await tester.tap(find.text('Someone else is handing it over?'));
await settle(tester);
@@ -723,7 +723,7 @@ void main() {
// Five shipment milestones. Everything before the order exists is one
// row — the whole pickup visit, which the live card above narrates by
// name, with a distance and an ETA.
- expect(find.text('Booking created'), findsOneWidget);
+ expect(find.text('Pickup booked'), findsOneWidget);
expect(find.text('Order created'), findsOneWidget);
expect(find.text('In transit'), findsOneWidget);
expect(find.text('Out for delivery'), findsOneWidget);
@@ -737,7 +737,7 @@ void main() {
// Advancing through the visit does not add a row — it moves the detail
// under the one row the whole visit occupies.
await serverAdvance(tester, state, 'DM-482913', JourneyStage.onTheWay);
- expect(find.text('Booking created'), findsOneWidget);
+ expect(find.text('Pickup booked'), findsOneWidget);
expect(find.textContaining('Miler on the way'), findsWidgets);
await drainToasts(tester);
});
@@ -868,7 +868,7 @@ void main() {
// The pickup map: a fixed-height sheet over a full-bleed map, so large
// text has nowhere to grow into. Opening the contact fold is the tallest
// it ever gets.
- await tester.tap(find.text('ORIGIN'));
+ await tester.tap(find.text('PICKUP'));
await settle(tester);
await tester.tap(find.text('Someone else is handing it over?'));
await settle(tester);
@@ -880,7 +880,7 @@ void main() {
await tester.tap(find.text('Track pickup'));
await settle(tester); // tracking
// The live card names the stage; the rail names the shipment milestone.
- expect(find.text('Booking created'), findsWidgets);
+ expect(find.text('Pickup booked'), findsWidgets);
await drainToasts(tester);
});
diff --git a/test/design_snapshot_test.dart b/test/design_snapshot_test.dart
index 1ac938a..24492db 100644
--- a/test/design_snapshot_test.dart
+++ b/test/design_snapshot_test.dart
@@ -213,7 +213,7 @@ 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('ORIGIN'));
+ await tester.tap(find.text('PICKUP'));
await _settle(tester);
await _shot(tester, '10-pickup-map');
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
diff --git a/test/page_transition_test.dart b/test/page_transition_test.dart
new file mode 100644
index 0000000..28ce742
--- /dev/null
+++ b/test/page_transition_test.dart
@@ -0,0 +1,139 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:doormile_cx/main.dart';
+import 'package:doormile_cx/ui/screens/booking/booking_routes.dart';
+
+/// ─────────────────────────────────────────────────────────────────────────
+/// WHICH WAY A PAGE LEAVES
+///
+/// The app's pages used to arrive from the right and leave to the right, which
+/// is the direction the system back gesture drags — a page that slides off
+/// sideways under a button press reads as a swipe the customer did not make.
+///
+/// They now rise from the bottom and fall back down it. A golden cannot see
+/// motion, so this is where that direction is held: mid-push the incoming page
+/// is *below* where it lands and exactly on its horizontal mark, and mid-pop it
+/// is on its way back down rather than off to one side.
+/// ─────────────────────────────────────────────────────────────────────────
+void main() {
+ const key = ValueKey('pushed-page');
+
+ Future pumpApp(WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: buildDoormileTheme(),
+ home: Builder(
+ builder: (context) => Scaffold(
+ body: Center(
+ child: TextButton(
+ onPressed: () => Navigator.of(context).push(
+ bookingRoute(
+ 'x',
+ (_) => const Scaffold(
+ key: key,
+ body: Center(child: Text('second')),
+ ),
+ ),
+ ),
+ child: const Text('go'),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ /// Where the pushed page's top-left corner currently sits.
+ Offset cornerOf(WidgetTester tester) =>
+ tester.getTopLeft(find.byKey(key).first);
+
+ testWidgets('a push arrives from below, not from the side', (tester) async {
+ await pumpApp(tester);
+ final screen = tester.view.physicalSize / tester.view.devicePixelRatio;
+
+ await tester.tap(find.text('go'));
+ await tester.pump();
+ // Partway through the 420ms push.
+ await tester.pump(const Duration(milliseconds: 120));
+
+ final corner = cornerOf(tester);
+ expect(
+ corner.dy,
+ greaterThan(0),
+ reason: 'the arriving page should still be below its resting place',
+ );
+ expect(
+ corner.dy,
+ lessThan(screen.height),
+ reason: 'and already on screen, not still parked off the bottom',
+ );
+ expect(
+ corner.dx,
+ 0,
+ reason: 'nothing travels horizontally any more',
+ );
+
+ await tester.pumpAndSettle();
+ expect(cornerOf(tester), Offset.zero);
+ });
+
+ testWidgets('back drops the page down the screen', (tester) async {
+ await pumpApp(tester);
+ await tester.tap(find.text('go'));
+ await tester.pumpAndSettle();
+
+ final settled = cornerOf(tester);
+ expect(settled, Offset.zero);
+
+ // The back button, not a gesture.
+ final popped = tester.state(find.byType(Navigator));
+ popped.pop();
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 120));
+
+ final leaving = cornerOf(tester);
+ expect(
+ leaving.dy,
+ greaterThan(0),
+ reason: 'the page being left should be heading down, not up',
+ );
+ expect(
+ leaving.dx,
+ 0,
+ reason: 'and not sideways, which is what it used to do',
+ );
+ });
+
+ testWidgets('it travels the whole height, so the direction is readable', (
+ tester,
+ ) async {
+ await pumpApp(tester);
+ final screen = tester.view.physicalSize / tester.view.devicePixelRatio;
+
+ await tester.tap(find.text('go'));
+ await tester.pump();
+ // One frame in: the page has barely moved, so it is still near the bottom
+ // edge. A 14%-of-the-width nudge — what this replaced — would put it within
+ // a few points of its resting place by now.
+ await tester.pump(const Duration(milliseconds: 16));
+
+ expect(
+ cornerOf(tester).dy,
+ greaterThan(screen.height * 0.5),
+ reason: 'a token nudge has no direction a customer can name',
+ );
+ });
+
+ testWidgets('going back is quicker than going forward', (tester) async {
+ final route = bookingRoute('x', (_) => const SizedBox()) as DmPageRoute;
+ expect(route.transitionDuration, const Duration(milliseconds: 420));
+ expect(route.reverseTransitionDuration, const Duration(milliseconds: 340));
+ expect(
+ route.reverseTransitionDuration < route.transitionDuration,
+ isTrue,
+ reason: 'the customer already knows what is behind them',
+ );
+ });
+}
diff --git a/test/snapshots/03-home.png b/test/snapshots/03-home.png
index 9c46372..3dc8ec3 100644
Binary files a/test/snapshots/03-home.png and b/test/snapshots/03-home.png differ
diff --git a/test/snapshots/03b-pickup-search.png b/test/snapshots/03b-pickup-search.png
index 212648d..4788564 100644
Binary files a/test/snapshots/03b-pickup-search.png and b/test/snapshots/03b-pickup-search.png differ
diff --git a/test/snapshots/04-orders.png b/test/snapshots/04-orders.png
index f4c057b..0786a76 100644
Binary files a/test/snapshots/04-orders.png and b/test/snapshots/04-orders.png differ
diff --git a/test/snapshots/04b-orders-completed.png b/test/snapshots/04b-orders-completed.png
index 79392d4..e614195 100644
Binary files a/test/snapshots/04b-orders-completed.png and b/test/snapshots/04b-orders-completed.png differ
diff --git a/test/snapshots/06-tracking.png b/test/snapshots/06-tracking.png
index 2bc9c7d..44824da 100644
Binary files a/test/snapshots/06-tracking.png and b/test/snapshots/06-tracking.png differ
diff --git a/test/snapshots/07-destination-states.png b/test/snapshots/07-destination-states.png
index 855bc9e..874f538 100644
Binary files a/test/snapshots/07-destination-states.png and b/test/snapshots/07-destination-states.png differ
diff --git a/test/snapshots/07b-destination-districts.png b/test/snapshots/07b-destination-districts.png
index fd721df..d0d397c 100644
Binary files a/test/snapshots/07b-destination-districts.png and b/test/snapshots/07b-destination-districts.png differ
diff --git a/test/snapshots/08-send.png b/test/snapshots/08-send.png
index cf72bd7..bf4e04f 100644
Binary files a/test/snapshots/08-send.png and b/test/snapshots/08-send.png differ
diff --git a/test/snapshots/09-window-sheet.png b/test/snapshots/09-window-sheet.png
index 4e9e7c8..86d0768 100644
Binary files a/test/snapshots/09-window-sheet.png and b/test/snapshots/09-window-sheet.png differ
diff --git a/test/snapshots/11-booked.png b/test/snapshots/11-booked.png
index 246b23e..ea2f949 100644
Binary files a/test/snapshots/11-booked.png and b/test/snapshots/11-booked.png differ
diff --git a/test/snapshots/12-receipt.png b/test/snapshots/12-receipt.png
index f7bfe21..3b35506 100644
Binary files a/test/snapshots/12-receipt.png and b/test/snapshots/12-receipt.png differ
diff --git a/tool/icons.py b/tool/icons.py
new file mode 100644
index 0000000..bd37eed
--- /dev/null
+++ b/tool/icons.py
@@ -0,0 +1,411 @@
+#!/usr/bin/env python3
+"""Renders every launcher icon in the app from one master artwork.
+
+ python3 tool/icons.py
+
+── Why this is a script and not twenty-eight exported PNGs ──
+
+An icon set is twenty-eight files across three platforms, and the failure mode
+when they are exported by hand is that nineteen of them get replaced and the
+other nine keep the old mark — on the one density nobody tests. Everything here
+comes from [MASTER], so the set cannot drift apart.
+
+── The master ──
+
+`assets/images/doormile-logo-1.png`: the Doormile D with the road running
+through it, in white, directly on a crimson field. No containing disc — which
+is the thing that changed, and the reason this file was rewritten. The previous
+master put the mark inside a white circle, and a circle is its own safe zone:
+it could be dropped into Android's adaptive canvas whole. A bare mark cannot.
+
+── Two things the master is not, and this fixes ──
+
+**It is not flat.** The field ranges over ten values of red across the canvas —
+a vignette from whatever rendered it. Scaled down to 48px that is invisible;
+sitting next to the flat `@color` the adaptive background is drawn with, it is
+a seam. Every output here is composited onto one sampled field colour.
+
+**It is not square on the canvas**, and that turned out to be deliberate. The
+mark's bounding box sits 38px right of centre. This script first read that as
+sloppiness and squared it up, which made the icon lean left — see [NUDGE_PCT]
+for the three measurements and why geometric centring is the wrong centre for a
+shape with an arrow on one side. The offset is placed here rather than
+inherited, so it survives a redraw and can be dialled.
+
+── The three shapes it has to become ──
+
+**iOS** takes the artwork whole. The squircle crops the corners and the mark
+sits well inside what is left. Written without an alpha channel, because the
+App Store rejects an icon that has one.
+
+**Android adaptive** hands the icon a 108dp canvas, shows about 72dp of it, and
+guarantees only a 66dp circle in the middle — then masks that to whatever shape
+the device chose. So the two layers are split: the crimson becomes the
+*background*, as a colour rather than a bitmap, and the white mark becomes the
+*foreground*, scaled by [SAFE_DP] so that its furthest corner still lands inside
+the safe circle. That is what protects the arrow tip, which is the most
+distinctive thing on the mark and the first thing a circular mask would take.
+
+**Monochrome** (themed icons, Android 13+) is tinted by the system using nothing
+but the alpha channel, so a full-colour foreground themes as a solid blob. The
+monochrome asset is the same silhouette with the colour thrown away.
+
+**Web** gets both — a plain icon that is the artwork, and a `maskable` one
+padded the way Android's is, for the same reason.
+"""
+
+from __future__ import annotations
+
+import json
+import math
+import os
+import re
+from collections import Counter
+
+from PIL import Image, ImageDraw
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+MASTER = os.path.join(ROOT, 'assets/images/doormile-logo-1.png')
+
+# Supersample factor for every mask drawn here. PIL has no antialiased
+# primitives; drawing at 4x and resampling down is how a circle gets a clean
+# edge instead of a staircase.
+SS = 4
+
+# ── The adaptive-icon budget, in dp on a 108dp canvas ──
+#
+# 66 is Android's guaranteed safe circle. 64 leaves a point of margin, and it is
+# applied to the mark's *circumscribed* diameter rather than to its bounding
+# box: the two extremes of this mark are the arrow tip at the top right and the
+# foot of the D at the bottom left, which is to say its bbox corners. Size by
+# the box and those corners sit outside the circle a round mask keeps.
+CANVAS_DP = 108.0
+SAFE_DP = 64.0
+FG_PX = 432 # 108dp at xxxhdpi — the size every adaptive foreground ships at.
+
+# Green-channel key. The field's green is about 9 and the mark's about 253, so
+# anything between these is the antialiased edge between them and maps to
+# partial coverage. The floor is well above the field's noise, so the vignette
+# keys out to nothing rather than to a faint haze.
+KEY_LO = 40
+KEY_HI = 240
+
+# ── Where the mark sits, as a percentage of the canvas, positive to the right ──
+#
+# Zero is not centred, it is only *geometrically* centred, and for this mark
+# those are different things. The arrow carries the bounding box a long way to
+# the right while contributing almost none of the ink, so squaring the box up
+# with the canvas drags the heavy part — the D — to the left and the whole thing
+# leans. Three measurements off this artwork:
+#
+# bounding-box centre 0.00% what a computer calls centred
+# as the logo was drawn +3.07% what the designer drew
+# centre of ink mass +5.48% full optical correction
+#
+# The first is what this script shipped, and it was wrong: the offset in the
+# master was not sloppiness to be tidied away, it was the compensation. The
+# last over-corrects, because a thin protrusion weighs nothing and reaches far,
+# which is exactly the case mass-centring handles badly.
+#
+# So: back to where it was drawn. Raise this to push the mark further right.
+# [build_android_adaptive] measures the safe circle from the canvas centre
+# *after* the nudge, so moving it costs a little size rather than the arrow tip.
+NUDGE_PCT = 3.0
+
+
+def log(msg: str) -> None:
+ print(msg)
+
+
+# ────────────────────────────────────────────────────── reading the master
+
+def field_colour(im: Image.Image) -> tuple[int, int, int]:
+ """The most common colour around the border.
+
+ The mode, not a corner pixel: the field is not flat, and one sample picks
+ up whatever the vignette happens to be doing in that corner.
+ """
+ px = im.load()
+ w, h = im.size
+ ring = []
+ for i in range(0, w, 3):
+ for j in (1, 3, 5, w - 2, w - 4, w - 6):
+ ring.append(px[i, j])
+ ring.append(px[j, i])
+ return Counter(ring).most_common(1)[0][0]
+
+
+def mark_alpha(im: Image.Image) -> Image.Image:
+ """Coverage of the mark, keyed off the field by brightness."""
+ span = KEY_HI - KEY_LO
+ return im.convert('RGB').split()[1].point(
+ lambda g: max(0, min(255, round((g - KEY_LO) * 255 / span)))
+ )
+
+
+def mark_colour(im: Image.Image, alpha: Image.Image) -> tuple[int, int, int]:
+ """The mark's own white, sampled rather than assumed to be #FFFFFF."""
+ px = im.convert('RGB').load()
+ ap = alpha.load()
+ w, h = im.size
+ hits = [px[x, y] for y in range(0, h, 7) for x in range(0, w, 7) if ap[x, y] == 255]
+ return Counter(hits).most_common(1)[0][0]
+
+
+def mark_geometry(alpha: Image.Image) -> tuple[float, float]:
+ """Centre of the mark's bounding box."""
+ box = alpha.point(lambda a: 255 if a > 127 else 0).getbbox()
+ if box is None:
+ raise SystemExit('no mark found in the master')
+ x0, y0, x1, y1 = box
+ return (x0 + x1 - 1) / 2, (y0 + y1 - 1) / 2
+
+
+def reach(art: Image.Image) -> float:
+ """How far the mark's furthest pixel is from the CANVAS centre.
+
+ Measured after the nudge, not before, and from the canvas rather than from
+ the mark: Android's safe zone is a circle centred on the canvas, so once the
+ mark is off-centre its reach is what has to fit, not its own radius.
+ """
+ ap = art.split()[3].load()
+ w, h = art.size
+ cx, cy = (w - 1) / 2, (h - 1) / 2
+ r = 0.0
+ for y in range(h):
+ for x in range(w):
+ if ap[x, y] > 127:
+ d = math.hypot(x - cx, y - cy)
+ if d > r:
+ r = d
+ return r
+
+
+# ────────────────────────────────────────────────────── building the parts
+
+def circle_mask(size: int, diameter: float) -> Image.Image:
+ """An antialiased filled circle, centred, as an 8-bit mask."""
+ big = Image.new('L', (size * SS, size * SS), 0)
+ d = ImageDraw.Draw(big)
+ r = diameter * SS / 2
+ c = size * SS / 2
+ d.ellipse([c - r, c - r, c + r, c + r], fill=255)
+ return big.resize((size, size), Image.LANCZOS)
+
+
+def build_master() -> tuple[Image.Image, Image.Image, tuple[int, int, int], float]:
+ """Returns (flat artwork, mark-on-transparent, field colour, mark radius).
+
+ Both are square, both are centred on the mark, and both have had the
+ vignette composited away.
+ """
+ raw = Image.open(MASTER).convert('RGB')
+ if raw.width != raw.height:
+ raise SystemExit(f'master must be square, got {raw.size}')
+
+ field = field_colour(raw)
+ alpha = mark_alpha(raw)
+ ink = mark_colour(raw, alpha)
+ cx, cy = mark_geometry(alpha)
+
+ size = raw.width
+ nudge = round(size * NUDGE_PCT / 100)
+
+ # The mark, on nothing, moved so it sits where [NUDGE_PCT] asks.
+ cut = Image.merge('RGBA', (
+ Image.new('L', raw.size, ink[0]),
+ Image.new('L', raw.size, ink[1]),
+ Image.new('L', raw.size, ink[2]),
+ alpha,
+ ))
+ dx = round(size / 2 - cx) + nudge
+ dy = round(size / 2 - cy)
+ placed = Image.new('RGBA', raw.size, (0, 0, 0, 0))
+ placed.paste(cut, (dx, dy), cut)
+
+ flat = Image.new('RGB', raw.size, field)
+ flat.paste(placed, (0, 0), placed)
+
+ radius = reach(placed)
+ log(
+ f'master {size}px\n'
+ f' field {field} -> #{field[0]:02X}{field[1]:02X}{field[2]:02X}'
+ ' (flattened; the source vignettes)\n'
+ f' mark {ink}, reaches {round(radius)}px from the canvas centre'
+ f' ({round(2 * radius / size * 100)}% as a diameter)\n'
+ f' moved {dx:+d}, {dy:+d}px — nudge {NUDGE_PCT:+.1f}% ({nudge:+d}px)\n'
+ )
+ return flat, placed, field, radius
+
+
+def resize(im: Image.Image, size: int) -> Image.Image:
+ return im.resize((size, size), Image.LANCZOS)
+
+
+def write(im: Image.Image, path: str) -> None:
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ im.save(path)
+ log(f' {os.path.relpath(path, ROOT)} {im.size[0]}px')
+
+
+# ─────────────────────────────────────────────────────────────────── iOS
+
+def build_ios(flat: Image.Image) -> None:
+ out = os.path.join(ROOT, 'ios/Runner/Assets.xcassets/AppIcon.appiconset')
+ contents = json.load(open(os.path.join(out, 'Contents.json')))
+
+ log('iOS')
+ seen = set()
+ for entry in contents['images']:
+ name = entry.get('filename')
+ if not name or name in seen:
+ continue
+ seen.add(name)
+ pt = float(entry['size'].split('x')[0])
+ px = int(round(pt * int(entry['scale'].rstrip('x'))))
+ write(resize(flat, px), os.path.join(out, name))
+
+
+# ─────────────────────────────────────────────────────────────── Android
+
+ANDROID_DENSITIES = {
+ 'mdpi': 48,
+ 'hdpi': 72,
+ 'xhdpi': 96,
+ 'xxhdpi': 144,
+ 'xxxhdpi': 192,
+}
+
+
+def build_android_legacy(flat: Image.Image) -> None:
+ """The pre-adaptive icons, still used on API 24-25 — this app's floor.
+
+ Square is the artwork as drawn. Round is the artwork cropped to the canvas
+ circle, which keeps the crimson as a ring around the mark rather than
+ cropping into it.
+ """
+ res = os.path.join(ROOT, 'android/app/src/main/res')
+ log('Android legacy')
+ for density, size in ANDROID_DENSITIES.items():
+ square = resize(flat, size)
+ write(square, os.path.join(res, f'mipmap-{density}/ic_launcher.png'))
+
+ rnd = square.convert('RGBA')
+ rnd.putalpha(circle_mask(size, size))
+ write(rnd, os.path.join(res, f'mipmap-{density}/ic_launcher_round.png'))
+
+
+def build_android_adaptive(placed: Image.Image, radius: float) -> None:
+ """The mark alone, at the largest size the safe circle allows.
+
+ [radius] is the mark's reach from the canvas centre with the nudge already
+ in it, so pushing the mark sideways shrinks it a little rather than walking
+ the arrow tip out past the mask.
+ """
+ res = os.path.join(ROOT, 'android/app/src/main/res')
+
+ target_d = FG_PX * SAFE_DP / CANVAS_DP
+ scale = target_d / (2 * radius)
+ side = max(1, round(placed.width * scale))
+ small = placed.resize((side, side), Image.LANCZOS)
+
+ fg = Image.new('RGBA', (FG_PX, FG_PX), (0, 0, 0, 0))
+ off = (FG_PX - side) // 2
+ fg.paste(small, (off, off), small)
+
+ # Same silhouette, colour discarded. The system replaces the RGB and keeps
+ # only the alpha, but a defined black beats whatever is in the buffer.
+ a = fg.split()[3]
+ black = Image.new('L', fg.size, 0)
+ mono = Image.merge('RGBA', (black, black, black, a))
+
+ log(f'Android adaptive (mark at {SAFE_DP:.0f}dp of {CANVAS_DP:.0f})')
+ write(fg, os.path.join(res, 'drawable/dm_mark_fg.png'))
+ write(mono, os.path.join(res, 'drawable/dm_mark_mono.png'))
+
+
+def build_android_xml(field: tuple[int, int, int]) -> None:
+ """Points the adaptive icon at the crimson field and the monochrome mark."""
+ res = os.path.join(ROOT, 'android/app/src/main/res')
+ hexed = f'#{field[0]:02X}{field[1]:02X}{field[2]:02X}'
+
+ colours = os.path.join(res, 'values/colors.xml')
+ text = open(colours).read()
+ entry = (
+ " \n"
+ f' {hexed}\n'
+ )
+ if 'dm_icon_bg' in text:
+ text = re.sub(
+ r'[ \t]*#[0-9A-Fa-f]{6,8}\n',
+ f' {hexed}\n',
+ text,
+ )
+ else:
+ text = text.replace('', entry + '')
+ open(colours, 'w').write(text)
+ log(f'Android colours\n dm_icon_bg = {hexed}')
+
+ xml = '''
+
+
+
+
+
+
+'''
+ for name in ('ic_launcher.xml', 'ic_launcher_round.xml'):
+ path = os.path.join(res, 'mipmap-anydpi-v26', name)
+ open(path, 'w').write(xml)
+ log(f' {os.path.relpath(path, ROOT)}')
+
+
+# ─────────────────────────────────────────────────────────────────── Web
+
+def build_web(flat: Image.Image, field: tuple[int, int, int]) -> None:
+ out = os.path.join(ROOT, 'web')
+ log('Web')
+ for size in (192, 512):
+ write(resize(flat, size), os.path.join(out, f'icons/Icon-{size}.png'))
+
+ # Maskable icons are cropped the way Android's are, so the artwork is
+ # padded down to leave the same margin.
+ inner = int(round(size * SAFE_DP / CANVAS_DP))
+ pad = Image.new('RGB', (size, size), field)
+ off = (size - inner) // 2
+ pad.paste(resize(flat, inner), (off, off))
+ write(pad, os.path.join(out, f'icons/Icon-maskable-{size}.png'))
+
+ favicon = Image.open(os.path.join(out, 'favicon.png'))
+ write(resize(flat, favicon.width), os.path.join(out, 'favicon.png'))
+
+
+def main() -> None:
+ flat, placed, field, radius = build_master()
+ build_ios(flat)
+ build_android_legacy(flat)
+ build_android_adaptive(placed, radius)
+ build_android_xml(field)
+ build_web(flat, field)
+ log('\ndone')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/web/favicon.png b/web/favicon.png
index 8aaa46a..ffa2ba6 100644
Binary files a/web/favicon.png and b/web/favicon.png differ
diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png
index b749bfe..013cb58 100644
Binary files a/web/icons/Icon-192.png and b/web/icons/Icon-192.png differ
diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png
index 88cfd48..73701cf 100644
Binary files a/web/icons/Icon-512.png and b/web/icons/Icon-512.png differ
diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png
index eb9b4d7..a5c2446 100644
Binary files a/web/icons/Icon-maskable-192.png and b/web/icons/Icon-maskable-192.png differ
diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png
index d69c566..10597c9 100644
Binary files a/web/icons/Icon-maskable-512.png and b/web/icons/Icon-maskable-512.png differ