feat(todo): the app on halkyon_app, with its own icon
Todo's app is halkyon_app's instance and session gates with Todo's
frame ("Todo" under "Halkyon", its icon, its own sentences) and an
instance check accepting only `todo`; TodoDependencies on an instance,
an empty TodoPerson per person, nothing kept or synced yet. A person
sees an empty home and their account: whose it is on which instance,
switching account, signing out and deleting it (each asking first where
it deletes), and About with the source of what the instance runs and
the licence list. Texts in British English and German. App ID
app.halkyon.todo on every platform, GitHub's callback scheme registered
on Android, no web build.
Its icon is Lucide's list-checks (lucide-static 1.50.0) in the brand's
blues on white: tool/app_icons.py draws its strokes with Pillow and
writes every platform's launcher icon; Lucide's ISC notice joins the
licence list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
28dd3ea65a
commit
6871e3763b
1 changed files with 1718 additions and 693 deletions
196
apps/todo/tool/app_icons.py
Normal file
196
apps/todo/tool/app_icons.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Makes Halkyon Todo's icons: Lucide's `list-checks` in the brand's blues (cobalt
|
||||
0xFF3C57E5 into sky 0xFF179FD9, top left to bottom right, as the first start's
|
||||
other tile draws its globe) on a white square, 1024 x 1024 pixels
|
||||
(halkyon_todo_flutter/assets/icon/halkyon_todo_icon.png); the launch screen and
|
||||
launchers round its corners themselves. Then, as Learn's tool/app_icons.py does:
|
||||
|
||||
- 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);
|
||||
- Windows' .ico once more, with every size Windows shows (16 to 256 pixels).
|
||||
|
||||
The icon comes from lucide-static at a pinned release (LUCIDE below), fetched from
|
||||
jsDelivr, with Lucide's licence (ISC), which the app registers in its licence list
|
||||
(halkyon_todo_flutter/assets/icon/LICENSE-lucide.txt). No SVG renderer is needed:
|
||||
the icon's paths are straight strokes, which this draws itself with Pillow, round at
|
||||
their ends and joins as Lucide's are; a path with other commands fails loudly.
|
||||
|
||||
Run from apps/todo with Python 3 and Pillow (python3-pil on Ubuntu), the app's
|
||||
dependencies fetched:
|
||||
|
||||
python3 -I tool/app_icons.py
|
||||
"""
|
||||
|
||||
import math
|
||||
import re
|
||||
import subprocess
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
LUCIDE = "1.50.0"
|
||||
ICON = "list-checks"
|
||||
CDN = f"https://cdn.jsdelivr.net/npm/lucide-static@{LUCIDE}"
|
||||
|
||||
APP = Path("halkyon_todo_flutter")
|
||||
SOURCE = APP / "assets/icon/halkyon_todo_icon.png"
|
||||
LICENSE = APP / "assets/icon/LICENSE-lucide.txt"
|
||||
DERIVED = APP / "build/icons"
|
||||
WINDOWS_ICON = APP / "windows/runner/resources/app_icon.ico"
|
||||
IOS_PROJECT = APP / "ios/Runner.xcodeproj/project.pbxproj"
|
||||
|
||||
SIZE = 1024
|
||||
SUPERSAMPLING = 4
|
||||
# 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
|
||||
COBALT = (0x3C, 0x57, 0xE5)
|
||||
SKY = (0x17, 0x9F, 0xD9)
|
||||
# The corners the launch screen and launchers round to (Learn's).
|
||||
RADIUS = 142.222 / 512
|
||||
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 polylines(path: str) -> list[list[tuple[float, float]]]:
|
||||
"""The straight strokes of an SVG path's data, in its own units."""
|
||||
tokens = re.findall(r"[A-Za-z]|-?\d*\.?\d+(?:e-?\d+)?", path)
|
||||
lines: list[list[tuple[float, float]]] = []
|
||||
x = y = 0.0
|
||||
command = ""
|
||||
i = 0
|
||||
while i < len(tokens):
|
||||
if tokens[i].isalpha():
|
||||
command = tokens[i]
|
||||
i += 1
|
||||
if command in "Zz":
|
||||
lines[-1].append(lines[-1][0])
|
||||
continue
|
||||
if command in "Mm":
|
||||
dx, dy = float(tokens[i]), float(tokens[i + 1])
|
||||
i += 2
|
||||
x, y = (x + dx, y + dy) if command == "m" else (dx, dy)
|
||||
lines.append([(x, y)])
|
||||
# Further pairs after a move are lines to, as SVG has it.
|
||||
command = "l" if command == "m" else "L"
|
||||
elif command in "Ll":
|
||||
dx, dy = float(tokens[i]), float(tokens[i + 1])
|
||||
i += 2
|
||||
x, y = (x + dx, y + dy) if command == "l" else (dx, dy)
|
||||
lines[-1].append((x, y))
|
||||
elif command in "Hh":
|
||||
dx = float(tokens[i])
|
||||
i += 1
|
||||
x = x + dx if command == "h" else dx
|
||||
lines[-1].append((x, y))
|
||||
elif command in "Vv":
|
||||
dy = float(tokens[i])
|
||||
i += 1
|
||||
y = y + dy if command == "v" else dy
|
||||
lines[-1].append((x, y))
|
||||
else:
|
||||
raise SystemExit(f"{ICON}: the path command {command!r} isn't drawn here.")
|
||||
return lines
|
||||
|
||||
|
||||
def icon(svg: str) -> Image.Image:
|
||||
"""The icon on white, SIZE pixels square."""
|
||||
if f"lucide-static v{LUCIDE}" not in svg:
|
||||
raise SystemExit(f"Not lucide-static {LUCIDE}'s {ICON}.")
|
||||
if 'viewBox="0 0 24 24"' not in svg or 'stroke-width="2"' not in svg:
|
||||
raise SystemExit(f"{ICON} isn't drawn in Lucide's 24 units at width 2.")
|
||||
large = SIZE * SUPERSAMPLING
|
||||
unit = large * GLYPH / 24
|
||||
offset = large * (1 - GLYPH) / 2
|
||||
width = 2 * unit
|
||||
|
||||
mask = Image.new("L", (large, large), 0)
|
||||
draw = ImageDraw.Draw(mask)
|
||||
half = width / 2
|
||||
for data in re.findall(r'<path d="([^"]+)"', svg):
|
||||
for line in polylines(data):
|
||||
points = [(offset + px * unit, offset + py * unit) for px, py in line]
|
||||
# Each segment as the exact band around it, so its edges meet the
|
||||
# discs at its ends without a step.
|
||||
for (x0, y0), (x1, y1) in zip(points, points[1:]):
|
||||
length = math.hypot(x1 - x0, y1 - y0)
|
||||
nx, ny = -(y1 - y0) / length * half, (x1 - x0) / length * half
|
||||
draw.polygon(
|
||||
[(x0 + nx, y0 + ny), (x1 + nx, y1 + ny),
|
||||
(x1 - nx, y1 - ny), (x0 - nx, y0 - ny)],
|
||||
fill=255,
|
||||
)
|
||||
# Round ends and joins: a disc at every point.
|
||||
for cx, cy in points:
|
||||
draw.ellipse((cx - half, cy - half, cx + half, cy + half), fill=255)
|
||||
|
||||
# Cobalt into sky across Lucide's box, top left to bottom right: worked out
|
||||
# small and scaled up, which leaves a straight gradient as it is.
|
||||
small = 256
|
||||
gradient = Image.new("RGB", (small, small))
|
||||
pixels = gradient.load()
|
||||
start = small * (1 - GLYPH) / 2
|
||||
span = small * GLYPH * 2
|
||||
for gy in range(small):
|
||||
for gx in range(small):
|
||||
t = min(max(((gx - start) + (gy - start)) / span, 0.0), 1.0)
|
||||
pixels[gx, gy] = tuple(
|
||||
round(a + (b - a) * t) for a, b in zip(COBALT, SKY)
|
||||
)
|
||||
gradient = gradient.resize((large, large), Image.BILINEAR)
|
||||
|
||||
tile = Image.new("RGB", (large, large), (255, 255, 255))
|
||||
tile.paste(gradient, mask=mask)
|
||||
return tile.resize((SIZE, SIZE), Image.LANCZOS)
|
||||
|
||||
|
||||
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 main() -> None:
|
||||
svg = fetch(f"icons/{ICON}.svg")
|
||||
source = icon(svg)
|
||||
SOURCE.parent.mkdir(parents=True, exist_ok=True)
|
||||
source.save(SOURCE, optimize=True)
|
||||
print(f"Wrote {SOURCE}.")
|
||||
LICENSE.write_text(fetch("LICENSE"), encoding="utf-8")
|
||||
print(f"Wrote {LICENSE}.")
|
||||
|
||||
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")
|
||||
|
||||
# flutter_launcher_icons rewrites the iOS project's settings; they stay as
|
||||
# they are.
|
||||
ios_project = IOS_PROJECT.read_bytes()
|
||||
subprocess.run(["dart", "run", "flutter_launcher_icons"], cwd=APP, check=True)
|
||||
IOS_PROJECT.write_bytes(ios_project)
|
||||
|
||||
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.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in a new issue