Replace the default Flutter icon with a stylised cloud

The app shipped the Flutter template's blue F, which said nothing about what it
does and did not match the interface. The launcher icon is now a white cloud on
the same seed blue the app themes itself with, so the two read as one product.

The icon is drawn rather than hand-edited: tool/generate_icon.py renders it from
signed distance fields — a union of three circles and a rounded box — using only
the standard library, so regenerating it needs no image toolchain. The shape's
framing is derived from its own bounding box, which is what keeps it centred;
maintaining the bounds separately from the shape had it drifting off-centre.

Adds the adaptive icon and the Android 13 monochrome layer, which were missing
entirely. The generator frames the cloud against the 72dp the launcher mask
always keeps and adaptive_icon_foreground_inset is 0, so the layer is not shrunk
twice and the icon reads at the same size as its neighbours on a home screen.

Verified on the emulator: the cloud appears in the app drawer and holds its shape
down to 48px. docs/architecture.md records how to regenerate it, including the
flutter_launcher_icons 0.14.4 bug that writes a string into a boolean Xcode
setting and needs reverting by hand afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 22:44:33 +02:00
co-authored by Claude Opus 5
parent aa91b457ed
commit f434a68155
46 changed files with 473 additions and 81 deletions
+225
View File
@@ -0,0 +1,225 @@
"""Draw the Nuvolari launcher icon: a stylised cloud.
Produces two 1024x1024 sources, which `flutter_launcher_icons` then fans out to
every Android density, the Android adaptive icon and the iOS set:
app/assets/icon/icon.png white cloud on the app blue, full bleed
app/assets/icon/icon_foreground.png the cloud alone, transparent
The foreground is drawn smaller on purpose. An Android adaptive icon layer is
108dp of which only the central 72dp is guaranteed to survive the launcher's
mask, so a cloud drawn edge to edge would lose its outer puffs to a circle.
This file owns that framing: flutter_launcher_icons is configured with
`adaptive_icon_foreground_inset: 0` so it does not shrink the layer a second
time on top of what is drawn here.
Shapes are rendered from signed distance fields rather than by supersampling: a
cloud is a union of circles and one rounded box, the distance to that union is
exact, and turning distance into coverage gives clean antialiasing at one
sample per pixel. That keeps this to the standard library — no Pillow, no
cairo, nothing for anyone to install.
Usage:
python tool/generate_icon.py
"""
from __future__ import annotations
import math
import pathlib
import struct
import zlib
SIZE = 1024
OUT_DIR = pathlib.Path(__file__).resolve().parent.parent / "app" / "assets" / "icon"
# The app's seed colour, so the icon and the interface are recognisably the same
# product. Keep these in step with ColorScheme.fromSeed in lib/main.dart.
BACKGROUND = (0x1F, 0x6F, 0xB2)
CLOUD = (0xFF, 0xFF, 0xFF)
# How much of the canvas width the cloud spans.
#
# The full icon can run closer to the edges: iOS and the legacy Android icon are
# square and unmasked.
FULL_WIDTH = 0.72
# The adaptive layer is measured against the 72dp the mask always keeps, not
# against the 108dp canvas, so the two icons read at the same size on a home
# screen. 0.72 of that safe zone leaves the widest points of the cloud at 0.82
# of the circle's radius — clear of a circular mask, and of the rounder masks
# some launchers use.
SAFE_ZONE = 72 / 108
ADAPTIVE_WIDTH = 0.72 * SAFE_ZONE
# --- the cloud ---------------------------------------------------------------
#
# Defined in its own arbitrary coordinate space. The bounding box is computed
# from these numbers rather than written alongside them, so the shape can be
# tuned without the framing silently going wrong — which is exactly what
# happened when the two were maintained separately.
# (centre x, centre y, half width, half height, corner radius)
BODY = (0.500, 0.560, 0.255, 0.075, 0.075)
# (centre x, centre y, radius)
#
# The trailing puff rests on the base line: its lowest point is exactly the
# bottom of the body, so the outline runs from the arc into the flat base
# without turning back on itself. Left higher, the union re-enters and leaves a
# visible notch at the bottom left.
PUFFS = (
(0.415, 0.470, 0.140), # main puff, left of centre and largest
(0.585, 0.495, 0.110), # shoulder puff, higher and smaller
(0.290, BODY[1] + BODY[3] - 0.088, 0.088), # trailing puff, on the base
)
def cloud_bounds() -> tuple[float, float, float, float]:
"""Axis-aligned bounds of the whole cloud: (min x, min y, max x, max y)."""
cx, cy, hw, hh, _ = BODY
min_x, min_y = cx - hw, cy - hh
max_x, max_y = cx + hw, cy + hh
for px, py, r in PUFFS:
min_x = min(min_x, px - r)
min_y = min(min_y, py - r)
max_x = max(max_x, px + r)
max_y = max(max_y, py + r)
return min_x, min_y, max_x, max_y
# --- signed distance fields --------------------------------------------------
def circle_sdf(x: float, y: float, cx: float, cy: float, r: float) -> float:
return math.hypot(x - cx, y - cy) - r
def rounded_box_sdf(
x: float,
y: float,
cx: float,
cy: float,
half_w: float,
half_h: float,
radius: float,
) -> float:
dx = abs(x - cx) - (half_w - radius)
dy = abs(y - cy) - (half_h - radius)
outside = math.hypot(max(dx, 0.0), max(dy, 0.0))
inside = min(max(dx, dy), 0.0)
return outside + inside - radius
def cloud_sdf(x: float, y: float) -> float:
"""Distance to the cloud: the union of the body and the puffs.
The asymmetry is deliberate — a cloud built from equal circles reads as a
flower.
"""
distance = rounded_box_sdf(x, y, *BODY)
for puff in PUFFS:
distance = min(distance, circle_sdf(x, y, *puff))
return distance
# --- rendering ---------------------------------------------------------------
def coverage(distance_px: float) -> float:
"""Turns a signed distance in pixels into pixel coverage.
A half-pixel band across the boundary is all the antialiasing a shape this
smooth needs.
"""
return min(max(0.5 - distance_px, 0.0), 1.0)
def render(size: int, width_fraction: float, opaque_background: bool) -> list[bytes]:
"""Renders the icon, returning one RGBA bytes row per scanline.
[width_fraction] is how much of the canvas width the cloud spans. The cloud
is centred on its own bounding box, so changing the shape above cannot leave
it drifting off-centre.
"""
min_x, min_y, max_x, max_y = cloud_bounds()
cloud_width = max_x - min_x
cloud_cx = (min_x + max_x) / 2
cloud_cy = (min_y + max_y) / 2
# Canvas pixel -> cloud space.
pixels_per_unit = size * width_fraction / cloud_width
rows: list[bytes] = []
for py in range(size):
row = bytearray()
sy = (py + 0.5 - size / 2) / pixels_per_unit + cloud_cy
for px in range(size):
sx = (px + 0.5 - size / 2) / pixels_per_unit + cloud_cx
# Distance in cloud units, converted to pixels so the antialiasing
# band stays one pixel wide at any output size.
alpha = coverage(cloud_sdf(sx, sy) * pixels_per_unit)
if opaque_background:
r = round(BACKGROUND[0] + (CLOUD[0] - BACKGROUND[0]) * alpha)
g = round(BACKGROUND[1] + (CLOUD[1] - BACKGROUND[1]) * alpha)
b = round(BACKGROUND[2] + (CLOUD[2] - BACKGROUND[2]) * alpha)
row += bytes((r, g, b, 255))
else:
row += bytes((CLOUD[0], CLOUD[1], CLOUD[2], round(alpha * 255)))
rows.append(bytes(row))
return rows
def write_png(path: pathlib.Path, size: int, rows: list[bytes]) -> None:
def chunk(kind: bytes, data: bytes) -> bytes:
return (
struct.pack(">I", len(data))
+ kind
+ data
+ struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
)
raw = b"".join(b"\x00" + row for row in rows) # filter type 0 per scanline
png = (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw, 9))
+ chunk(b"IEND", b"")
)
path.write_bytes(png)
def main() -> int:
OUT_DIR.mkdir(parents=True, exist_ok=True)
full = OUT_DIR / "icon.png"
write_png(full, SIZE, render(SIZE, FULL_WIDTH, opaque_background=True))
print(f"{full.name}: {full.stat().st_size / 1024:.0f} KB")
foreground = OUT_DIR / "icon_foreground.png"
write_png(
foreground,
SIZE,
render(SIZE, ADAPTIVE_WIDTH, opaque_background=False),
)
print(f"{foreground.name}: {foreground.stat().st_size / 1024:.0f} KB")
print(
"\nbackground #%02X%02X%02X cloud #%02X%02X%02X"
% (*BACKGROUND, *CLOUD)
)
print("now run: flutter pub run flutter_launcher_icons")
return 0
if __name__ == "__main__":
raise SystemExit(main())