diff --git a/GLOSSARY.md b/GLOSSARY.md index a3fc82f..edf456f 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -11,7 +11,7 @@ app: design, Exercise Library, Plans and History. The last-used Mode is remember _Avoid_: Tab, section, category **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**: The energetic Mode: dark design, orange/red on near-black, bold type, snappy transitions. diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..945dfda Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a5c57ce Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml index f74085f..ab7b2e7 100644 --- a/android/app/src/main/res/drawable-v21/launch_background.xml +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -1,12 +1,5 @@ - + - - - - + diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..0984003 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..ec9463a Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..1d6f855 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml index 304732f..ab7b2e7 100644 --- a/android/app/src/main/res/drawable/launch_background.xml +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -1,12 +1,5 @@ - + - - - - + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c79c58a --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index db77bb4..eb458e0 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 17987b7..e46c937 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 09d4391..645d405 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d5f1c8d..aa47ae1 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 4d6372e..3386a2e 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..e092aba --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #F1EEF6 + #F1EEF6 + \ No newline at end of file diff --git a/assets/icon/icon.png b/assets/icon/icon.png new file mode 100644 index 0000000..5ac7172 Binary files /dev/null and b/assets/icon/icon.png differ diff --git a/assets/icon/icon_foreground.png b/assets/icon/icon_foreground.png new file mode 100644 index 0000000..b2492b4 Binary files /dev/null and b/assets/icon/icon_foreground.png differ diff --git a/docs/adr/0002-library-ui-and-player.md b/docs/adr/0002-library-ui-and-player.md index 0d4b820..0ae0a2b 100644 --- a/docs/adr/0002-library-ui-and-player.md +++ b/docs/adr/0002-library-ui-and-player.md @@ -13,7 +13,7 @@ Status: Accepted (2026-10-28) 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. 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. 7. **Player cues**: Visual cues (countdown ring, accent pulse, next cards, auto-advance, screen kept on) plus a gentle, non-annoying sound. No vibration. diff --git a/docs/adr/0007-app-icon.md b/docs/adr/0007-app-icon.md new file mode 100644 index 0000000..5f5d16a --- /dev/null +++ b/docs/adr/0007-app-icon.md @@ -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. diff --git a/lib/src/design/zen_style.dart b/lib/src/design/zen_style.dart index e7e787d..e8a83e2 100644 --- a/lib/src/design/zen_style.dart +++ b/lib/src/design/zen_style.dart @@ -74,16 +74,17 @@ class ZenStyle extends ThemeExtension { static const stretching = ZenStyle( mode: Mode.stretching, brightness: Brightness.light, - background: Color(0xFFEDF3EF), + // Lavender mist and a muted violet, as in the app icon. + background: Color(0xFFF1EEF6), surface: Color(0xFFFFFFFF), - surfaceSoft: Color(0xFFDCEAE2), - ink: Color(0xFF1E3832), - inkMuted: Color(0xFF587069), - accent: Color(0xFF2E8B80), - accentEnd: Color(0xFF2E8B80), + surfaceSoft: Color(0xFFE4DEEF), + ink: Color(0xFF2A2338), + inkMuted: Color(0xFF6A6279), + accent: Color(0xFF6B5B95), + accentEnd: Color(0xFF6B5B95), onAccent: Color(0xFFFFFFFF), - line: Color(0xFFA8C9B4), - navBar: Color(0xFFD3E5DA), + line: Color(0xFFC3B8DA), + navBar: Color(0xFFE0D9EC), radius: 28, controlRadius: 999, motion: Duration(milliseconds: 700), diff --git a/pubspec.lock b/pubspec.lock index 1ec51be..8b8003a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.1.1" + archive: + dependency: transitive + description: + name: archive + sha256: "0d58d1fa1fddf6588251ccf2819f3fb3b6d0452c1b2813ac7413a05592bfbefe" + url: "https://pub.dev" + source: hosted + version: "4.5.2" args: dependency: transitive description: @@ -294,6 +302,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: @@ -397,6 +413,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "89ed75b2f8231fe676808c5564f9590d728024ded7ac782a7ed8474f1c95b72a" + url: "https://pub.dev" + source: hosted + version: "4.12.0" intl: dependency: "direct main" description: @@ -613,6 +637,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.3" + posix: + dependency: transitive + description: + name: posix + sha256: "613646d076944f3acc125830af935b48d4b8a57a388691547bbfd25b434f4d63" + url: "https://pub.dev" + source: hosted + version: "6.5.4" provider: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 1e15bc0..1e9a7e5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,6 +31,7 @@ dev_dependencies: drift_dev: ^2.31.0 build_runner: ^2.15.1 sqlite3: ^2.9.4 + flutter_launcher_icons: ^0.14.4 flutter: uses-material-design: true @@ -84,3 +85,12 @@ flutter: weight: 600 - asset: assets/fonts/Barlow-Bold.ttf 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 diff --git a/tools/icon/make_icon.py b/tools/icon/make_icon.py new file mode 100644 index 0000000..a325469 --- /dev/null +++ b/tools/icon/make_icon.py @@ -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()