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
+3
View File
@@ -77,6 +77,9 @@ grounds that there are no ads today.
generated by tool/generate_places.py. Offline search, no geocoding service. The source generated by tool/generate_places.py. Offline search, no geocoding service. The source
DBF is UTF-8 despite appearances and the geometry is UTM 32N — see docs/data-sources.md DBF is UTF-8 despite appearances and the geometry is UTM 32N — see docs/data-sources.md
before regenerating. before regenerating.
- Launcher icon: a white cloud on the seed blue #1F6FB2, drawn by tool/generate_icon.py
and expanded by flutter_launcher_icons. Read the "Launcher icon" section of
docs/architecture.md before regenerating: the tool corrupts an Xcode build setting.
## Verification loop (run after each milestone) ## Verification loop (run after each milestone)
- `dart format .` ; `flutter analyze` ; `flutter test` ; `flutter build appbundle` - `dart format .` ; `flutter analyze` ; `flutter test` ; `flutter build appbundle`
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="0%" />
</foreground>
<monochrome>
<inset
android:drawable="@drawable/ic_launcher_monochrome"
android:inset="0%" />
</monochrome>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 776 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1F6FB2</color>
</resources>
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

@@ -1,122 +1,158 @@
{ {
"images" : [ "images": [
{ {
"size" : "20x20", "size": "20x20",
"idiom" : "iphone", "idiom": "iphone",
"filename" : "Icon-App-20x20@2x.png", "filename": "Icon-App-20x20@2x.png",
"scale" : "2x" "scale": "2x"
}, },
{ {
"size" : "20x20", "size": "20x20",
"idiom" : "iphone", "idiom": "iphone",
"filename" : "Icon-App-20x20@3x.png", "filename": "Icon-App-20x20@3x.png",
"scale" : "3x" "scale": "3x"
}, },
{ {
"size" : "29x29", "size": "29x29",
"idiom" : "iphone", "idiom": "iphone",
"filename" : "Icon-App-29x29@1x.png", "filename": "Icon-App-29x29@1x.png",
"scale" : "1x" "scale": "1x"
}, },
{ {
"size" : "29x29", "size": "29x29",
"idiom" : "iphone", "idiom": "iphone",
"filename" : "Icon-App-29x29@2x.png", "filename": "Icon-App-29x29@2x.png",
"scale" : "2x" "scale": "2x"
}, },
{ {
"size" : "29x29", "size": "29x29",
"idiom" : "iphone", "idiom": "iphone",
"filename" : "Icon-App-29x29@3x.png", "filename": "Icon-App-29x29@3x.png",
"scale" : "3x" "scale": "3x"
}, },
{ {
"size" : "40x40", "size": "40x40",
"idiom" : "iphone", "idiom": "iphone",
"filename" : "Icon-App-40x40@2x.png", "filename": "Icon-App-40x40@2x.png",
"scale" : "2x" "scale": "2x"
}, },
{ {
"size" : "40x40", "size": "40x40",
"idiom" : "iphone", "idiom": "iphone",
"filename" : "Icon-App-40x40@3x.png", "filename": "Icon-App-40x40@3x.png",
"scale" : "3x" "scale": "3x"
}, },
{ {
"size" : "60x60", "size": "57x57",
"idiom" : "iphone", "idiom": "iphone",
"filename" : "Icon-App-60x60@2x.png", "filename": "Icon-App-57x57@1x.png",
"scale" : "2x" "scale": "1x"
}, },
{ {
"size" : "60x60", "size": "57x57",
"idiom" : "iphone", "idiom": "iphone",
"filename" : "Icon-App-60x60@3x.png", "filename": "Icon-App-57x57@2x.png",
"scale" : "3x" "scale": "2x"
}, },
{ {
"size" : "20x20", "size": "60x60",
"idiom" : "ipad", "idiom": "iphone",
"filename" : "Icon-App-20x20@1x.png", "filename": "Icon-App-60x60@2x.png",
"scale" : "1x" "scale": "2x"
}, },
{ {
"size" : "20x20", "size": "60x60",
"idiom" : "ipad", "idiom": "iphone",
"filename" : "Icon-App-20x20@2x.png", "filename": "Icon-App-60x60@3x.png",
"scale" : "2x" "scale": "3x"
}, },
{ {
"size" : "29x29", "size": "20x20",
"idiom" : "ipad", "idiom": "ipad",
"filename" : "Icon-App-29x29@1x.png", "filename": "Icon-App-20x20@1x.png",
"scale" : "1x" "scale": "1x"
}, },
{ {
"size" : "29x29", "size": "20x20",
"idiom" : "ipad", "idiom": "ipad",
"filename" : "Icon-App-29x29@2x.png", "filename": "Icon-App-20x20@2x.png",
"scale" : "2x" "scale": "2x"
}, },
{ {
"size" : "40x40", "size": "29x29",
"idiom" : "ipad", "idiom": "ipad",
"filename" : "Icon-App-40x40@1x.png", "filename": "Icon-App-29x29@1x.png",
"scale" : "1x" "scale": "1x"
}, },
{ {
"size" : "40x40", "size": "29x29",
"idiom" : "ipad", "idiom": "ipad",
"filename" : "Icon-App-40x40@2x.png", "filename": "Icon-App-29x29@2x.png",
"scale" : "2x" "scale": "2x"
}, },
{ {
"size" : "76x76", "size": "40x40",
"idiom" : "ipad", "idiom": "ipad",
"filename" : "Icon-App-76x76@1x.png", "filename": "Icon-App-40x40@1x.png",
"scale" : "1x" "scale": "1x"
}, },
{ {
"size" : "76x76", "size": "40x40",
"idiom" : "ipad", "idiom": "ipad",
"filename" : "Icon-App-76x76@2x.png", "filename": "Icon-App-40x40@2x.png",
"scale" : "2x" "scale": "2x"
}, },
{ {
"size" : "83.5x83.5", "size": "50x50",
"idiom" : "ipad", "idiom": "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png", "filename": "Icon-App-50x50@1x.png",
"scale" : "2x" "scale": "1x"
}, },
{ {
"size" : "1024x1024", "size": "50x50",
"idiom" : "ios-marketing", "idiom": "ipad",
"filename" : "Icon-App-1024x1024@1x.png", "filename": "Icon-App-50x50@2x.png",
"scale" : "1x" "scale": "2x"
},
{
"size": "72x72",
"idiom": "ipad",
"filename": "Icon-App-72x72@1x.png",
"scale": "1x"
},
{
"size": "72x72",
"idiom": "ipad",
"filename": "Icon-App-72x72@2x.png",
"scale": "2x"
},
{
"size": "76x76",
"idiom": "ipad",
"filename": "Icon-App-76x76@1x.png",
"scale": "1x"
},
{
"size": "76x76",
"idiom": "ipad",
"filename": "Icon-App-76x76@2x.png",
"scale": "2x"
},
{
"size": "83.5x83.5",
"idiom": "ipad",
"filename": "Icon-App-83.5x83.5@2x.png",
"scale": "2x"
},
{
"size": "1024x1024",
"idiom": "ios-marketing",
"filename": "Icon-App-1024x1024@1x.png",
"scale": "1x"
} }
], ],
"info" : { "info": {
"version" : 1, "version": 1,
"author" : "xcode" "author": "xcode"
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 299 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 554 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 857 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 431 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 845 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 554 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

+40
View File
@@ -41,6 +41,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@@ -134,6 +150,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev"
source: hosted
version: "0.14.4"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -269,6 +293,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.20.3" version: "0.20.3"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
url: "https://pub.dev"
source: hosted
version: "4.12.0"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -698,6 +730,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" version: "7.0.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
url: "https://pub.dev"
source: hosted
version: "3.1.4"
sdks: sdks:
dart: ">=3.13.3 <4.0.0" dart: ">=3.13.3 <4.0.0"
flutter: ">=3.44.0" flutter: ">=3.44.0"
+35
View File
@@ -33,6 +33,11 @@ dev_dependencies:
sdk: flutter sdk: flutter
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
# Fans the two generated sources out to every Android density, the adaptive
# icon and the iOS set. Build-time only: it writes files into android/ and
# ios/, which are committed, so nothing here ships in the app.
flutter_launcher_icons: ^0.14.4
flutter: flutter:
uses-material-design: true uses-material-design: true
@@ -45,3 +50,33 @@ flutter:
# the app runnable offline with no credentials. # the app runnable offline with no credentials.
- assets/mock/ - assets/mock/
- assets/mock/frames/ - assets/mock/frames/
# Launcher icon, generated from the sources that tool/generate_icon.py draws.
# Re-run after changing them:
#
# python ../tool/generate_icon.py
# dart run flutter_launcher_icons
flutter_launcher_icons:
android: true
ios: true
image_path: assets/icon/icon.png
# Adaptive icon: the launcher masks the foreground to its own shape, so the
# cloud is drawn inside the safe zone and the app blue fills whatever the mask
# leaves. Kept identical to ColorScheme.fromSeed in lib/main.dart.
adaptive_icon_background: "#1F6FB2"
adaptive_icon_foreground: assets/icon/icon_foreground.png
# The generator already frames the cloud inside the 72dp safe zone. Left at
# its default of 16, this would shrink the layer again and the icon would read
# noticeably smaller than every other one on the home screen.
adaptive_icon_foreground_inset: 0
# Android 13 themed icons tint the alpha of this layer with the user's
# wallpaper palette. The foreground is a flat white silhouette, so its alpha
# is already exactly the cloud.
adaptive_icon_monochrome: assets/icon/icon_foreground.png
# iOS rejects an alpha channel in app icons. The source is opaque anyway;
# this drops the channel itself.
remove_alpha_ios: true
+36 -1
View File
@@ -33,7 +33,7 @@ Nuvolari/
├─ app/ Flutter application (Dart package "nuvolari") ├─ app/ Flutter application (Dart package "nuvolari")
├─ backend/ Python worker ├─ backend/ Python worker
├─ docs/ this documentation ├─ docs/ this documentation
└─ tool/ verification scripts └─ tool/ verification and asset-generation scripts
``` ```
## App layers ## App layers
@@ -131,3 +131,38 @@ subscribing to FCM topics named after geographic cells — the subscription happ
device, so the backend holds no user records at all. With no advertising and no forecast device, so the backend holds no user records at all. With no advertising and no forecast
provider, nothing about the user leaves the device except the area the map is looking at, provider, nothing about the user leaves the device except the area the map is looking at,
which is inherent to any hosted base map. which is inherent to any hosted base map.
## Launcher icon
The icon is a white cloud on the app's seed blue `#1F6FB2`, so the launcher and the
interface are recognisably the same product. It is drawn, not hand-edited:
`tool/generate_icon.py` renders it from signed distance fields — a union of three
circles and one rounded box — and writes two 1024px sources into `app/assets/icon/`.
Those sources are not listed in the pubspec `assets:` block: they feed the build and are
never bundled into the app.
`flutter_launcher_icons` fans them out to the Android densities, the adaptive icon, the
Android 13 monochrome layer and the iOS set, all of which are committed.
To change it:
```
python tool/generate_icon.py
cd app && dart run flutter_launcher_icons
```
Two things to know before doing that.
**The generator owns the framing.** An adaptive layer is 108dp of which only the central
72dp survives the launcher's mask, so `ADAPTIVE_WIDTH` is measured against that safe zone
and `adaptive_icon_foreground_inset` is set to `0`. Left at its default of 16 the tool
would inset the layer a second time and the icon would read visibly smaller than every
other one on the home screen.
**flutter_launcher_icons 0.14.4 corrupts an unrelated Xcode setting.** `ios.dart:340`
rewrites the value of any line containing `ASSETCATALOG`, so it writes `AppIcon` into
`ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS`, which is a boolean.
Check `git diff app/ios/Runner.xcodeproj/project.pbxproj` after running it and restore
those lines to `YES`. It also minifies `AppIcon.appiconset/Contents.json` onto one line;
re-indenting it keeps the file reviewable.
+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())