Stage 1: project setup, Exercise Library, both Mode designs and the Mode switch

- Flutter Android app on CookIT's stack (provider, shared_preferences, gen-l10n)
- tools/exercise-import turns free-exercise-db into 876 bundled Exercises
  with 1,746 WebP images (20 MB); stretching -> Stretching, rest -> Workout
- ZenStyle tokens per Mode: calm rounded sage/teal with breath ripples vs.
  dark ember-to-red with floor-tape slashes; the theme morphs at each
  Mode's own pace
- Remembered global Mode switch, Plans / Library / History tabs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CedricandClaude Opus 5.5 committed 2026-10-28 23:18:03 +01:00
1 parent 5dfa995d55
commit 1289fadb98
1822 files changed
+2819

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
# Exercise import
Builds the bundled Exercise Library in `assets/exercises/` from
[free-exercise-db](https://github.com/yuhonas/free-exercise-db) (Unlicense).
```sh
git clone --depth 1 https://github.com/yuhonas/free-exercise-db.git <somewhere outside the repo>
python -m venv .venv
.venv/Scripts/python -m pip install -r requirements.txt
.venv/Scripts/python convert.py <somewhere outside the repo>/free-exercise-db
.venv/Scripts/python -m pytest
```
The script slims `dist/exercises.json`, assigns each Exercise its Mode (`stretching` category →
Stretching, everything else → Workout) and converts every image to a WebP of at most 480 px,
flattened to `images/<id>_<n>.webp`. Rerunning it replaces the previous output.
+25
View File
@@ -0,0 +1,25 @@
"""Usage: python convert.py <free-exercise-db checkout> [output folder]
The output folder defaults to the app's assets/exercises.
"""
import collections
import sys
from pathlib import Path
from exercise_import import run
def main() -> None:
if len(sys.argv) < 2:
sys.exit(__doc__)
source = Path(sys.argv[1])
output = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(__file__).resolve().parents[2] / "assets" / "exercises"
exercises = run(source, output)
modes = collections.Counter(e["mode"] for e in exercises)
size = sum(f.stat().st_size for f in (output / "images").glob("*.webp"))
print(f"{len(exercises)} Exercises ({dict(modes)}), images {size / 1_000_000:.1f} MB -> {output}")
if __name__ == "__main__":
main()
+69
View File
@@ -0,0 +1,69 @@
"""Turns a checkout of free-exercise-db into ZenXercise's bundled Exercise Library.
Writes `exercises.json` (slimmed, with each Exercise's Mode) and one WebP per
image into the output folder, flattened to `images/<id>_<n>.webp` because
Flutter assets are declared per folder.
"""
from __future__ import annotations
import json
from pathlib import Path
from PIL import Image
STRETCHING_CATEGORY = "stretching"
MAX_EDGE = 480
WEBP_QUALITY = 72
def mode_of(category: str) -> str:
"""Category `stretching` belongs to Stretching Mode, everything else to Workout Mode."""
return "stretching" if category == STRETCHING_CATEGORY else "workout"
def slim(exercise: dict) -> dict:
"""The fields the app needs, with images reduced to a count."""
return {
"id": exercise["id"],
"name": exercise["name"],
"mode": mode_of(exercise["category"]),
"category": exercise["category"],
"level": exercise["level"],
"equipment": exercise.get("equipment"),
"force": exercise.get("force"),
"mechanic": exercise.get("mechanic"),
"primaryMuscles": exercise["primaryMuscles"],
"secondaryMuscles": exercise["secondaryMuscles"],
"instructions": [line.strip() for line in exercise["instructions"] if line.strip()],
"imageCount": len(exercise["images"]),
}
def image_name(exercise_id: str, index: int) -> str:
return f"{exercise_id}_{index}.webp"
def convert_image(source: Path, target: Path) -> None:
with Image.open(source) as image:
image = image.convert("RGB")
image.thumbnail((MAX_EDGE, MAX_EDGE))
image.save(target, "WEBP", quality=WEBP_QUALITY, method=6)
def run(source_root: Path, output_root: Path) -> list[dict]:
exercises = json.loads((source_root / "dist" / "exercises.json").read_text(encoding="utf-8"))
images = output_root / "images"
images.mkdir(parents=True, exist_ok=True)
for stale in images.glob("*.webp"):
stale.unlink()
slimmed = sorted((slim(e) for e in exercises), key=lambda e: e["name"].lower())
for exercise in exercises:
for index, relative in enumerate(exercise["images"]):
convert_image(source_root / "exercises" / relative, images / image_name(exercise["id"], index))
(output_root / "exercises.json").write_text(
json.dumps(slimmed, ensure_ascii=False, separators=(",", ":")), encoding="utf-8"
)
return slimmed
+3
View File
@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
pythonpath = .
+2
View File
@@ -0,0 +1,2 @@
Pillow>=11
pytest>=8
@@ -0,0 +1,50 @@
import json
from PIL import Image
from exercise_import import mode_of, run, slim
RAW = {
"id": "Calf_Stretch",
"name": "Calf Stretch",
"force": "static",
"level": "beginner",
"mechanic": None,
"equipment": "body only",
"primaryMuscles": ["calves"],
"secondaryMuscles": [],
"instructions": ["Stand tall. ", "", "Lean forward."],
"category": "stretching",
"images": ["Calf_Stretch/0.jpg", "Calf_Stretch/1.jpg"],
}
def test_stretching_category_is_stretching_mode_and_others_workout():
assert mode_of("stretching") == "stretching"
for category in ["strength", "plyometrics", "cardio", "powerlifting"]:
assert mode_of(category) == "workout"
def test_slim_keeps_needed_fields_and_cleans_instructions():
result = slim(RAW)
assert result["mode"] == "stretching"
assert result["instructions"] == ["Stand tall.", "Lean forward."]
assert result["imageCount"] == 2
assert "images" not in result
def test_run_writes_json_and_flattened_small_webps(tmp_path):
source = tmp_path / "db"
(source / "dist").mkdir(parents=True)
(source / "exercises" / "Calf_Stretch").mkdir(parents=True)
(source / "dist" / "exercises.json").write_text(json.dumps([RAW]), encoding="utf-8")
for i in range(2):
Image.new("RGB", (850, 567), "white").save(source / "exercises" / "Calf_Stretch" / f"{i}.jpg")
out = tmp_path / "out"
run(source, out)
written = json.loads((out / "exercises.json").read_text(encoding="utf-8"))
assert [e["id"] for e in written] == ["Calf_Stretch"]
with Image.open(out / "images" / "Calf_Stretch_1.webp") as image:
assert max(image.size) == 480