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