Files
doormile_customer_app/tool/icons.py
Thiru-tenext 49b0de0d8f 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.
2026-09-25 13:34:52 +05:30

412 lines
16 KiB
Python

#!/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()