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>
@@ -77,6 +77,9 @@ grounds that there are no ads today.
|
||||
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
|
||||
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)
|
||||
- `dart format .` ; `flutter analyze` ; `flutter test` ; `flutter build appbundle`
|
||||
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
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>
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 776 B |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 2.3 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
@@ -1,122 +1,158 @@
|
||||
{
|
||||
"images" : [
|
||||
"images": [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
"size": "20x20",
|
||||
"idiom": "iphone",
|
||||
"filename": "Icon-App-20x20@2x.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@3x.png",
|
||||
"scale" : "3x"
|
||||
"size": "20x20",
|
||||
"idiom": "iphone",
|
||||
"filename": "Icon-App-20x20@3x.png",
|
||||
"scale": "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
"size": "29x29",
|
||||
"idiom": "iphone",
|
||||
"filename": "Icon-App-29x29@1x.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
"size": "29x29",
|
||||
"idiom": "iphone",
|
||||
"filename": "Icon-App-29x29@2x.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@3x.png",
|
||||
"scale" : "3x"
|
||||
"size": "29x29",
|
||||
"idiom": "iphone",
|
||||
"filename": "Icon-App-29x29@3x.png",
|
||||
"scale": "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
"size": "40x40",
|
||||
"idiom": "iphone",
|
||||
"filename": "Icon-App-40x40@2x.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@3x.png",
|
||||
"scale" : "3x"
|
||||
"size": "40x40",
|
||||
"idiom": "iphone",
|
||||
"filename": "Icon-App-40x40@3x.png",
|
||||
"scale": "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@2x.png",
|
||||
"scale" : "2x"
|
||||
"size": "57x57",
|
||||
"idiom": "iphone",
|
||||
"filename": "Icon-App-57x57@1x.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@3x.png",
|
||||
"scale" : "3x"
|
||||
"size": "57x57",
|
||||
"idiom": "iphone",
|
||||
"filename": "Icon-App-57x57@2x.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@1x.png",
|
||||
"scale" : "1x"
|
||||
"size": "60x60",
|
||||
"idiom": "iphone",
|
||||
"filename": "Icon-App-60x60@2x.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
"size": "60x60",
|
||||
"idiom": "iphone",
|
||||
"filename": "Icon-App-60x60@3x.png",
|
||||
"scale": "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
"size": "20x20",
|
||||
"idiom": "ipad",
|
||||
"filename": "Icon-App-20x20@1x.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
"size": "20x20",
|
||||
"idiom": "ipad",
|
||||
"filename": "Icon-App-20x20@2x.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@1x.png",
|
||||
"scale" : "1x"
|
||||
"size": "29x29",
|
||||
"idiom": "ipad",
|
||||
"filename": "Icon-App-29x29@1x.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
"size": "29x29",
|
||||
"idiom": "ipad",
|
||||
"filename": "Icon-App-29x29@2x.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@1x.png",
|
||||
"scale" : "1x"
|
||||
"size": "40x40",
|
||||
"idiom": "ipad",
|
||||
"filename": "Icon-App-40x40@1x.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@2x.png",
|
||||
"scale" : "2x"
|
||||
"size": "40x40",
|
||||
"idiom": "ipad",
|
||||
"filename": "Icon-App-40x40@2x.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||
"scale" : "2x"
|
||||
"size": "50x50",
|
||||
"idiom": "ipad",
|
||||
"filename": "Icon-App-50x50@1x.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon-App-1024x1024@1x.png",
|
||||
"scale" : "1x"
|
||||
"size": "50x50",
|
||||
"idiom": "ipad",
|
||||
"filename": "Icon-App-50x50@2x.png",
|
||||
"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" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
"info": {
|
||||
"version": 1,
|
||||
"author": "xcode"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 299 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 554 B |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 857 B |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 431 B |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 845 B |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 554 B |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 692 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 806 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -41,6 +41,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -134,6 +150,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
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:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -269,6 +293,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -698,6 +730,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
sdks:
|
||||
dart: ">=3.13.3 <4.0.0"
|
||||
flutter: ">=3.44.0"
|
||||
|
||||
@@ -33,6 +33,11 @@ dev_dependencies:
|
||||
sdk: flutter
|
||||
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:
|
||||
uses-material-design: true
|
||||
|
||||
@@ -45,3 +50,33 @@ flutter:
|
||||
# the app runnable offline with no credentials.
|
||||
- assets/mock/
|
||||
- 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
|
||||
|
||||
@@ -33,7 +33,7 @@ Nuvolari/
|
||||
├─ app/ Flutter application (Dart package "nuvolari")
|
||||
├─ backend/ Python worker
|
||||
├─ docs/ this documentation
|
||||
└─ tool/ verification scripts
|
||||
└─ tool/ verification and asset-generation scripts
|
||||
```
|
||||
|
||||
## 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
|
||||
provider, nothing about the user leaves the device except the area the map is looking at,
|
||||
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.
|
||||
|
||||
|
||||
@@ -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())
|
||||