── The splash, in three beats ── Crimson edge to edge with the truck running across it in white; the invert; then the mark. The red starts before Flutter does. Four surfaces painted white before any Dart runs — launch_background at both API levels, windowSplashScreenBackground on Android 12+ in light and dark, and the iOS launch storyboard — and leaving any one of them white makes the launch a white flash followed by a red one. That flash is the only part of a launch a customer consciously notices. The invert is one gesture rather than a fade. A white truck on a background turning white is an invisible truck, so the ground lightens as the truck darkens, off one controller, and the screen turns itself inside out with the truck still on it. Fading it would have left the screen empty for the moment before the mark. The truck's colour is no longer its own: it is painted through srcIn, so the file's palette is discarded and only alpha survives. A replacement Lottie now needs no preparation, and tool/lottie_brand.py is off this path. `splash.json` is a seamless 3.9s loop — frame 0 is frame 60, the truck never arrives or departs — so there is no completion to hand over on. _truckBeat is a decision about how long a launch may hold somebody, not a property of the file. The comment claiming six seconds was wrong on both counts. ── Three things the splash was getting wrong quietly ── It showed the wrong logo: doormile-icon.png, the previous mark, to a customer who had just tapped the new one. tool/icons.py now cuts doormile-mark.png from the same master alpha as the launcher icon, so they cannot drift again. The fallback loader was invisible on red. _RoadLoader painted in DmColors.brand on what used to be a white screen; on crimson that is crimson on crimson, and it drew nothing at all on exactly the devices that had fallen back to it. The mark appeared and left in the same frame — _minimum was the sum of the beats exactly, so the clock ran out as the entrance finished. Hence _markHold. ── The truck was not in the middle ── Not a layout bug: both beats sit in a Center and always did. The artwork is drawn low and to the right inside its own 500x500 composition, so a centred widget rendered an off-centre picture — 30pt right, 36pt down. splash_centring_test.dart renders a frame at phone size and density, finds the ink and fails if either beat drifts. It is the only form of test that could have caught this, and the one that will catch it again when the Lottie is replaced, which is when the correction goes stale. Two things it taught: one enormous pump does not let the splash's async start-up chain advance, and capturing at pixelRatio 1 rasterizes the speed lines too faintly to detect, which truncates the bounding box and moves the measured centre by 12pt. ── The destination sheet stops resizing ── Tapping ONE TOUCH opened a tall sheet that snapped shorter a few frames later. DmAsyncList renders four skeleton rows while it loads — 302pt — and the states that replace them are nearer 200; the sheet was Flexible, so it was as tall as whichever state its content happened to be in, and the modal is still animating up while that swap happens. The list now lives in a box of one height. That also removes a second resize: the sheet grew from 48% of the screen to 74% when a state was picked. Both steps now measure 64% and it never changes size again. And DmAsyncList takes initialItems, fed by AppState.cachedCities: FutureBuilder reports `waiting` on its first build even for an already-complete future, so a warm cache still flashed a skeleton over data it already had. Three skeleton rows here rather than four — sheet_stability_test caught that 302pt overflows the smallest box the clamp can produce.
461 lines
18 KiB
Python
461 lines
18 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.
|
|
|
|
**The splash** gets the mark on its own, crimson on transparent. Not a
|
|
separate export: the splash used to show a logo
|
|
file that was nobody's job to keep in step, and by the time the launcher icon
|
|
was redrawn it was showing the previous mark to a customer who had just tapped
|
|
the new one. Both come off this master's alpha, so they cannot drift.
|
|
|
|
**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'))
|
|
|
|
|
|
# How much of the mark's own canvas is margin, in the splash cut-outs.
|
|
#
|
|
# Tighter than the launcher icon, which has a mask eating its edges. Nothing
|
|
# crops these — the splash sizes them directly — so the margin only has to stop
|
|
# the strokes touching the box.
|
|
MARK_PAD = 0.06
|
|
|
|
|
|
def build_marks(placed: Image.Image, field: tuple[int, int, int]) -> None:
|
|
"""The mark alone, on transparent, in the field's own crimson.
|
|
|
|
Cropped to the ink and re-padded, so the splash can size the file and get
|
|
the mark at that size rather than at whatever fraction of it the master's
|
|
own padding left over.
|
|
"""
|
|
out = os.path.join(ROOT, 'assets/images')
|
|
alpha = placed.split()[3]
|
|
box = alpha.point(lambda a: 255 if a > 8 else 0).getbbox()
|
|
if box is None:
|
|
raise SystemExit('no mark to cut')
|
|
|
|
x0, y0, x1, y1 = box
|
|
side = max(x1 - x0, y1 - y0)
|
|
pad = round(side * MARK_PAD)
|
|
side += pad * 2
|
|
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
|
|
cut = alpha.crop((
|
|
round(cx - side / 2), round(cy - side / 2),
|
|
round(cx + side / 2), round(cy + side / 2),
|
|
)).resize((1024, 1024), Image.LANCZOS)
|
|
|
|
log('Splash marks')
|
|
for name, rgb in (('doormile-mark.png', field),):
|
|
layer = Image.merge('RGBA', (
|
|
Image.new('L', cut.size, rgb[0]),
|
|
Image.new('L', cut.size, rgb[1]),
|
|
Image.new('L', cut.size, rgb[2]),
|
|
cut,
|
|
))
|
|
write(layer, os.path.join(out, name))
|
|
|
|
|
|
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_marks(placed, field)
|
|
build_web(flat, field)
|
|
log('\ndone')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|