Pages fall away downward, the pickup comes back, and a real icon set

── Navigation ──

Pages arrived from the right and left to the right. Right is the direction
the system back gesture drags, so a page leaving under a button press read as
a swipe nobody made — and every push in this app is a layer over the one
before it, which arrives from below. The axis is vertical now: the incoming
page rises and covers, the page underneath stays put and dims, and back drops
the top page off the bottom. Full travel rather than the old 14% nudge, which
is what makes the direction nameable. test/page_transition_test.dart holds it.

── Home ──

The pickup pill is back under the greeting. It was cut on the argument that
the form below already carried the pickup; the form is four hundred points
down the screen, and a location you can only see by scrolling to a form is a
location you do not trust. It shows the locality while the form keeps the
door, so the screen answers one question once, and it now has a resolving
state — it used to print the last place you were, confidently, while the fix
was still in flight.

The sphere's caption was falling off the screen. Adding the pill pushed it out
of the scroll viewport, silently, with no overflow warning — the fourth time
Home growing has clipped something under the sphere. DmBookOrb.field is no
longer a constant: the glow gives way to whatever is left, the sphere never
does.

── 74 points of dead canvas, on every root ──

Home, Orders and Account each cleared the floating tab bar by
`MediaQuery.paddingOf(context).bottom + 74`, on the reasoning that the inset is
the home indicator and 74 is the bar above it. Scaffold with extendBody
replaces the body's bottom padding with the bar's whole laid-out height —
measured, a 34pt inset and this bar hand the body 106. They were clearing the
bar, then clearing it again. One helper now, dmTabBarClear().

── One journey, one vocabulary ──

Review said ORIGIN/DESTINATION; Home's form and tracking say PICKUP/DROP.
Three screens a customer walks in order. The tracking card had already written
down the argument; the booking screen hadn't followed.

The rail's first milestone read "Booking created" directly above "Order
created" — down the rail: created, created, in transit. It is "Pickup booked",
which is what JourneyStage.milestoneLabel has called that stage all along on
Orders and on Home's live row.

── The launcher icon ──

tool/icons.py renders all twenty-eight files from one master, because the
failure mode of exporting by hand is nineteen replaced and nine left on the
old mark, at the density nobody checks.

The master is a bare mark on a field, so it cannot be dropped into Android's
adaptive canvas whole: crimson is the background, the white mark is the
foreground, and it is sized by its reach from the canvas centre so a round
mask cannot take the arrow tip. Measured on the built file: 63.7dp against a
66dp safe zone. A real monochrome layer replaces the colour foreground that
was standing in for one — the system tints by alpha, so that themed as a blob.
ic_launcher_round.png exists at last; minSdk is 24 and android:roundIcon had
nothing to resolve to below API 26.

The artwork is flattened (the source vignettes over ten values of red) and its
horizontal offset is placed deliberately rather than inherited — geometric
centring made the mark lean, because the arrow carries the bounding box right
while contributing almost none of the ink.

── Bundle ──

pubspec declared assets/images/ as a folder, which shipped the 974 KB icon
master to every customer for a file no code opens. Named explicitly now.
This commit is contained in:
2026-09-25 13:34:52 +05:30
parent 8956e818a4
commit 49b0de0d8f
72 changed files with 897 additions and 92 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@@ -1,15 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Doormile mark on white.
<!-- The Doormile mark, as three layers. Generated by tool/icons.py.
The foreground was a house drawn as two vector paths — a placeholder that
shipped, and the reason the launcher showed a generic home icon next to
the name Doormile. It is the real mark now, rendered from
`assets/images/doormile-icon.png` into the 66dp safe zone.
background The crimson field, as a colour rather than a bitmap — it is
flat, and a launcher that parallaxes the layers should slide a
colour rather than resample an image.
White background rather than brand: the mark carries its own crimson ring,
and on a maroon field that ring disappears into it. -->
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. -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/dm_surface" />
<background android:drawable="@color/dm_icon_bg" />
<foreground android:drawable="@drawable/dm_mark_fg" />
<monochrome android:drawable="@drawable/dm_mark_fg" />
<monochrome android:drawable="@drawable/dm_mark_mono" />
</adaptive-icon>

View File

