App icon and a lavender Stretching palette

A lotus inside a dry-brush ensō, with a small plate in the gap and a small
barbell as the lifting hint. Drawn by tools/icon/make_icon.py and wired up
with flutter_launcher_icons; the launch screen uses the icon's lavender.
Stretching Mode moves from sage/teal to the icon's lavender and violet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CedricandClaude Opus 5.5 committed 2026-10-29 01:14:37 +01:00
1 parent 1ffe6269ea
commit 1deb2a43d0
23 files changed
+330 -28

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@ app: design, Exercise Library, Plans and History. The last-used Mode is remember
_Avoid_: Tab, section, category _Avoid_: Tab, section, category
**Stretching Mode**: **Stretching Mode**:
The calm Mode: light design, sage/teal, rounded shapes, slow transitions. The calm Mode: light design, lavender and violet, rounded shapes, slow transitions.
**Workout Mode**: **Workout Mode**:
The energetic Mode: dark design, orange/red on near-black, bold type, snappy transitions. The energetic Mode: dark design, orange/red on near-black, bold type, snappy transitions.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -1,12 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen --> <!-- The launch screen: the app icon's lavender mist, until Flutter draws its first frame. -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" /> <item android:drawable="@color/launch_background" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list> </layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

@@ -1,12 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen --> <!-- The launch screen: the app icon's lavender mist, until Flutter draws its first frame. -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" /> <item android:drawable="@color/launch_background" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list> </layer-list>
@@ -0,0 +1,9 @@
<?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="16%" />
</foreground>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

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

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

