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:
1 parent
5dfa995d55
commit
1289fadb98
1822 files changed
+2819
No files matched your search
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
pythonpath = .
|
||||
@@ -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
|
||||
Reference in new issue
Block a user