@@ -1,15 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Doormile mark on white.
<!-- The Doormile mark, as three layers. Generated by tool/icons.py.
The foreground was a house drawn as two vector paths — a placeholder that
shipped, and the reason the launcher showed a generic home icon next to
the name Doormile. It is the real mark now, rendered from
`assets/images/doormile-icon.png` into the 66dp safe zone.
background The crimson field, as a colour rather than a bitmap — it is
flat, and a launcher that parallaxes the layers should slide a
colour rather than resample an image.
White background rather than brand: the mark carries its own crimson ring,
and on a maroon field that ring disappears into it. -->
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. -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/dm_surface" />
<background android:drawable="@color/dm_icon_bg" />
<foreground android:drawable="@drawable/dm_mark_fg" />
<monochrome android:drawable="@drawable/dm_mark_fg" />
<monochrome android:drawable="@drawable/dm_mark_mono" />
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -3,4 +3,8 @@
<!-- Doormile brand tokens, mirrored from lib/ui/tokens.dart -->
<color name="dm_brand">#8F0F06</color>
<color name="dm_surface">#FFFFFF</color>
<!-- The launcher icon's field. Read off the master artwork by
tool/icons.py, not typed here: if the logo is redrawn in a
different crimson, the adaptive background follows it. -->
<color name="dm_icon_bg">#BD0921</color>
</resources>

Binary file not shown.

After

Width:  |  Height:  |  Size: 952 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 KiB

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 705 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@@ -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'),

View File

@@ -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,
),
);
}

View File

@@ -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),

View File

@@ -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.

View File

@@ -135,7 +135,10 @@ class _HomeScreenState extends State<HomeScreen> {
// 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<HomeScreen> {
),
],
),
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<HomeScreen> {
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<HomeScreen> {
// 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<HomeScreen> {
).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 ──

View File

@@ -133,7 +133,7 @@ class _OrdersScreenState extends State<OrdersScreen> {
DmSpace.pad,
0,
DmSpace.pad,
74 + MediaQuery.paddingOf(context).bottom,
dmTabBarClear(context),
),
itemCount: rows.isEmpty ? 1 : rows.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),

View File

@@ -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);

View File

@@ -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<DmBookOrb> createState() => _DmBookOrbState();
}
@@ -132,8 +164,8 @@ class _DmBookOrbState extends State<DmBookOrb> 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<DmBookOrb> with TickerProviderStateMixin {
child: AnimatedBuilder(
animation: _radar,
builder: (_, _) => CustomPaint(
size: const Size.square(field),
size: Size.square(field),
painter: _RadarPainter(_radar.value, disc / 2),
),
),

View File

@@ -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:

View File

@@ -151,7 +151,7 @@ Future<void> pickCity(
Future<void> 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);
});

View File

@@ -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);

View File

@@ -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<void> 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<void>(
'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<NavigatorState>(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<void>('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',
);
});
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 KiB

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 185 KiB

411
tool/icons.py Normal file
View File

@@ -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 = (
" <!-- The launcher icon's field, sampled off the master artwork by\n"
' tool/icons.py rather than typed here: redraw the logo in a\n'
' different crimson and the adaptive background follows it.\n'
' Deliberately its own token, not dm_brand — this is the icon,\n'
" and the UI's brand red is a separate decision. -->\n"
f' <color name="dm_icon_bg">{hexed}</color>\n'
)
if 'dm_icon_bg' in text:
text = re.sub(
r'[ \t]*<color name="dm_icon_bg">#[0-9A-Fa-f]{6,8}</color>\n',
f' <color name="dm_icon_bg">{hexed}</color>\n',
text,
)
else:
text = text.replace('</resources>', entry + '</resources>')
open(colours, 'w').write(text)
log(f'Android colours\n dm_icon_bg = {hexed}')
xml = '''<?xml version="1.0" encoding="utf-8"?>
<!-- The Doormile mark, as three layers. Generated by tool/icons.py.
background The crimson field, as a colour rather than a bitmap — it is
flat, and a launcher that parallaxes the layers should slide a
colour rather than resample an image.
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. -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/dm_icon_bg" />
<foreground android:drawable="@drawable/dm_mark_fg" />
<monochrome android:drawable="@drawable/dm_mark_mono" />
</adaptive-icon>
'''
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()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 B

After

Width:  |  Height:  |  Size: 563 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 18 KiB