halkyon_meditate_flutter on halkyon_app and halkyon_ui (ADR 0038): the first start, sign-in and account as every Halkyon app's, a bar below medium windows and a rail with the icon from medium on, fern washes, Settings in cards (Sync, the operator's, Sitting, Practice, the account, What's new, About, the version), English and German. - Today: a greeting by the hour, Sit now at 5, 10, 20 minutes and the timer's own length, the practice of the day, the week with the days in a row, favourites, recently sat (D9). - Explore: six practices of Halkyon's own, written for the app, found by a search without case or accents, by goal and by kind; each page with how it goes, its lengths, its heart and Start (D4, D10). - The sitting, over the tabs: its time from the clock, a ring counting down, the cue of the moment, Pause and End, the bells at the start, between and at the end, the screen kept on; ended early, kept from a minute on where the person says so (D5, D6). - Profile: every Halkyon app's, then the person's practice: minutes, sittings, days, the week and every sitting kept, to delete (D8). - Settings and sittings sealed with the person's key and synced (D7). The bells are synthesised by apps/meditate/tool/bells.py, the icon is Lucide's sprout from cobalt through pacific into fern (tool/app_icons.py). Releases and the deploy are a later step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
226 lines
8.3 KiB
Python
226 lines
8.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Makes the apps' icons, one family (ADR 0031): each app's Lucide glyph on a white
|
|
square, fading from cobalt through the colours APPS names, top left to bottom right
|
|
across Lucide's 24-unit box, the stops evenly spaced, at Lucide's stroke width (2),
|
|
1024 x 1024 pixels (<app>/<flutter package>/assets/icon/<icon>); the launchers and
|
|
the launch screens round its corners themselves. Then, for each app:
|
|
|
|
- Lucide's licence (ISC) beside the icon (assets/icon/LICENSE-lucide.txt), which the
|
|
app registers in its licence list;
|
|
- the snap's icon where the app has a snap (Learn's snap/gui/halkyon-learn.png at the
|
|
repository's root, Todo's apps/todo/snap/gui/halkyon-todo.png; 512 x 512 pixels, the
|
|
Snap Store's size), the icon with rounded corners, as other apps' white tiles
|
|
have (radius 142.222 of 512, about 28 %);
|
|
- through flutter_launcher_icons, configured in the app's pubspec: Android's adaptive
|
|
icon and its older square one, iOS's, macOS's (the rounded tile on Apple's grid,
|
|
824 of 1024 pixels) and Windows' (the rounded tile), from the images derived in
|
|
the app's build/icons/, which git ignores;
|
|
- Windows' .ico once more, with every size Windows shows (16 to 256 pixels): the tool
|
|
writes only the 256 one and leaves Windows to scale it down for its small views;
|
|
- the iOS project file as it was: the tool sets every build setting with
|
|
ASSETCATALOG in its name to the icon set's name, Xcode's yes-or-no
|
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS too, which breaks the
|
|
project (flutter_launcher_icons issue 634); the project names its icon set already.
|
|
|
|
The glyphs come from lucide-static at a pinned release (LUCIDE below), fetched from
|
|
jsDelivr, and are drawn by librsvg, as they have arcs and curves (bike's wheels).
|
|
|
|
Run from the repository's root with Python 3, Pillow, PyGObject with librsvg and
|
|
pycairo (Ubuntu's python3-pil, python3-gi, gir1.2-rsvg-2.0, python3-cairo), the apps'
|
|
dependencies fetched, naming the apps to draw, or none for all of them:
|
|
|
|
python3 -I tool/app_icons.py train
|
|
"""
|
|
|
|
import subprocess
|
|
import sys
|
|
import urllib.request
|
|
from dataclasses import dataclass
|
|
from io import BytesIO
|
|
from pathlib import Path
|
|
|
|
import cairo
|
|
import gi
|
|
|
|
gi.require_version("Rsvg", "2.0")
|
|
from gi.repository import GLib, Rsvg # noqa: E402 (after the version is required)
|
|
from PIL import Image, ImageDraw # noqa: E402
|
|
|
|
LUCIDE = "1.50.0"
|
|
CDN = f"https://cdn.jsdelivr.net/npm/lucide-static@{LUCIDE}"
|
|
|
|
COBALT = "#3C57E5"
|
|
VIOLET = "#8E4FD6"
|
|
STRAWBERRY = "#EC3E7C"
|
|
CARROT = "#F78400"
|
|
PACIFIC = "#179FD9"
|
|
FERN = "#3CBB3A"
|
|
REEF = "#1DA583"
|
|
ENZIAN = "#5252CC"
|
|
PURPLE = "#8080FF"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AppIcon:
|
|
"""An app's icon: where it goes, its glyph and its gradient's colours."""
|
|
|
|
folder: str
|
|
package: str
|
|
icon: str
|
|
glyph: str
|
|
stops: tuple[str, ...]
|
|
snap: str | None = None
|
|
|
|
|
|
# ADR 0031, D5: each from cobalt into its app's backdrop.
|
|
APPS = {
|
|
"learn": AppIcon(
|
|
"apps/learn",
|
|
"halkyon_learn_flutter",
|
|
"halkyon_learn_icon.png",
|
|
"graduation-cap",
|
|
(COBALT, ENZIAN, PURPLE),
|
|
snap="snap/gui/halkyon-learn.png",
|
|
),
|
|
"todo": AppIcon(
|
|
"apps/todo",
|
|
"halkyon_todo_flutter",
|
|
"halkyon_todo_icon.png",
|
|
"list-todo",
|
|
(COBALT, VIOLET, STRAWBERRY),
|
|
snap="apps/todo/snap/gui/halkyon-todo.png",
|
|
),
|
|
"train": AppIcon(
|
|
"apps/train",
|
|
"halkyon_train_flutter",
|
|
"halkyon_train_icon.png",
|
|
"dumbbell",
|
|
(COBALT, VIOLET, STRAWBERRY, CARROT),
|
|
snap="apps/train/snap/gui/halkyon-train.png",
|
|
),
|
|
"meditate": AppIcon(
|
|
"apps/meditate",
|
|
"halkyon_meditate_flutter",
|
|
"halkyon_meditate_icon.png",
|
|
"sprout",
|
|
(COBALT, PACIFIC, FERN),
|
|
),
|
|
}
|
|
|
|
SIZE = 1024
|
|
# The 24-unit box Lucide draws in takes this share of the square, centred: well
|
|
# inside the circle Android's adaptive icons keep (66 %).
|
|
GLYPH = 0.6
|
|
# The corners the launch screen and launchers round to.
|
|
RADIUS = 142.222 / 512
|
|
SUPERSAMPLING = 4
|
|
SNAP_SIZE = 512
|
|
# Apple's grid: the tile is 824 of the icon's 1024 pixels, centred.
|
|
MACOS_CANVAS = 1024
|
|
MACOS_TILE = 824
|
|
WINDOWS_SIZES = [16, 20, 24, 32, 40, 48, 64, 128, 256]
|
|
|
|
|
|
def fetch(name: str) -> str:
|
|
with urllib.request.urlopen(f"{CDN}/{name}", timeout=30) as response:
|
|
return response.read().decode("utf-8")
|
|
|
|
|
|
def rgb(colour: str) -> tuple[float, float, float]:
|
|
return tuple(int(colour[i : i + 2], 16) / 255 for i in (1, 3, 5))
|
|
|
|
|
|
def icon(glyph: str, stops: tuple[str, ...], svg: str) -> Image.Image:
|
|
"""[glyph] on white in the gradient of [stops], SIZE pixels square."""
|
|
if f"lucide-static v{LUCIDE}" not in svg:
|
|
raise SystemExit(f"Not lucide-static {LUCIDE}'s {glyph}.")
|
|
if 'viewBox="0 0 24 24"' not in svg or 'stroke-width="2"' not in svg:
|
|
raise SystemExit(f"{glyph} isn't drawn in Lucide's 24 units at width 2.")
|
|
# The strokes alone, as a mask the gradient shows through.
|
|
handle = Rsvg.Handle.new_from_data(svg.replace("currentColor", "#000").encode())
|
|
box = SIZE * GLYPH
|
|
offset = (SIZE - box) / 2
|
|
mask = cairo.ImageSurface(cairo.FORMAT_ARGB32, SIZE, SIZE)
|
|
viewport = Rsvg.Rectangle()
|
|
viewport.x, viewport.y, viewport.width, viewport.height = offset, offset, box, box
|
|
try:
|
|
handle.render_document(cairo.Context(mask), viewport)
|
|
except GLib.Error as error:
|
|
raise SystemExit(f"librsvg couldn't draw {glyph}: {error.message}")
|
|
|
|
tile = cairo.ImageSurface(cairo.FORMAT_RGB24, SIZE, SIZE)
|
|
context = cairo.Context(tile)
|
|
context.set_source_rgb(1, 1, 1)
|
|
context.paint()
|
|
gradient = cairo.LinearGradient(offset, offset, offset + box, offset + box)
|
|
for i, colour in enumerate(stops):
|
|
gradient.add_color_stop_rgb(i / (len(stops) - 1), *rgb(colour))
|
|
context.set_source(gradient)
|
|
context.mask_surface(mask)
|
|
|
|
png = BytesIO()
|
|
tile.write_to_png(png)
|
|
png.seek(0)
|
|
return Image.open(png).convert("RGB")
|
|
|
|
|
|
def rounded(source: Image.Image, size: int) -> Image.Image:
|
|
"""[source] at [size] pixels, its corners rounded off to transparency."""
|
|
picture = source.convert("RGBA").resize((size, size), Image.LANCZOS)
|
|
large = size * SUPERSAMPLING
|
|
mask = Image.new("L", (large, large), 0)
|
|
ImageDraw.Draw(mask).rounded_rectangle(
|
|
(0, 0, large - 1, large - 1), radius=RADIUS * large, fill=255
|
|
)
|
|
picture.putalpha(mask.resize((size, size), Image.LANCZOS))
|
|
return picture
|
|
|
|
|
|
def make(app: AppIcon, license_text: str) -> None:
|
|
package = Path(app.folder) / app.package
|
|
source = icon(app.glyph, app.stops, fetch(f"icons/{app.glyph}.svg"))
|
|
path = package / "assets/icon" / app.icon
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
source.save(path, optimize=True)
|
|
print(f"Wrote {path}.")
|
|
license_path = package / "assets/icon/LICENSE-lucide.txt"
|
|
license_path.write_text(license_text, encoding="utf-8")
|
|
print(f"Wrote {license_path}.")
|
|
if app.snap:
|
|
rounded(source, SNAP_SIZE).save(app.snap, optimize=True)
|
|
print(f"Wrote {app.snap}.")
|
|
|
|
derived = package / "build/icons"
|
|
derived.mkdir(parents=True, exist_ok=True)
|
|
rounded(source, 1024).save(derived / "rounded.png")
|
|
macos = Image.new("RGBA", (MACOS_CANVAS, MACOS_CANVAS), (0, 0, 0, 0))
|
|
margin = (MACOS_CANVAS - MACOS_TILE) // 2
|
|
macos.alpha_composite(rounded(source, MACOS_TILE), (margin, margin))
|
|
macos.save(derived / "macos.png")
|
|
|
|
ios_project = package / "ios/Runner.xcodeproj/project.pbxproj"
|
|
kept = ios_project.read_bytes()
|
|
subprocess.run(["dart", "run", "flutter_launcher_icons"], cwd=package, check=True)
|
|
ios_project.write_bytes(kept)
|
|
|
|
windows_icon = package / "windows/runner/resources/app_icon.ico"
|
|
rounded(source, 256).save(
|
|
windows_icon, format="ICO", sizes=[(size, size) for size in WINDOWS_SIZES]
|
|
)
|
|
print(f"Wrote {windows_icon} with {len(WINDOWS_SIZES)} sizes.")
|
|
|
|
|
|
def main() -> None:
|
|
names = sys.argv[1:] or list(APPS)
|
|
unknown = [name for name in names if name not in APPS]
|
|
if unknown:
|
|
raise SystemExit(f"No icon for {', '.join(unknown)}: {', '.join(APPS)}.")
|
|
if not Path("tool/app_icons.py").is_file():
|
|
raise SystemExit("Run this from the repository's root.")
|
|
license_text = fetch("LICENSE")
|
|
for name in names:
|
|
make(APPS[name], license_text)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|