+1 -1
View File
@@ -13,7 +13,7 @@ Status: Accepted (2026-10-28)
Stretching = hold, both-sides flag, rounds. Workout Steps can be grouped into Supersets. Stretching = hold, both-sides flag, rounds. Workout Steps can be grouped into Supersets.
5. **Library browsing**: search + muscle/equipment/level filters, reused as the Plan exercise picker. 5. **Library browsing**: search + muscle/equipment/level filters, reused as the Plan exercise picker.
6. **Structure & design**: Mode switch at top; per-mode tabs Plans · Library · History. 6. **Structure & design**: Mode switch at top; per-mode tabs Plans · Library · History.
Stretching = calm/light (sage/teal, rounded, slow). Workout = dark/energetic (orange/red, bold, snappy). Stretching = calm/light (lavender/violet since ADR 0007, first sage/teal; rounded, slow). Workout = dark/energetic (orange/red, bold, snappy).
Last-used mode is remembered. Last-used mode is remembered.
7. **Player cues**: Visual cues (countdown ring, accent pulse, next cards, auto-advance, screen kept on) 7. **Player cues**: Visual cues (countdown ring, accent pulse, next cards, auto-advance, screen kept on)
plus a gentle, non-annoying sound. No vibration. plus a gentle, non-annoying sound. No vibration.
+23
View File
@@ -0,0 +1,23 @@
# ADR 0007 — App icon: a lotus in a Zen circle, with a hint of lifting
Status: Accepted (2026-10-29)
1. **Motif**: a lotus inside an ensō (Zen brush circle). Yoga and calm lead; lifting is only a hint:
a small weight plate floating in the circle's open gap, and a small barbell under the lotus.
2. **Brush**: the ensō is painted counter-clockwise from the top, with its gap at the upper right.
It starts round and solid with calm, nearly smooth edges, then runs dry: pale streaks open inside
the stroke and the bristles give out at different points, so the tail frays out close to the plate.
The circle is never closed.
3. **Colours**: a calm purple palette: lavender mist ground `#F1EEF6`, muted violet `#6B5B95` for the
ensō and the lotus's centre petal, light violet `#A496C8` for the side petals, and a soft apricot
`#E98A6F` only for the plate and the barbell.
4. **Stretching follows the icon**: Stretching Mode's palette moved from sage/teal to the same lavender
and violet, so the app and its icon match. Workout keeps its dark ember design.
5. **Style**: flat shapes, no text, no gradients or outlines.
6. **Format**: Android adaptive icon (foreground on a solid lavender background, masked by the launcher)
plus a legacy square PNG, wired up with `flutter_launcher_icons` as in CookIT.
7. **Launch screen**: the Android launch background uses the same lavender mist as the icon.
The icon is generated by `tools/icon/make_icon.py` (seeded, so it redraws identically): run it, then
`dart run flutter_launcher_icons`. Rejected along the way: a dumbbell or barbell as the main element,
a plate closing the circle's gap, a seated figure, and heavier, more textured brush strokes.
+9 -8
View File
@@ -74,16 +74,17 @@ class ZenStyle extends ThemeExtension<ZenStyle> {
static const stretching = ZenStyle( static const stretching = ZenStyle(
mode: Mode.stretching, mode: Mode.stretching,
brightness: Brightness.light, brightness: Brightness.light,
background: Color(0xFFEDF3EF), // Lavender mist and a muted violet, as in the app icon.
background: Color(0xFFF1EEF6),
surface: Color(0xFFFFFFFF), surface: Color(0xFFFFFFFF),
surfaceSoft: Color(0xFFDCEAE2), surfaceSoft: Color(0xFFE4DEEF),
ink: Color(0xFF1E3832), ink: Color(0xFF2A2338),
inkMuted: Color(0xFF587069), inkMuted: Color(0xFF6A6279),
accent: Color(0xFF2E8B80), accent: Color(0xFF6B5B95),
accentEnd: Color(0xFF2E8B80), accentEnd: Color(0xFF6B5B95),
onAccent: Color(0xFFFFFFFF), onAccent: Color(0xFFFFFFFF),
line: Color(0xFFA8C9B4), line: Color(0xFFC3B8DA),
navBar: Color(0xFFD3E5DA), navBar: Color(0xFFE0D9EC),
radius: 28, radius: 28,
controlRadius: 999, controlRadius: 999,
motion: Duration(milliseconds: 700), motion: Duration(milliseconds: 700),
+32
View File
@@ -17,6 +17,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.1.1" version: "8.1.1"
archive:
dependency: transitive
description:
name: archive
sha256: "0d58d1fa1fddf6588251ccf2819f3fb3b6d0452c1b2813ac7413a05592bfbefe"
url: "https://pub.dev"
source: hosted
version: "4.5.2"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -294,6 +302,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:
@@ -397,6 +413,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: "89ed75b2f8231fe676808c5564f9590d728024ded7ac782a7ed8474f1c95b72a"
url: "https://pub.dev"
source: hosted
version: "4.12.0"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -613,6 +637,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.3" version: "1.5.3"
posix:
dependency: transitive
description:
name: posix
sha256: "613646d076944f3acc125830af935b48d4b8a57a388691547bbfd25b434f4d63"
url: "https://pub.dev"
source: hosted
version: "6.5.4"
provider: provider:
dependency: "direct main" dependency: "direct main"
description: description:
+10
View File
@@ -31,6 +31,7 @@ dev_dependencies:
drift_dev: ^2.31.0 drift_dev: ^2.31.0
build_runner: ^2.15.1 build_runner: ^2.15.1
sqlite3: ^2.9.4 sqlite3: ^2.9.4
flutter_launcher_icons: ^0.14.4
flutter: flutter:
uses-material-design: true uses-material-design: true
@@ -84,3 +85,12 @@ flutter:
weight: 600 weight: 600
- asset: assets/fonts/Barlow-Bold.ttf - asset: assets/fonts/Barlow-Bold.ttf
weight: 700 weight: 700
# Drawn by tools/icon/make_icon.py; regenerate with `dart run flutter_launcher_icons`.
flutter_launcher_icons:
android: true
ios: false
image_path: assets/icon/icon.png
adaptive_icon_background: "#F1EEF6"
adaptive_icon_foreground: assets/icon/icon_foreground.png
min_sdk_android: 21
+236
View File
@@ -0,0 +1,236 @@
"""Draws the ZenXercise app icon: a lotus inside a dry-brush ensō, with a hint of lifting.
Usage: python make_icon.py [output folder] (default: assets/icon)
Writes icon.png (legacy square icon on the lavender ground) and icon_foreground.png
(transparent adaptive-icon foreground, kept inside the launcher's safe zone).
Everything is drawn at 4x and scaled down, which smooths the edges. All randomness is
seeded, so the icon is identical on every run.
"""
from __future__ import annotations
import math
import random
import sys
from pathlib import Path
from PIL import Image, ImageDraw
SIZE = 1024
SCALE = 4
# Stretching's palette.
GROUND = (241, 238, 246, 255) # Lavender mist: the icon's background and the launch screen
VIOLET = (107, 91, 149, 255) # The ensō and the lotus's centre petal
VIOLET_LIGHT = (164, 150, 200, 255) # Side petals: a tonal step of the violet
APRICOT = (233, 138, 111, 255) # The lifting hint only: the plate and the small barbell
def _s(v: float) -> float:
return v * SCALE
class _Noise:
"""Smooth 1-D value noise in -1..1."""
def __init__(self, seed: int, size: int = 512):
rng = random.Random(seed)
self.values = [rng.uniform(-1, 1) for _ in range(size)]
def __call__(self, x: float) -> float:
i = math.floor(x)
f = x - i
a = self.values[i % len(self.values)]
b = self.values[(i + 1) % len(self.values)]
return a + (b - a) * (1 - math.cos(math.pi * f)) / 2
def dry_brush_enso(
draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, width: float, gap_deg: float, gap_at: float
):
"""An ensō painted counter-clockwise from beside the gap.
The opening of [gap_deg] degrees is centred on [gap_at] (degrees, 0 = right, counter-clockwise).
The brush lands with a round start and calm, nearly smooth edges; from halfway it runs dry:
its bristles stop overlapping, so pale streaks open inside the stroke, and the bristles give
out at different points, fraying the tail.
"""
serration, nicks, swell, wander = _Noise(3), _Noise(29), _Noise(11), _Noise(19)
rng = random.Random(13)
start = math.radians(gap_at + gap_deg / 2)
sweep = math.radians(360 - gap_deg)
dry = 0.5 # Where the brush starts to run out of ink.
def angle(t: float) -> float:
return start + sweep * t
def centre(t: float) -> float:
return r * (1 + 0.01 * wander(t * 3))
def stroke_width(t: float) -> float:
return width * (0.93 + 0.1 * swell(t * 3.5)) * (1 - 0.28 * (max(0.0, t - dry) / (1 - dry)) ** 1.6)
def point(t: float, d: float) -> tuple[float, float]:
a = angle(t)
return (_s(cx + d * math.cos(a)), _s(cy - d * math.sin(a)))
def ragged(t: float, side: int) -> float:
"""A faint irregularity on both edges, and the rare small nick on the outside."""
fine = 0.006 * serration(t * 40 + side * 31) + 0.002 * serration(t * 120 + side * 77)
nick = max(0.0, nicks(t * 9 + side * 5) - 0.8) * (0.03 if side > 0 else 0.0)
return (fine - nick) * min(1.0, t / 0.08)
steps = 1400
# Solid body, overlapping the bristles where they take over, with a round start.
ts = [i * (dry + 0.06) / steps for i in range(steps + 1)]
outer = [point(t, centre(t) + stroke_width(t) * (0.5 + ragged(t, 1))) for t in ts]
inner = [point(t, centre(t) - stroke_width(t) * (0.5 + ragged(t, -1))) for t in ts]
draw.polygon(outer + inner[::-1], fill=VIOLET)
a0, w0, d0 = angle(0.0), stroke_width(0.0), centre(0.0)
x0, y0 = cx + d0 * math.cos(a0), cy - d0 * math.sin(a0)
draw.ellipse([_s(x0 - w0 / 2), _s(y0 - w0 / 2), _s(x0 + w0 / 2), _s(y0 + w0 / 2)], fill=VIOLET)
# Bristles from the dry point on.
count = 15
for k in range(count):
frac = -0.5 + (k + 0.5) / count
edge = abs(frac) * 2
ink = rng.random()
end = 0.95 - 0.05 * rng.random() - 0.16 * edge**1.6 # Edge bristles give out sooner.
skips = []
for _ in range(rng.randint(0, 2)):
s0 = dry + 0.22 + rng.random() * max(0.0, end - dry - 0.3)
skips.append((s0, s0 + 0.008 + rng.random() * 0.025 * (1.2 - ink)))
phase = rng.random() * math.tau
def edges(t: float) -> tuple[float, float]:
w = stroke_width(t)
dried = max(0.0, (t - dry) / (end - dry))
# Full overlap while inked; separating as the bristle dries out.
half = 0.5 / count * (1.8 - 1.25 * dried ** (0.7 + ink)) * max(0.0, min(1.0, (end - t) / 0.12)) ** 0.8
# Lifting off the paper and touching down again: taper into and out of each skip.
for a, b in skips:
near = max(a - t, t - b, 0.0)
half *= min(1.0, near / 0.018) ** 0.7
splay = w * 0.005 * dried * math.sin(2 * math.pi * 6 * t + phase)
mid = centre(t) + w * frac + splay
# Never past the stroke's own edges, so the body hands over without a step.
top = min(mid + w * half, centre(t) + w * (0.5 + ragged(t, 1)))
bottom = max(mid - w * half, centre(t) - w * (0.5 + ragged(t, -1)))
return top, min(bottom, top)
run: list[float] = []
def flush():
if len(run) > 1:
draw.polygon([point(t, edges(t)[0]) for t in run] + [point(t, edges(t)[1]) for t in reversed(run)], fill=VIOLET)
run.clear()
for i in range(steps + 1):
t = dry + (end - dry) * i / steps
if any(a <= t <= b for a, b in skips):
flush()
else:
run.append(t)
flush()
def plate(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, ground):
"""A weight plate seen from the front: a disc with a centre hole."""
draw.ellipse([_s(cx - r), _s(cy - r), _s(cx + r), _s(cy + r)], fill=APRICOT)
hole = r * 0.3
draw.ellipse([_s(cx - hole), _s(cy - hole), _s(cx + hole), _s(cy + hole)], fill=ground)
class Pen:
"""Draws in 1024-unit coordinates around the centre, shrunk by [scale]."""
def __init__(self, draw: ImageDraw.ImageDraw, scale: float):
self.draw = draw
self.scale = scale
def p(self, x: float, y: float) -> tuple[float, float]:
c = SIZE / 2
return (_s(c + (x - c) * self.scale), _s(c + (y - c) * self.scale))
def w(self, v: float) -> float:
return _s(v * self.scale)
def circle(self, x, y, r, fill):
cx, cy = self.p(x, y)
rr = self.w(r)
self.draw.ellipse([cx - rr, cy - rr, cx + rr, cy + rr], fill=fill)
def limb(self, points, width, fill):
"""A thick stroke through [points] with round ends and joints."""
pts = [self.p(x, y) for x, y in points]
self.draw.line(pts, fill=fill, width=int(self.w(width)), joint="curve")
for x, y in points:
self.circle(x, y, width / 2, fill)
def bar(self, x0, x1, y, thickness, fill, plates=1.0):
"""A horizontal barbell: the bar, a big and a small plate at each end, sized by [plates]."""
self.limb([(x0, y), (x1, y)], thickness, fill)
for side, end in ((-1, x0), (1, x1)):
for offset, w, h in ((-28 * plates, 34 * plates, 120 * plates), (4 * plates, 24 * plates, 84 * plates)):
px = end - side * offset
a, b = self.p(px - w / 2, y - h / 2)
c, d = self.p(px + w / 2, y + h / 2)
self.draw.rounded_rectangle([a, b, c, d], radius=self.w(w / 2.4), fill=fill)
def petal(self, bx, by, length, width, angle_deg, fill):
"""An almond-shaped petal growing from (bx, by) towards [angle_deg] (90 = up)."""
a = math.radians(angle_deg)
ux, uy = math.cos(a), -math.sin(a)
nx, ny = -uy, ux
left, right = [], []
steps = 80
for i in range(steps + 1):
t = i / steps
half = width / 2 * math.sin(math.pi * t) ** 0.75 * (1 - 0.25 * t)
x, y = bx + ux * length * t, by + uy * length * t
left.append(self.p(x + nx * half, y + ny * half))
right.append(self.p(x - nx * half, y - ny * half))
self.draw.polygon(left + right[::-1], fill=fill)
def lotus(pen: Pen, base: float) -> None:
"""Five petals growing from (512, [base]): a deep centre, lighter sides."""
for angle, length, width, fill in (
(162, 250, 150, VIOLET_LIGHT),
(18, 250, 150, VIOLET_LIGHT),
(128, 300, 160, VIOLET_LIGHT),
(52, 300, 160, VIOLET_LIGHT),
(90, 360, 190, VIOLET),
):
pen.petal(512, base, length, width, angle, fill)
def draw_icon(ground, scale: float) -> Image.Image:
"""The motif on [ground]; [scale] shrinks it to fit the adaptive safe zone."""
image = Image.new("RGBA", (SIZE * SCALE, SIZE * SCALE), ground)
draw = ImageDraw.Draw(image)
c = SIZE / 2
r = 300 * scale
plate_at = 58 # Upper right, in the ensō's open gap.
# The stroke starts 29° past the plate and runs on until its dry tail gives out close to it.
dry_brush_enso(draw, c, c, r, 74 * scale, gap_deg=44, gap_at=plate_at + 7)
lotus(Pen(draw, scale * 0.7), base=660)
Pen(draw, scale * 0.7).bar(382, 642, 730, 16, APRICOT, plates=0.5)
a = math.radians(plate_at)
plate(draw, c + r * math.cos(a), c - r * math.sin(a), 46 * scale, ground)
return image.resize((SIZE, SIZE), Image.LANCZOS)
def main() -> None:
out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).resolve().parents[2] / "assets" / "icon"
out.mkdir(parents=True, exist_ok=True)
# Legacy icon: full bleed on the lavender ground.
draw_icon(GROUND, 1.0).save(out / "icon.png")
# Adaptive foreground: transparent, shrunk into the central safe zone (about 61% of the canvas).
draw_icon((0, 0, 0, 0), 0.68).save(out / "icon_foreground.png")
print(f"icon -> {out}")
if __name__ == "__main__":
main()